Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions shelfmark/core/queue.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,11 @@ def handoffs_since(self, since: float) -> list[DownloadTask]:
with self._lock:
return [task for at, task in self._handoffs.values() if at >= since]

def handoff_times_since(self, since: float) -> list[tuple[float, DownloadTask]]:
"""`handoffs_since`, with when each task was handed off."""
with self._lock:
return [(at, task) for at, task in self._handoffs.values() if at >= since]

def forget_handoff(self, task_id: str) -> None:
"""Drop a task's handoff: it never reached the indexer (refused at grab)."""
with self._lock:
Expand Down
7 changes: 5 additions & 2 deletions shelfmark/download/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -1113,8 +1113,11 @@ def _finalize_download_failure(task_id: str) -> None:
# marked as waiting, and is released oldest first once a slot is free: checked before
# each MAM grab, and after a grab fails while the account is at the limit.

UNSAT_WAIT_MESSAGE = "Waiting for an unsatisfied slot on MyAnonamouse"
_UNSAT_RECHECK_SECONDS = 5 * 60
_UNSAT_RECHECK_SECONDS = 2 * 60
UNSAT_WAIT_MESSAGE = (
"Waiting for an unsatisfied slot on MyAnonamouse (checked every "
f"{_UNSAT_RECHECK_SECONDS // 60} minutes)"
)
_held_lock = Lock()
_held_for_slot: dict[str, float] = {} # task id -> when it started waiting
_slot_watcher: threading.Thread | None = None
Expand Down
68 changes: 58 additions & 10 deletions shelfmark/release_sources/prowlarr/mam_account.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@
# How far behind MAM's unsatisfied count may be: snatches this long before the stats
# were read are counted as Shelfmark's own too.
_UNSAT_LAG_SECONDS = 15 * 60
# MAM's unsatisfied count as read, (when, count), oldest first: to tell how many of
# Shelfmark's recent snatches it already shows (see `_uncounted_snatches`).
_UNSAT_SAMPLE_SECONDS = 2 * 3600
_unsat_samples: list[tuple[float, int]] = []
_unsat_lock = threading.Lock()
_GIB = 1024**3
_SIZE_RE = re.compile(r"^\s*([-+]?\d+(?:\.\d+)?)\s*([KMGTP]?i?B)?\s*$", re.IGNORECASE)
_SIZE_UNITS = {"": _GIB, "B": 1, "K": 1024, "M": 1024**2, "G": _GIB, "T": 1024**4, "P": 1024**5}
Expand Down Expand Up @@ -234,12 +239,48 @@ def get_stats(*, refresh: bool = False) -> MamStats:
stats = parse_stats(client.get_user_data())
with _cache_lock:
_cached = (mam_id, stats)
_record_unsat(stats)
from shelfmark.release_sources.prowlarr import mam_points

mam_points.record_sample(stats.seedbonus, at=stats.fetched_at)
return stats


def _record_unsat(stats: MamStats) -> None:
if stats.unsat_count is None:
return
with _unsat_lock:
_unsat_samples.append((stats.fetched_at, stats.unsat_count))
cutoff = stats.fetched_at - _UNSAT_SAMPLE_SECONDS
_unsat_samples[:] = [sample for sample in _unsat_samples if sample[0] >= cutoff]


def _unsat_count_before(at: float) -> int | None:
"""MAM's unsatisfied count as last read at or before `at`, if it was."""
with _unsat_lock:
earlier = [count for read_at, count in _unsat_samples if read_at <= at]
return earlier[-1] if earlier else None


def _uncounted_snatches(handoffs: list[float], unsat_count: int | None) -> int:
"""How many of Shelfmark's recent snatches (handed off at `handoffs`) MAM's
`unsat_count` doesn't show yet.

MAM's count lags its snatches, so recent ones are added to it, but once it has
caught up that counts them twice: a run that filled the slots then saw them full for
the whole lag allowance (15 minutes), with slots free. So: however far MAM's count
has risen since just before the first of them, that many are in it already. Counted
in full when there's no reading from before them (as after a restart). Torrents
leaving the count meanwhile (satisfied) only make this count more, never fewer.
"""
if not handoffs:
return 0
baseline = _unsat_count_before(min(handoffs)) if unsat_count is not None else None
if unsat_count is None or baseline is None:
return len(handoffs)
return max(0, len(handoffs) - max(0, unsat_count - baseline))


def invalidate_stats() -> None:
"""Drop cached stats so the next read asks MAM again."""
global _cached
Expand Down Expand Up @@ -518,7 +559,9 @@ def pending_charge_bytes() -> int:
return sum(task.mam_charge_bytes or 0 for _status, task in _active_mam_tasks())


def pending_unsat_count(stats_fetched_at: float | None = None) -> int:
def pending_unsat_count(
stats_fetched_at: float | None = None, unsat_count: int | None = None
) -> int:
"""Shelfmark's MAM torrents that the account's unsatisfied count may not show yet.

MAM counts a torrent as unsatisfied from when it is snatched, but the stats are read
Expand All @@ -538,8 +581,12 @@ def pending_unsat_count(stats_fetched_at: float | None = None) -> int:
if task.mam_torrent_id and status in not_started
}
since = (stats_fetched_at if stats_fetched_at is not None else time.time()) - _UNSAT_LAG_SECONDS
counted |= {task.task_id for task in book_queue.handoffs_since(since) if task.mam_torrent_id}
return len(counted)
snatched = [
at
for at, task in book_queue.handoff_times_since(since)
if task.mam_torrent_id and task.task_id not in counted
]
return len(counted) + _uncounted_snatches(snatched, unsat_count)


def unsat_free_slots(
Expand All @@ -564,12 +611,13 @@ def unsat_free_slots(
if stats.unsat_count is None or stats.unsat_limit is None:
return None
since = stats.fetched_at - _UNSAT_LAG_SECONDS
snatched = {
task.task_id
for task in book_queue.handoffs_since(since)
snatched = [
at
for at, task in book_queue.handoff_times_since(since)
if task.mam_torrent_id and task.task_id not in exclude
}
return stats.unsat_limit - unsat_reserve_slots() - stats.unsat_count - len(snatched)
]
uncounted = _uncounted_snatches(snatched, stats.unsat_count)
return stats.unsat_limit - unsat_reserve_slots() - stats.unsat_count - uncounted


def recommended_purchase_gb(missing_bytes: int) -> int:
Expand Down Expand Up @@ -641,7 +689,7 @@ def check_buffer(releases: list[dict[str, Any]]) -> BufferCheck:
missing = request_bytes + pending - stats.buffer_bytes if check_buffer_size else 0
recommended = recommended_purchase_gb(missing) if missing > 0 else 0

unsat_pending = pending_unsat_count(stats.fetched_at)
unsat_pending = pending_unsat_count(stats.fetched_at, stats.unsat_count)
reserve = unsat_reserve_slots()
unsat_ok = True
if check_unsat and stats.unsat_count is not None and stats.unsat_limit is not None:
Expand Down Expand Up @@ -722,6 +770,6 @@ def ratio_snapshot() -> dict[str, Any]:
"warning_ratio": warning_ratio(),
"unsat_count": stats.unsat_count,
"unsat_limit": stats.unsat_limit,
"unsat_pending": pending_unsat_count(stats.fetched_at),
"unsat_pending": pending_unsat_count(stats.fetched_at, stats.unsat_count),
"unsat_reserve": unsat_reserve_slots(),
}
57 changes: 31 additions & 26 deletions src/frontend/src/components/activity/ActivityItemParts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,36 @@ export const sourcePageName = (url: string): string => {
}
};

/** A release's page at its source (the MAM torrent listing), opened in a new tab. */
export const SourcePageLink = ({ url, size = 'md' }: { url: string; size?: 'sm' | 'md' }) => (
<Tooltip content={`Open on ${sourcePageName(url)}`} delay={0} position="bottom">
<a
href={url}
target="_blank"
rel="noopener noreferrer"
aria-label={`Open on ${sourcePageName(url)}`}
className={`inline-flex items-center justify-center rounded-full text-gray-500 transition-colors hover:bg-sky-100 hover:text-sky-600 dark:hover:bg-sky-900/30 dark:hover:text-sky-400 ${
size === 'sm' ? 'h-5 w-5' : 'h-7 w-7'
}`}
>
<svg
className={size === 'sm' ? 'h-3.5 w-3.5' : 'h-4 w-4'}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M14 5h5v5M19 5l-8 8M10 5H6a1 1 0 00-1 1v12a1 1 0 001 1h12a1 1 0 001-1v-4"
/>
</svg>
</a>
</Tooltip>
);

/** The status badges, with a download's progress filling its badge. */
export const ActivityStatusBadges = ({
badges,
Expand Down Expand Up @@ -314,32 +344,7 @@ export const ActivityLinkButtons = ({
</IconButton>
</Tooltip>
)}
{item.infoUrl && (
<Tooltip content={`Open on ${sourcePageName(item.infoUrl)}`} delay={0} position="bottom">
<a
href={item.infoUrl}
target="_blank"
rel="noopener noreferrer"
aria-label={`Open on ${sourcePageName(item.infoUrl)}`}
className="inline-flex h-7 w-7 items-center justify-center rounded-full text-gray-500 transition-colors hover:bg-sky-100 hover:text-sky-600 dark:hover:bg-sky-900/30 dark:hover:text-sky-400"
>
<svg
className="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M14 5h5v5M19 5l-8 8M10 5H6a1 1 0 00-1 1v12a1 1 0 001 1h12a1 1 0 001-1v-4"
/>
</svg>
</a>
</Tooltip>
)}
{item.infoUrl && <SourcePageLink url={item.infoUrl} />}
{canShowDownloadLink && item.downloadBookId && (
<Tooltip content="Save file to this device" delay={0} position="bottom">
<a
Expand Down
22 changes: 13 additions & 9 deletions src/frontend/src/components/activity/DownloadQueue.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
rowClassName,
tableClassName,
} from '../shared/DataTable';
import { SourcePageLink } from './ActivityItemParts';
import type { ActivityItem } from './activityTypes';
import {
DragHandle,
Expand Down Expand Up @@ -156,15 +157,18 @@ export const DownloadQueue = ({
{item.format ?? '—'}
</td>
<td className={`${cellClassName} w-0`}>
{onCancel && bookId && (
<button
type="button"
onClick={() => onCancel(bookId)}
className="rounded-full px-2.5 py-1 text-xs font-medium whitespace-nowrap opacity-70 transition-colors hover:bg-(--hover-surface) hover:opacity-100"
>
Remove
</button>
)}
<div className="flex items-center justify-end gap-1">
{item.infoUrl && <SourcePageLink url={item.infoUrl} />}
{onCancel && bookId && (
<button
type="button"
onClick={() => onCancel(bookId)}
className="rounded-full px-2.5 py-1 text-xs font-medium whitespace-nowrap opacity-70 transition-colors hover:bg-(--hover-surface) hover:opacity-100"
>
Remove
</button>
)}
</div>
</td>
</tr>
);
Expand Down
20 changes: 13 additions & 7 deletions src/frontend/src/components/activity/SavedPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
tableClassName,
type SortState,
} from '../shared/DataTable';
import { SourcePageLink } from './ActivityItemParts';
import {
DragHandle,
QueueMoveButtons,
Expand Down Expand Up @@ -110,13 +111,18 @@ const ReleaseLines = ({ item }: { item: SavedItem }) => {
const full = savedPickLines(item, { full: true });
return (
<ul className="space-y-0.5 text-xs" title={full.join('\n')}>
{lines.map((line, index) => (
// Lines can repeat ("Audiobook" twice): the index tells them apart.
// eslint-disable-next-line react/no-array-index-key
<li key={index} className="whitespace-nowrap">
{line}
</li>
))}
{lines.map((line, index) => {
// Lines line up with the picks; each links to its release's page at the source.
const url = item.releases[index]?.release.info_url;
return (
// Lines can repeat ("Audiobook" twice): the index tells them apart.
// eslint-disable-next-line react/no-array-index-key
<li key={index} className="flex items-center gap-1 whitespace-nowrap">
<span>{line}</span>
{url && <SourcePageLink url={url} size="sm" />}
</li>
);
})}
</ul>
);
};
Expand Down
45 changes: 43 additions & 2 deletions tests/prowlarr/test_mam_account.py
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,12 @@ def __init__(self, task_id: str, mam_torrent_id: int | None) -> None:
self.mam_torrent_id = mam_torrent_id


@pytest.fixture(autouse=True)
def no_unsat_readings(monkeypatch):
"""Each test starts with no record of MAM's unsatisfied count."""
monkeypatch.setattr(mam_account, "_unsat_samples", [])


class TestUnsatisfied:
@pytest.mark.parametrize("nested", [True, False])
def test_parsed_from_either_shape(self, nested):
Expand Down Expand Up @@ -556,7 +562,7 @@ def task(task_id: str, mam_id: int | None) -> DownloadTask:
status[QueueStatus.RESOLVING] = {"c": task("c", 2)}
status[QueueStatus.DOWNLOADING] = {"d": task("d", 3)} # already in MAM's count
monkeypatch.setattr(book_queue, "get_status", lambda: status)
monkeypatch.setattr(book_queue, "handoffs_since", lambda _since: [])
monkeypatch.setattr(book_queue, "handoff_times_since", lambda _since: [])

assert mam_account.pending_unsat_count() == 2

Expand Down Expand Up @@ -592,7 +598,9 @@ def test_free_slots_count_recent_snatches_but_not_the_asker(self, mam, monkeypat
SimpleTask("asker", 2),
SimpleTask("not-mam", None),
]
monkeypatch.setattr(book_queue, "handoffs_since", lambda _since: snatched)
monkeypatch.setattr(
book_queue, "handoff_times_since", lambda _since: [(time.time(), t) for t in snatched]
)
# 100 - 3 kept free - 90 - "a" (the asker doesn't count against itself)
assert mam_account.unsat_free_slots(exclude={"asker"}) == 6
monkeypatch.setattr(mam_account, "unsat_check_enabled", lambda: False)
Expand All @@ -611,3 +619,36 @@ def test_download_route_queues_a_download_that_waits_for_a_slot(
assert resp.status_code == 200
assert resp.get_json()["waiting_for_slot"] is True
qr.assert_called_once()


class TestRecentSnatchesAlreadyCounted:
"""Recent snatches MAM's count already shows aren't counted twice."""

def _snatch(self, monkeypatch, count: int, *, at: float):
from shelfmark.core.queue import book_queue

tasks = [(at, SimpleTask(f"t{i}", i + 1)) for i in range(count)]
monkeypatch.setattr(book_queue, "handoff_times_since", lambda _since: tasks)

def test_counts_only_the_snatches_mam_does_not_show_yet(self, mam, monkeypatch):
monkeypatch.setattr(mam_account, "unsat_reserve_slots", lambda: 5)
mam.user = _unsat_user(119, 150)
before = mam_account.get_stats(refresh=True) # Read before the snatches
self._snatch(monkeypatch, 13, at=before.fetched_at)

# MAM's count hasn't moved yet: all 13 take slots. 150 - 5 - 119 - 13.
assert mam_account.unsat_free_slots(refresh=True) == 13
# It shows 7 of them now: the other 6 still take slots.
mam.user = _unsat_user(126, 150)
assert mam_account.unsat_free_slots(refresh=True) == 150 - 5 - 126 - 6
# It shows all 13 (132 of 150): 13 slots free, not 0.
mam.user = _unsat_user(132, 150)
stats = mam_account.get_stats(refresh=True)
assert mam_account.unsat_free_slots() == 13
assert mam_account.pending_unsat_count(stats.fetched_at, stats.unsat_count) == 0

def test_without_an_earlier_reading_every_snatch_counts(self, mam, monkeypatch):
monkeypatch.setattr(mam_account, "unsat_reserve_slots", lambda: 5)
self._snatch(monkeypatch, 13, at=time.time() - 60)
mam.user = _unsat_user(132, 150)
assert mam_account.unsat_free_slots(refresh=True) == 0
Loading