From cb188cea86e78d5258723e9c920668e24bfed97b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 16:46:56 +0000 Subject: [PATCH 1/2] feat: MAM room to ratio, Queued page size and ETAs, MyAnonamouse settings tab - The MAM panel's Buffer becomes Room to the Keep Ratio At Least ratio, counting Shelfmark's active MAM downloads; the plain buffer is in its tooltip. - The Queued page shows the queue's total size, what counts against the ratio, the room left and the upload credit the whole queue needs. - Saved queued items get an ETA: fits now, freeleech, or the upload credit needed and (admins) how long the bonus points take at the estimated rate. - MyAnonamouse gets its own tab under Settings > Indexers, split off the Prowlarr tab. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ULvMD6k72dAoo7JXgkhk6p --- CHANGELOG.md | 15 ++ docs/environment-variables.md | 12 +- docs/myanonamouse-enrichment.md | 6 +- docs/saved-for-later.md | 20 ++- shelfmark/config/settings.py | 4 +- shelfmark/core/mam_routes.py | 8 + shelfmark/release_sources/prowlarr/mam.py | 2 +- .../release_sources/prowlarr/mam_account.py | 25 ++- .../release_sources/prowlarr/settings.py | 11 ++ src/frontend/src/App.tsx | 69 ++++++++- .../src/components/MamAccountModal.tsx | 18 ++- .../src/components/activity/ActivityPage.tsx | 11 +- .../src/components/activity/QueueSummary.tsx | 146 ++++++++++++++++++ .../src/components/activity/SavedPanel.tsx | 28 ++++ .../components/settings/settingsCategories.ts | 5 + src/frontend/src/hooks/useMamQueue.ts | 53 +++++++ src/frontend/src/tests/mamAccount.test.ts | 12 ++ src/frontend/src/tests/mamRatio.test.ts | 63 ++++++++ .../src/tests/settingsCategories.test.ts | 39 +++++ src/frontend/src/utils/mamAccount.ts | 34 ++++ src/frontend/src/utils/mamRatio.ts | 122 +++++++++++++++ tests/prowlarr/test_mam_account.py | 18 +++ 22 files changed, 698 insertions(+), 23 deletions(-) create mode 100644 src/frontend/src/components/activity/QueueSummary.tsx create mode 100644 src/frontend/src/hooks/useMamQueue.ts create mode 100644 src/frontend/src/tests/settingsCategories.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 896abf23e..7361302c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ Releases of this fork. Images: `ghcr.io/mikaeltarquin/shelfmark:` and ` ## Unreleased +### Added + +- **The Queued page shows the size of the queue and when each item fits your ratio.** + At the top: the total size, how much counts against your MyAnonamouse ratio, the room + left before it drops below **Keep Ratio At Least**, and the upload credit the whole + queue needs. Each saved item gets an **ETA**: fits now, freeleech, or the upload credit + it needs and, for admins, roughly how long the bonus points for it take to earn. +- **MyAnonamouse has its own settings tab**, under **Settings → Indexers**, after Prowlarr. + +### Changed + +- **The MyAnonamouse panel's Buffer is now Room to your ratio**: how much more can be + downloaded before the ratio drops below **Keep Ratio At Least**, counting Shelfmark's + active MAM downloads. The plain buffer is in its tooltip. + ### Fixed - **MyAnonamouse downloads no longer run past the unsatisfied limit when MAM's count diff --git a/docs/environment-variables.md b/docs/environment-variables.md index c1cdc2eb3..1912f4122 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -348,8 +348,8 @@ Audiobook formats to include in search results. ZIP/RAR archives are extracted a | `METADATA_PROVIDER_COMBINED` | Metadata provider for combined mode searches. Uses the book provider if not set. | string (choice) | _empty string_ | | `DEFAULT_RELEASE_SOURCE` | The release source tab to open by default in the release modal for books. Leave unset to use the first available source. | string (choice) | _empty string_ | | `DEFAULT_RELEASE_SOURCE_AUDIOBOOK` | The release source tab to open by default in the release modal for audiobooks. Uses the book release source if not set. | string (choice) | _empty string_ | -| `SHOW_SERIES_COLUMN` | Series name and number, for books and audiobooks. Filled in for MyAnonamouse results from Prowlarr when a MAM session ID is set in the Prowlarr settings. | boolean | `true` | -| `SHOW_NARRATOR_COLUMN` | Audiobooks only. Filled in for MyAnonamouse results from Prowlarr when a MAM session ID is set in the Prowlarr settings. | boolean | `true` | +| `SHOW_SERIES_COLUMN` | Series name and number, for books and audiobooks. Filled in for MyAnonamouse results from Prowlarr when a MAM session ID is set in the MyAnonamouse settings. | boolean | `true` | +| `SHOW_NARRATOR_COLUMN` | Audiobooks only. Filled in for MyAnonamouse results from Prowlarr when a MAM session ID is set in the MyAnonamouse settings. | boolean | `true` | | `SHOW_BITRATE_COLUMN` | Audiobooks only. AudiobookBay lists it for most releases. In Prowlarr results it needs a MAM session ID and is read from the MyAnonamouse uploader's tags, so some releases won't have one. Other Prowlarr indexers fill it only if they report a bitrate attribute, which most don't. | boolean | `true` |
@@ -475,7 +475,7 @@ The release source tab to open by default in the release modal for audiobooks. U **Show Series Column** -Series name and number, for books and audiobooks. Filled in for MyAnonamouse results from Prowlarr when a MAM session ID is set in the Prowlarr settings. +Series name and number, for books and audiobooks. Filled in for MyAnonamouse results from Prowlarr when a MAM session ID is set in the MyAnonamouse settings. - **Type:** boolean - **Default:** `true` @@ -484,7 +484,7 @@ Series name and number, for books and audiobooks. Filled in for MyAnonamouse res **Show Narrator Column** -Audiobooks only. Filled in for MyAnonamouse results from Prowlarr when a MAM session ID is set in the Prowlarr settings. +Audiobooks only. Filled in for MyAnonamouse results from Prowlarr when a MAM session ID is set in the MyAnonamouse settings. - **Type:** boolean - **Default:** `true` @@ -1355,7 +1355,7 @@ How long to cache individual book details. Default: 600 (10 minutes). Max: 60480 | `PROWLARR_USE_SEED_PREFERENCES` | Apply per-indexer seed time and ratio preferences from Prowlarr when sending torrents to the download client | boolean | `false` | | `PROWLARR_MAM_ID` | The mam_id value MyAnonamouse shows when you create a session. MAM locks each session to one IP or ASN, so reusing Prowlarr's or another client's session often fails with a 403: you will likely need a separate session for Shelfmark if it reaches MAM from another IP (different host, VPN or proxy) or the existing session is ASN-locked to another network. Leave empty to turn enrichment off. | string (secret) | _none_ | | `MAM_BLOCK_ON_LOW_BUFFER` | Before queueing MyAnonamouse torrents, check that they fit in the account's buffer (uploaded minus downloaded), counting Shelfmark's MAM downloads that are still active. If not, admins are offered upload credit to buy first; other users are asked to contact an admin. Freeleech torrents don't count. If MAM can't be reached, downloads go ahead. | boolean | `true` | -| `MAM_BLOCK_ON_UNSAT_LIMIT` | MyAnonamouse limits how many torrents can be unsatisfied (not yet seeded 72 hours) at once, by user class. Before queueing MAM torrents (freeleech too), check that they, plus Shelfmark's MAM downloads not yet started, leave the slots below free. Buying upload credit doesn't help here; wait for torrents to finish seeding. | boolean | `true` | +| `MAM_BLOCK_ON_UNSAT_LIMIT` | MyAnonamouse limits how many torrents can be unsatisfied (not yet seeded 72 hours) at once, by user class. Before queueing MAM torrents (freeleech too), check that they, plus Shelfmark's MAM downloads not yet started, leave the slots below free. Buying upload credit doesn't help here; wait for torrents to finish seeding. Queued MAM downloads also wait while MAM can't be read, rejects the torrent client's announces, or has frozen downloads. | boolean | `true` | | `MAM_UNSAT_RESERVE_SLOTS` | Hold back downloads that would leave fewer free unsatisfied slots than this. | number | `5` | | `SAVED_AUTO_GET_ENABLED` | Turn off to pause every automatic download; items stay marked. | boolean | `true` | | `SAVED_AUTO_GET_TARGET_RATIO` | Non-freeleech downloads that would take the ratio below this wait, unless they're ebook-sized (100 MB or less) and lower it by less than 0.01. 0 turns the ratio check off. | number | `2.0` | @@ -1472,7 +1472,7 @@ Before queueing MyAnonamouse torrents, check that they fit in the account's buff **Hold Back Downloads at the Unsatisfied Limit** -MyAnonamouse limits how many torrents can be unsatisfied (not yet seeded 72 hours) at once, by user class. Before queueing MAM torrents (freeleech too), check that they, plus Shelfmark's MAM downloads not yet started, leave the slots below free. Buying upload credit doesn't help here; wait for torrents to finish seeding. +MyAnonamouse limits how many torrents can be unsatisfied (not yet seeded 72 hours) at once, by user class. Before queueing MAM torrents (freeleech too), check that they, plus Shelfmark's MAM downloads not yet started, leave the slots below free. Buying upload credit doesn't help here; wait for torrents to finish seeding. Queued MAM downloads also wait while MAM can't be read, rejects the torrent client's announces, or has frozen downloads. - **Type:** boolean - **Default:** `true` diff --git a/docs/myanonamouse-enrichment.md b/docs/myanonamouse-enrichment.md index ea62de7db..8cf0bc413 100644 --- a/docs/myanonamouse-enrichment.md +++ b/docs/myanonamouse-enrichment.md @@ -17,7 +17,7 @@ Only MyAnonamouse results are enriched. Other Prowlarr indexers fill the bitrate ## Setup 1. On MyAnonamouse, open **Preferences > Security** and create a session for Shelfmark. Lock it to the public IP (or ASN) Shelfmark connects from. -2. In Shelfmark, open **Settings > Prowlarr > MyAnonamouse Enrichment**, paste the `mam_id` value into **MAM Session ID** (or set `PROWLARR_MAM_ID`), and click **Test MAM Session**. +2. In Shelfmark, open **Settings > Indexers > MyAnonamouse**, paste the `mam_id` value into **MAM Session ID** (or set `PROWLARR_MAM_ID`), and click **Test MAM Session**. 3. Choose which columns appear under **Settings > Search Mode > Release List Columns** (`SHOW_SERIES_COLUMN`, `SHOW_NARRATOR_COLUMN`, `SHOW_BITRATE_COLUMN`). Users can override these for their own account. ## Use a session made for Shelfmark @@ -51,7 +51,7 @@ figures) and unsatisfied count at a glance (amber or red as they near their limi refreshed every 5 minutes), plus a countdown to the next unsatisfied slot freeing up, and opens a panel with links to your MAM profile and the MAM store. The panel shows: -- **Account:** ratio, buffer (uploaded − downloaded), bonus points, uploaded, downloaded, class, VIP expiry and unsatisfied torrents against your class's limit (amber at 90%, red at the limit), from MAM's `jsonLoad.php?snatch_summary`. Refreshed at most once a minute unless you press **Refresh**. +- **Account:** ratio, **Room to** your ratio (how much more can be downloaded, not counting freeleech, before the ratio drops below **Keep Ratio At Least**, or 1.0 when that's off; it counts Shelfmark's active MAM downloads, and its tooltip shows the plain buffer, uploaded − downloaded), bonus points, uploaded, downloaded, class, VIP expiry and unsatisfied torrents against your class's limit (amber at 90%, red at the limit), from MAM's `jsonLoad.php?snatch_summary`. Refreshed at most once a minute unless you press **Refresh**. - **Points / hour:** MAM's API has no earning rate, so Shelfmark estimates it from its own readings of your balance (taken hourly, kept for two days) over the last 24 hours. Points Shelfmark spent on upload credit are added back; a period where points dropped for another reason (spending on MAM's site) or hit MAM's 99,999 cap is left out. It shows once a few hours of readings exist. - **Connections:** whether MAM accepts the session, and whether Shelfmark can reach its torrent client, each with the error when it can't. - **Buy upload credit:** spends bonus points in MAM's store (500 points per GB). Pick 50, 100, 250 or 500 GB, a custom amount in multiples of 50 GB, or **Max affordable**. Nothing is bought until you confirm. Larger amounts are bought as 100 GB and 50 GB purchases, and a purchase stops at the first one MAM declines, reporting what was already added. @@ -69,7 +69,7 @@ Active downloads are counted at their full size, because MAM's figures only incl ## Upload credit auto-buy -Under **Settings > Prowlarr > Upload Credit Auto-Buy**, Shelfmark can spend bonus points on upload credit by itself. Each mode is off until you turn it on: +Under **Settings > Indexers > MyAnonamouse > Upload Credit Auto-Buy**, Shelfmark can spend bonus points on upload credit by itself. Each mode is off until you turn it on: | Mode | Buys when | Default threshold | Default amount | |------|-----------|-------------------|----------------| diff --git a/docs/saved-for-later.md b/docs/saved-for-later.md index acf7b6ad8..8d8a0253d 100644 --- a/docs/saved-for-later.md +++ b/docs/saved-for-later.md @@ -33,17 +33,29 @@ A saved item is one of two things, marked on books everywhere they show: ## The Queued and Wanted pages -**Activity → Queued** is everything waiting to download, in the order it goes: +**Activity → Queued** is everything waiting to download, in the order it goes. At the +top, its total size, and with MyAnonamouse set up: how much of the saved picks counts +against your ratio (freeleech doesn't), the room left before the ratio drops below **Keep +Ratio At Least**, and the upload credit it would take for all of it to download without +going under (with, for admins, the bonus points that costs and roughly how long earning +them takes at your points-per-hour rate). - **Up next**: downloads already handed to Shelfmark that haven't started, such as a MyAnonamouse torrent waiting for an unsatisfied slot. They're numbered in the order they start; drag a row by its handle (or use the arrows) to change it. - **Waiting for room**: saved picks, numbered after them, each showing its **Releases** (one line per release, with the format, narrator and a link to its page at the source, the torrent on - MyAnonamouse for example), what it's **Waiting for**, when it was **Last checked** and a - countdown to the **Next check**. Drag these to reorder them too: earlier ones get free + MyAnonamouse for example), what it's **Waiting for**, its **ETA** (with MyAnonamouse + set up), when it was **Last checked** and a countdown to the **Next check**. Drag these to reorder them too: earlier ones get free room first. **+ Get** downloads one now without waiting; **Remove** drops it. +The **ETA** goes by the ratio alone, as the automatic check does: **Fits now**, **Freeleech**, +or, once the room runs out, the upload credit that makes room for it (counting the picks +ahead of it, in queue order) and how long the bonus points for it take, or **Buy credit** +when you have them already. Picks that fit go ahead of earlier ones that don't. It +doesn't know when an unsatisfied slot frees up, or a torrent turns freeleech, and upload +credit for automatic downloads is only bought by you or auto-buy. + **Wanted** lists books saved without picks. **Choose releases** opens the release list; pick and save, and the book moves to Queued. Rows with picks that couldn't be queued have **Choose again** and **+ Get**. @@ -94,7 +106,7 @@ Queuing something doesn't take you away from the page you're on. **Show Download Queued** (off by default) goes to Activity → Downloads when you start a download yourself; background downloads never move the page. -**Settings → Indexers → Prowlarr → Saved for Later** has the global switch (**Get Saved +**Settings → Indexers → MyAnonamouse → Saved for Later** has the global switch (**Get Saved Books Automatically**, on by default; turning it off pauses every item without unmarking it), **Keep Ratio At Least** (0 turns the ratio check off) and the check interval (at least 5 minutes). diff --git a/shelfmark/config/settings.py b/shelfmark/config/settings.py index b0598454e..fffa1965d 100644 --- a/shelfmark/config/settings.py +++ b/shelfmark/config/settings.py @@ -768,7 +768,7 @@ def search_mode_settings() -> list[SettingsField]: description=( "Series name and number, for books and audiobooks. Filled in for " "MyAnonamouse results from Prowlarr when a MAM session ID is set in the " - "Prowlarr settings." + "MyAnonamouse settings." ), default=True, show_when={"field": "SEARCH_MODE", "value": "universal"}, @@ -779,7 +779,7 @@ def search_mode_settings() -> list[SettingsField]: label="Show Narrator Column", description=( "Audiobooks only. Filled in for MyAnonamouse results from Prowlarr when a " - "MAM session ID is set in the Prowlarr settings." + "MAM session ID is set in the MyAnonamouse settings." ), default=True, show_when={"field": "SEARCH_MODE", "value": "universal"}, diff --git a/shelfmark/core/mam_routes.py b/shelfmark/core/mam_routes.py index 07582c05d..3bd8e4c20 100644 --- a/shelfmark/core/mam_routes.py +++ b/shelfmark/core/mam_routes.py @@ -31,11 +31,19 @@ def _account_payload(*, refresh: bool) -> dict[str, Any]: stats = mam_account.get_stats(refresh=refresh) except mam_account.MAM_ERRORS as exc: return {"configured": True, "stats": None, "error": mam_account.describe_error(exc)} + pending = mam_account.pending_charge_bytes() + keep = mam_account.keep_ratio() return { "configured": True, "stats": stats.to_dict(), "error": None, "points_per_hour": mam_points.estimate_rate(), + "pending_bytes": pending, + "keep_ratio": keep, + # Room before the ratio drops below Keep Ratio At Least, after active MAM downloads + "room_bytes": mam_account.ratio_room_bytes( + stats.uploaded_bytes, stats.downloaded_bytes + pending, keep + ), } diff --git a/shelfmark/release_sources/prowlarr/mam.py b/shelfmark/release_sources/prowlarr/mam.py index 429295baa..9cce68110 100644 --- a/shelfmark/release_sources/prowlarr/mam.py +++ b/shelfmark/release_sources/prowlarr/mam.py @@ -411,7 +411,7 @@ def _record_failure(mam_id: str, error: Exception) -> None: if count >= _MAX_CONSECUTIVE_FAILURES: logger.warning( "MAM enrichment stopped after %s consecutive failures (last: %s). " - "Test the MAM session in the Prowlarr settings to turn it back on.", + "Test the MAM session in the MyAnonamouse settings to turn it back on.", count, error, ) diff --git a/shelfmark/release_sources/prowlarr/mam_account.py b/shelfmark/release_sources/prowlarr/mam_account.py index 8eb86cb8a..4252c14c1 100644 --- a/shelfmark/release_sources/prowlarr/mam_account.py +++ b/shelfmark/release_sources/prowlarr/mam_account.py @@ -876,6 +876,22 @@ def warning_ratio() -> float: return value if value is not None and math.isfinite(value) else RATIO_WARNING +def keep_ratio() -> float: + """The ratio to stay at or above: Keep Ratio At Least, or 1.0 (the buffer) when it's off.""" + from shelfmark.core import saved_autoget + + target = saved_autoget.target_ratio() + return target if target > 0 else 1.0 + + +def ratio_room_bytes(uploaded: int, downloaded: int, ratio: float) -> int: + """How much more can be downloaded before uploaded / downloaded drops below `ratio`. + + Negative once it already has: then it's how far the downloaded total is over. + """ + return int(uploaded / ratio) - downloaded if ratio > 0 else uploaded - downloaded + + def ratio_snapshot() -> dict[str, Any]: """What the projected-ratio line needs; no username or bonus points (all users see it).""" if not is_configured(): @@ -884,14 +900,21 @@ def ratio_snapshot() -> dict[str, Any]: stats = get_stats() except MAM_ERRORS as exc: return {"available": False, "error": describe_error(exc)} + pending = pending_charge_bytes() + keep = keep_ratio() return { "available": True, "uploaded_bytes": stats.uploaded_bytes, "downloaded_bytes": stats.downloaded_bytes, "ratio": stats.ratio, "buffer_bytes": stats.buffer_bytes, - "pending_bytes": pending_charge_bytes(), + "pending_bytes": pending, "warning_ratio": warning_ratio(), + "keep_ratio": keep, + # Room left after Shelfmark's active MAM downloads + "room_bytes": ratio_room_bytes( + stats.uploaded_bytes, stats.downloaded_bytes + pending, keep + ), "unsat_count": stats.unsat_count, "unsat_limit": stats.unsat_limit, "unsat_pending": pending_unsat_count(stats.fetched_at, stats.unsat_count), diff --git a/shelfmark/release_sources/prowlarr/settings.py b/shelfmark/release_sources/prowlarr/settings.py index 6fcdecf18..8ad7bb39c 100644 --- a/shelfmark/release_sources/prowlarr/settings.py +++ b/shelfmark/release_sources/prowlarr/settings.py @@ -252,6 +252,17 @@ def prowlarr_config_settings() -> list[SettingsField]: description="Apply per-indexer seed time and ratio preferences from Prowlarr when sending torrents to the download client", show_when={"field": "PROWLARR_ENABLED", "value": True}, ), + # Starts the MyAnonamouse tab the Settings page splits off this one; everything + # on it needs Prowlarr, so it says so rather than showing an empty page. + HeadingField( + key="prowlarr_mam_tab_notice", + title="MyAnonamouse", + description=( + "MyAnonamouse is searched through Prowlarr. Turn on the Prowlarr source on " + "the Prowlarr tab to set it up." + ), + show_when={"field": "PROWLARR_ENABLED", "value": False}, + ), HeadingField( key="prowlarr_mam_heading", title="MyAnonamouse Enrichment", diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index b7eca699a..bb9cb9cfc 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -8,6 +8,7 @@ import { type ActivityItem, type ActivityTabKey, } from './components/activity'; +import { QueueSummary } from './components/activity/QueueSummary'; import { SavedPanel } from './components/activity/SavedPanel'; import { AdvancedFilters } from './components/AdvancedFilters'; import { ConfigSetupBanner } from './components/ConfigSetupBanner'; @@ -32,6 +33,7 @@ import { resolveSettingsCategory, settingsPath, } from './components/settings'; +import { parseSize } from './components/shared/DataTable'; import { SystemPage } from './components/system/SystemPage'; import { ToastContainer } from './components/ToastContainer'; import { UrlSearchBootstrapMount } from './components/UrlSearchBootstrapMount'; @@ -61,6 +63,7 @@ import { useActivity } from './hooks/useActivity'; import { useAuth } from './hooks/useAuth'; import { useDownloadTracking } from './hooks/useDownloadTracking'; import { useLatestCallback } from './hooks/useLatestCallback'; +import { useMamQueue } from './hooks/useMamQueue'; import { useMountEffect } from './hooks/useMountEffect'; import { useRealtimeStatus } from './hooks/useRealtimeStatus'; import { useRequestPolicy } from './hooks/useRequestPolicy'; @@ -124,7 +127,12 @@ import { isPartRelease, releaseNarrators } from './utils/combinedSelection'; import { wasDownloadQueuedAfterResponseError } from './utils/downloadRecovery'; import { getDynamicOptionGroup } from './utils/dynamicFieldOptions'; import { resolveDefaultLanguageCodes } from './utils/languageFilters'; -import type { MamBufferCheck, MamRatioSnapshot } from './utils/mamRatio'; +import { + estimateQueue, + releaseChargeBytes, + type MamBufferCheck, + type MamRatioSnapshot, +} from './utils/mamRatio'; import { getConfiguredMetadataProviderForContentType } from './utils/metadataProviders'; import { getEffectiveMetadataSort } from './utils/metadataSort'; import { isRecord } from './utils/objectHelpers'; @@ -154,6 +162,7 @@ import { } from './utils/requestPolicyUi'; import { combinedPicks, + queueOrder, savedStage, type SavedItem, type SavedPick, @@ -954,6 +963,34 @@ function App() { autoGetAvailable: Boolean(config?.saved_auto_get_enabled), onShowToast: showToast, }); + // The Queued page's totals and ETAs: the saved picks waiting, against the MAM ratio. + const savedQueue = useMemo( + () => queueOrder(savedStore.items.filter((item) => savedStage(item) === 'queued')), + [savedStore.items], + ); + const mamQueue = useMamQueue( + section === 'activity' && + location.pathname.split('/')[2] === 'queued' && + Boolean(config?.mam_account_available), + requestRoleIsAdmin || !authRequired, + `${savedQueue.map((item) => `${item.id}:${item.auto_checked_at}`).join(',')}|${statusCounts.queued}`, + ); + const queueEstimate = useMemo( + () => + mamQueue.snapshot + ? estimateQueue( + mamQueue.snapshot, + savedQueue.map((item) => + item.releases.map((pick) => ({ + sizeBytes: pick.release.size_bytes ?? 0, + chargeBytes: releaseChargeBytes(pick.release), + })), + ), + mamQueue.points, + ) + : null, + [mamQueue, savedQueue], + ); // Marks books already saved or downloaded, wherever they show. const bookActivity = useBookActivityStore({ status: currentStatus, @@ -2097,6 +2134,35 @@ function App() { onReorder={stage === 'queued' ? savedStore.reorder : undefined} positionOffset={stage === 'queued' ? statusCounts.queued : 0} nextCheckAt={stage === 'queued' ? savedStore.nextCheckAt : null} + etas={ + stage === 'queued' && queueEstimate + ? { + byId: new Map(savedQueue.map((item, index) => [item.id, queueEstimate.etas[index]])), + keepRatio: queueEstimate.keepRatio, + points: mamQueue.points, + } + : null + } + /> + ); + + const upNextBytes = Object.values(currentStatus.queued ?? {}).reduce( + (total, book) => total + (parseSize(book.size) ?? 0), + 0, + ); + const queueSummary = ( + + total + item.releases.reduce((sum, pick) => sum + (pick.release.size_bytes ?? 0), 0), + 0, + ) + } + estimate={queueEstimate} + points={mamQueue.points} /> ); @@ -2905,6 +2971,7 @@ function App() { key={activityTab} tab={activityTab} queuedPanel={savedPanelFor('queued')} + queueSummary={queueSummary} onReorderDownloads={handleReorderDownloads} status={activitySidebarStatus} isAdmin={requestRoleIsAdmin} diff --git a/src/frontend/src/components/MamAccountModal.tsx b/src/frontend/src/components/MamAccountModal.tsx index 8bbac9316..fb2413115 100644 --- a/src/frontend/src/components/MamAccountModal.tsx +++ b/src/frontend/src/components/MamAccountModal.tsx @@ -17,6 +17,7 @@ import { customAmountError, describeAutobuy, describeMamHold, + describeRoom, describeUnsatTiming, describeCheck, purchaseReasonLabel, @@ -263,6 +264,9 @@ export const MamAccountModal = ({ onClose }: MamAccountModalProps) => { const stats = account?.stats ?? null; const pointsRate = account?.points_per_hour ?? null; + const keepRatio = account?.keep_ratio ?? 1; + let roomBytes: number | null = null; + if (stats) roomBytes = account?.room_bytes ?? stats.buffer_bytes; const pointsPerGb = account?.points_per_gb ?? DEFAULT_POINTS_PER_GB; const stepGb = account?.step_gb ?? DEFAULT_STEP_GB; const maxGb = stats ? maxAffordableGb(stats.seedbonus, pointsPerGb, stepGb) : 0; @@ -426,10 +430,15 @@ export const MamAccountModal = ({ onClose }: MamAccountModalProps) => { tone={stats ? ratioTone(stats.ratio) : undefined} /> @@ -623,7 +632,8 @@ export const MamAccountModal = ({ onClose }: MamAccountModalProps) => { ) : (

- Off. Turn modes on under Settings › Prowlarr › Upload Credit Auto-Buy. + Off. Turn modes on under Settings › Indexers › MyAnonamouse › Upload Credit + Auto-Buy.

)} {autobuy?.last_check && ( diff --git a/src/frontend/src/components/activity/ActivityPage.tsx b/src/frontend/src/components/activity/ActivityPage.tsx index 3bd533f92..3650d1a6f 100644 --- a/src/frontend/src/components/activity/ActivityPage.tsx +++ b/src/frontend/src/components/activity/ActivityPage.tsx @@ -42,6 +42,8 @@ interface ActivityPageProps { onRequestDismiss?: (requestId: number) => void; // The saved items queued to download on their own, for the Queued page. queuedPanel?: ReactNode; + // The Queued page's totals, over everything waiting (see QueueSummary). + queueSummary?: ReactNode; /** Puts the queued downloads in this order (download ids, first to start first). */ onReorderDownloads?: (ids: string[]) => Promise; } @@ -229,6 +231,7 @@ export const ActivityPage = ({ onRequestReject, onRequestDismiss, queuedPanel, + queueSummary, onReorderDownloads, }: ActivityPageProps) => { const [selectedUser, setSelectedUser] = useState(ALL_USERS_FILTER); @@ -567,10 +570,16 @@ export const ActivityPage = ({ {(() => { if (effectiveActiveTab === 'queued') { if (queuedDownloadItems.length === 0) { - return queuedPanel; + return ( +
+ {queueSummary} + {queuedPanel} +
+ ); } return (
+ {queueSummary}

( +
+

{label}

+

{value}

+ {detail &&

{detail}

} +
+); + +/** How long the points for some upload credit take, in words: "~14 h of bonus points". */ +const describeCreditWait = (hours: number | null, points: QueuePoints | null): string => { + if (hours === 0) return 'You have the bonus points now'; + if (hours !== null) return `~${formatWait(hours)} of bonus points`; + return points ? 'Points rate not known yet' : 'Ask an admin to buy upload credit'; +}; + +/** A queued item's ETA cell: when it fits the ratio, and what that takes. */ +export const QueueEtaCell = ({ + eta, + keepRatio, + points, + isMam, +}: { + eta: QueueEta; + keepRatio: number; + points: QueuePoints | null; + isMam: boolean; +}) => { + if (eta.kind === 'free') { + return {isMam ? 'Freeleech' : '—'}; + } + if (eta.kind === 'fits') { + return ( + + Fits now + + ); + } + let headline = `${eta.creditGb} GB credit`; + if (eta.hours === 0) headline = 'Buy credit'; + else if (eta.hours !== null) headline = `~${formatWait(eta.hours)}`; + return ( + + {headline} + + {eta.creditGb} GB · {formatPoints(eta.points)} BP + + + ); +}; + +/** The top of the Queued page: how much is waiting, and how it sits with the ratio. */ +export const QueueSummary = ({ count, sizeBytes, estimate, points }: QueueSummaryProps) => { + if (count === 0) return null; + const tiles = [ + , + ]; + if (estimate) { + const ratio = formatRatio(estimate.keepRatio); + tiles.push( + , + , + ); + tiles.push( + estimate.creditGb > 0 ? ( + + ) : ( + + ), + ); + } + return ( +
+ {tiles} +
+ ); +}; diff --git a/src/frontend/src/components/activity/SavedPanel.tsx b/src/frontend/src/components/activity/SavedPanel.tsx index 74e8630a6..b65cdeb86 100644 --- a/src/frontend/src/components/activity/SavedPanel.tsx +++ b/src/frontend/src/components/activity/SavedPanel.tsx @@ -4,10 +4,12 @@ import { useDependencyEffect, useMountEffect } from '../../hooks/useMountEffect' import { useRowDrag } from '../../hooks/useRowDrag'; import { withBasePath } from '../../utils/basePath'; import { formatCountdown } from '../../utils/mamAccount'; +import type { QueueEta, QueuePoints } from '../../utils/mamRatio'; import { formatDateTime, isoTimeAgo } from '../../utils/relativeTime'; import { SAVED_STAGE_LABELS, describeSavedPick, + hasMamPick, queueOrder, savedPickLines, savedStage, @@ -35,6 +37,7 @@ import { moveToIndex, type DragHandleProps, } from './QueueControls'; +import { QueueEtaCell } from './QueueSummary'; interface SavedPanelProps { items: SavedItem[]; @@ -54,6 +57,14 @@ interface SavedPanelProps { positionOffset?: number; // Queued only: when the automatic check next runs (ms since epoch), or null. nextCheckAt?: number | null; + // Queued only: when each item fits the MAM ratio, by item id (see estimateQueue). + etas?: QueueEtas | null; +} + +interface QueueEtas { + byId: Map; + keepRatio: number; + points: QueuePoints | null; } // A queued row's place in line and its move buttons. @@ -134,6 +145,7 @@ const SavedRow = ({ onOpenDetails, onChooseReleases, nextCheckAt, + etas, queue, }: RowProps) => { const [busy, setBusy] = useState<'get' | 'choose' | 'remove' | null>(null); @@ -151,6 +163,7 @@ const SavedRow = ({ const openDetails = onOpenDetails ? () => onOpenDetails(item) : undefined; const hasPicks = item.kind !== 'book' && item.releases.length > 0; const reason = waitingFor(item); + const eta = etas?.byId.get(item.id); return ( {reason ?? First check pending} + {etas && ( + + {eta ? ( + + ) : ( + '—' + )} + + )} + {rowHandlers.etas && } diff --git a/src/frontend/src/components/settings/settingsCategories.ts b/src/frontend/src/components/settings/settingsCategories.ts index 157d50f6a..827193881 100644 --- a/src/frontend/src/components/settings/settingsCategories.ts +++ b/src/frontend/src/components/settings/settingsCategories.ts @@ -45,6 +45,11 @@ export const resolveSettingsCategory = (key: string | null): string => { * one's. All of them still load and save as the one backend tab. */ const SPLIT_TABS: Record> = { + // The first part keeps the backend tab's name, so links to it still land there. + prowlarr_config: [ + { id: 'prowlarr_config', label: 'Prowlarr' }, + { id: 'myanonamouse', label: 'MyAnonamouse', startsAt: 'prowlarr_mam_tab_notice' }, + ], prowlarr_clients: [ { id: 'torrent', label: 'Torrent' }, { id: 'usenet', label: 'Usenet', startsAt: 'usenet_heading' }, diff --git a/src/frontend/src/hooks/useMamQueue.ts b/src/frontend/src/hooks/useMamQueue.ts new file mode 100644 index 000000000..cb6e65201 --- /dev/null +++ b/src/frontend/src/hooks/useMamQueue.ts @@ -0,0 +1,53 @@ +import { useState } from 'react'; + +import { getMamAccount, getMamRatio } from '../services/api'; +import type { MamRatioSnapshot, QueuePoints } from '../utils/mamRatio'; +import { useDependencyEffect } from './useMountEffect'; + +export interface MamQueueData { + snapshot: MamRatioSnapshot | null; + points: QueuePoints | null; // Admins only: bonus points are kept from other users +} + +const EMPTY: MamQueueData = { snapshot: null, points: null }; + +/** + * The MAM ratio, and for admins the bonus points, that the Queued page estimates from. + * Read while `enabled`, again whenever `refreshKey` changes (the queue moved). + */ +export const useMamQueue = ( + enabled: boolean, + canSeePoints: boolean, + refreshKey: string, +): MamQueueData => { + const [data, setData] = useState(EMPTY); + + useDependencyEffect(() => { + if (!enabled) return undefined; + let cancelled = false; + const pointsRequest: Promise = canSeePoints + ? getMamAccount() + .then((account) => + account.stats + ? { + balance: account.stats.seedbonus, + perHour: account.points_per_hour?.per_hour ?? null, + perGb: account.points_per_gb ?? 500, + stepGb: account.step_gb ?? 50, + } + : null, + ) + .catch(() => null) + : Promise.resolve(null); + void Promise.all([getMamRatio().catch(() => null), pointsRequest]).then( + ([snapshot, points]) => { + if (!cancelled) setData({ snapshot, points }); + }, + ); + return () => { + cancelled = true; + }; + }, [enabled, canSeePoints, refreshKey]); + + return enabled ? data : EMPTY; +}; diff --git a/src/frontend/src/tests/mamAccount.test.ts b/src/frontend/src/tests/mamAccount.test.ts index e4b914cd4..6d7a9621c 100644 --- a/src/frontend/src/tests/mamAccount.test.ts +++ b/src/frontend/src/tests/mamAccount.test.ts @@ -13,6 +13,7 @@ import { describeUnsatTiming, formatCountdown, formatHoursMinutes, + formatWait, formatUnsat, mamProfileUrl, ratioTone, @@ -206,3 +207,14 @@ describe('describeMamHold', () => { expect(describeMamHold(null)).toBeNull(); }); }); + +describe('formatWait', () => { + it('rounds a wait to hours, days or weeks', () => { + expect(formatWait(0.4)).toBe('under 1 h'); + expect(formatWait(13.2)).toBe('14 h'); + expect(formatWait(71.5)).toBe('3 d'); + expect(formatWait(76)).toBe('3 d 4 h'); + expect(formatWait(24 * 10)).toBe('10 d'); + expect(formatWait(24 * 35)).toBe('5 weeks'); + }); +}); diff --git a/src/frontend/src/tests/mamRatio.test.ts b/src/frontend/src/tests/mamRatio.test.ts index 950ad05c3..ca27a3c6f 100644 --- a/src/frontend/src/tests/mamRatio.test.ts +++ b/src/frontend/src/tests/mamRatio.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import type { Release } from '../types'; import { + estimateQueue, projectRatio, releaseChargeBytes, unsatSlotsFull, @@ -122,3 +123,65 @@ describe('unsatSlotsFull', () => { expect(unsatSlotsFull(slotsSnapshot({ unsat_limit: null }))).toBe(false); }); }); + +const pick = (gib: number, charged = true) => ({ + sizeBytes: gib * GIB, + chargeBytes: charged ? gib * GIB : 0, +}); + +describe('estimateQueue', () => { + // 300 up, 100 down: 50 GiB of room before the ratio drops below 2. + const base: MamRatioSnapshot = { ...snapshot, keep_ratio: 2 }; + const points = { balance: 10_000, perHour: 100, perGb: 500, stepGb: 50 }; + + it('lets what fits go first, and prices the rest in upload credit and hours', () => { + const estimate = estimateQueue( + base, + [[pick(20)], [pick(40)], [pick(5)], [pick(3, false)]], + points, + ); + expect(estimate?.roomBytes).toBe(50 * GIB); + expect(estimate?.totalBytes).toBe(68 * GIB); + expect(estimate?.chargeBytes).toBe(65 * GIB); + // The 40 GiB one waits; the 5 GiB one behind it fits and goes ahead. + expect(estimate?.etas.map((eta) => eta.kind)).toEqual(['fits', 'credit', 'fits', 'free']); + // 165 GiB down needs 330 up: 30 short, so 50 GB (25,000 BP), 15,000 more at 100 an hour. + expect(estimate?.etas[1]).toEqual({ kind: 'credit', creditGb: 50, points: 25_000, hours: 150 }); + expect(estimate).toMatchObject({ creditGb: 50, points: 25_000, hours: 150 }); + }); + + it('counts the credit cumulatively, in queue order', () => { + const estimate = estimateQueue(base, [[pick(60)], [pick(60)]], { + ...points, + balance: 100_000, + }); + // 160 down: 20 short (50 GB); 220 down: 140 short (150 GB). + expect(estimate?.etas).toEqual([ + { kind: 'credit', creditGb: 50, points: 25_000, hours: 0 }, + { kind: 'credit', creditGb: 150, points: 75_000, hours: 0 }, + ]); + }); + + it('takes an item at its slowest pick', () => { + const estimate = estimateQueue(base, [[pick(1), pick(80)]], points); + expect(estimate?.etas[0]).toMatchObject({ kind: 'credit', creditGb: 100 }); + }); + + it('lets an ebook-sized download through below the ratio, as the automatic check does', () => { + const low = { ...base, uploaded_bytes: 150 * GIB }; + const estimate = estimateQueue(low, [[{ sizeBytes: 5e7, chargeBytes: 5e7 }]], points); + expect(estimate?.etas[0]).toEqual({ kind: 'fits' }); + expect(estimate?.roomBytes).toBe(-25 * GIB); + }); + + it('has no wait without the bonus points rate', () => { + expect(estimateQueue(base, [[pick(80)]], null)?.etas[0]).toMatchObject({ hours: null }); + expect(estimateQueue(base, [[pick(80)]], { ...points, perHour: null })?.etas[0]).toMatchObject({ + hours: null, + }); + }); + + it('needs the account', () => { + expect(estimateQueue({ available: false }, [[pick(1)]], points)).toBeNull(); + }); +}); diff --git a/src/frontend/src/tests/settingsCategories.test.ts b/src/frontend/src/tests/settingsCategories.test.ts new file mode 100644 index 000000000..e2fe12895 --- /dev/null +++ b/src/frontend/src/tests/settingsCategories.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; + +import { settingsEntriesInCategory } from '../components/settings/settingsCategories'; +import type { SettingsField, SettingsTab } from '../types/settings'; + +const heading = (key: string): SettingsField => ({ key, type: 'HeadingField', title: key }); + +const prowlarr: SettingsTab = { + name: 'prowlarr_config', + displayName: 'Prowlarr', + order: 41, + fields: [ + heading('prowlarr_heading'), + heading('prowlarr_mam_tab_notice'), + heading('prowlarr_mam_heading'), + ], +}; + +describe('settingsEntriesInCategory', () => { + it('splits MyAnonamouse off the Prowlarr tab, saving as the one backend tab', () => { + const entries = settingsEntriesInCategory([prowlarr], 'indexers'); + expect(entries.map((entry) => [entry.id, entry.label, entry.tabName])).toEqual([ + ['prowlarr_config', 'Prowlarr', 'prowlarr_config'], + ['myanonamouse', 'MyAnonamouse', 'prowlarr_config'], + ]); + expect(entries[0].tab.fields.map((field) => field.key)).toEqual(['prowlarr_heading']); + expect(entries[1].tab.fields.map((field) => field.key)).toEqual([ + 'prowlarr_mam_tab_notice', + 'prowlarr_mam_heading', + ]); + }); + + it('shows the tab whole when the MyAnonamouse heading is missing', () => { + const tab = { ...prowlarr, fields: [heading('prowlarr_heading')] }; + expect(settingsEntriesInCategory([tab], 'indexers').map((entry) => entry.id)).toEqual([ + 'prowlarr_config', + ]); + }); +}); diff --git a/src/frontend/src/utils/mamAccount.ts b/src/frontend/src/utils/mamAccount.ts index 7dd9b68e8..ea6099836 100644 --- a/src/frontend/src/utils/mamAccount.ts +++ b/src/frontend/src/utils/mamAccount.ts @@ -23,6 +23,10 @@ export interface MamAccountResponse { step_gb?: number; // Estimated from Shelfmark's own balance readings; MAM's API has no rate. points_per_hour?: { per_hour: number; hours: number } | null; + pending_bytes?: number; // Shelfmark's MAM downloads still active + keep_ratio?: number; // Keep Ratio At Least, or 1.0 when that's off + // Downloadable before the ratio drops below keep_ratio, after the active downloads + room_bytes?: number; } export interface MamConnection { @@ -142,6 +146,24 @@ export const formatGib = (bytes: number): string => { return `${sign}${gib.toFixed(gib >= 100 ? 0 : 1)} GiB`; }; +/** The Room stat's tooltip: what it measures, and the plain buffer behind it. */ +export const describeRoom = ( + keepRatio: number, + bufferBytes: number, + pendingBytes: number, +): string => { + const lines = [ + keepRatio > 1 + ? `How much more can be downloaded (not freeleech) before the ratio drops below ${formatRatio(keepRatio)}, your Keep Ratio At Least setting.` + : 'How much more can be downloaded (not freeleech) before the ratio drops below 1.00. Set Keep Ratio At Least to measure to a higher ratio.', + ]; + if (pendingBytes > 0) { + lines.push(`Counts the ${formatGib(pendingBytes)} Shelfmark's active MAM downloads will add.`); + } + lines.push(`Buffer (uploaded minus downloaded): ${formatGib(bufferBytes)}`); + return lines.join('\n'); +}; + export const formatRatio = (ratio: number | null): string => { if (ratio === null) return '—'; if (!Number.isFinite(ratio)) return '∞'; @@ -177,6 +199,18 @@ export const formatHoursMinutes = (seconds: number): string => { return `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, '0')}`; }; +/** A wait in hours, roughly: "under 1 h", "14 h", "3 d 4 h", "5 weeks". */ +export const formatWait = (hours: number): string => { + if (hours < 1) return 'under 1 h'; + const total = Math.ceil(hours); + if (total < 48) return `${total} h`; + const days = Math.floor(total / 24); + const rest = total % 24; + if (days >= 28) return `${Math.round(total / 168)} weeks`; + if (days >= 7) return `${Math.round(total / 24)} d`; + return rest > 0 ? `${days} d ${rest} h` : `${days} d`; +}; + /** A live countdown: "2:05:13", or "4:09" under an hour. */ export const formatCountdown = (seconds: number): string => { const total = Math.max(0, Math.ceil(seconds)); diff --git a/src/frontend/src/utils/mamRatio.ts b/src/frontend/src/utils/mamRatio.ts index 65de95dc1..243d1d343 100644 --- a/src/frontend/src/utils/mamRatio.ts +++ b/src/frontend/src/utils/mamRatio.ts @@ -11,6 +11,8 @@ export interface MamRatioSnapshot { buffer_bytes?: number; pending_bytes?: number; // Shelfmark's MAM downloads still active warning_ratio?: number; + keep_ratio?: number; // Keep Ratio At Least, or 1.0 when that's off + room_bytes?: number; // Downloadable before the ratio drops below keep_ratio, after pending unsat_count?: number | null; unsat_limit?: number | null; unsat_pending?: number; // Shelfmark's MAM downloads not yet started @@ -126,3 +128,123 @@ export const projectRatio = ( tone, }; }; + +const GIB = 1024 ** 3; +// Mirrors saved_autoget: an ebook-sized download that barely moves the ratio goes anyway. +const SMALL_DOWNLOAD_BYTES = 100 * 1024 ** 2; +const SMALL_RATIO_DROP = 0.01; + +/** One release waiting in the queue: its size, and what it adds to MAM's downloaded total. */ +export interface QueuedPick { + sizeBytes: number; + chargeBytes: number; // 0 for freeleech and non-MAM releases +} + +/** Bonus points and what they buy, from /api/mam/account (admins only). */ +export interface QueuePoints { + balance: number; + perHour: number | null; // Estimated earning rate; null until there are enough readings + perGb: number; + stepGb: number; +} + +/** + * When a queued item fits the ratio: now (`fits`, `free` when nothing counts against it), or + * once `creditGb` of upload credit is bought with `points` bonus points. `hours` is the wait + * to earn the points still missing: 0 when there are enough now, null when the rate is unknown. + */ +export type QueueEta = + | { kind: 'free' } + | { kind: 'fits' } + | { kind: 'credit'; creditGb: number; points: number; hours: number | null }; + +export interface QueueEstimate { + keepRatio: number; + roomBytes: number; // Before the queue, after the active downloads + totalBytes: number; // Every queued release + chargeBytes: number; // The part that counts against the ratio + creditGb: number; // Upload credit to buy for all of it to keep the ratio + points: number; // Bonus points that credit costs + hours: number | null; // Wait for those points; see QueueEta + etas: QueueEta[]; // One per item, in queue order +} + +const etaRank = (eta: QueueEta): number => { + if (eta.kind === 'free') return 0; + if (eta.kind === 'fits') return 1; + return 2 + eta.creditGb; +}; + +/** + * Estimates when each queued item fits the ratio (Keep Ratio At Least). Like the automatic + * check, a release that fits now goes ahead of an earlier one that doesn't; the rest go in + * queue order as upload credit, bought with bonus points, makes room. Doesn't know about + * unsatisfied slots, or a torrent turning freeleech. + */ +export const estimateQueue = ( + snapshot: MamRatioSnapshot, + items: QueuedPick[][], + points: QueuePoints | null, +): QueueEstimate | null => { + if (!snapshot.available) return null; + const uploaded = positive(snapshot.uploaded_bytes); + const keepRatio = positive(snapshot.keep_ratio) || 1; + let downloaded = positive(snapshot.downloaded_bytes) + positive(snapshot.pending_bytes); + const roomBytes = snapshot.room_bytes ?? Math.floor(uploaded / keepRatio - downloaded); + const perGb = points?.perGb ?? 500; + const stepGb = points?.stepGb ?? 50; + const ratio = (down: number) => (down > 0 ? uploaded / down : Number.POSITIVE_INFINITY); + + // What fits now, in queue order; the rest wait for credit. + const picks = items.map((item) => + item.map((pick): { charge: number; eta: QueueEta | null } => { + const charge = positive(pick.chargeBytes); + if (charge === 0) return { charge, eta: { kind: 'free' } }; + const after = ratio(downloaded + charge); + const small = charge <= SMALL_DOWNLOAD_BYTES && ratio(downloaded) - after < SMALL_RATIO_DROP; + if (after >= keepRatio || small) { + downloaded += charge; + return { charge, eta: { kind: 'fits' } }; + } + return { charge, eta: null }; + }), + ); + + const creditFor = (down: number) => { + const shortGb = Math.max(0, keepRatio * down - uploaded) / GIB; + const creditGb = Math.ceil(shortGb / stepGb) * stepGb; + const cost = creditGb * perGb; + let hours: number | null = null; + if (points && cost <= points.balance) hours = 0; + else if (points?.perHour) hours = (cost - points.balance) / points.perHour; + return { creditGb, points: cost, hours }; + }; + + let last: QueueEta = { kind: 'fits' }; + for (const item of picks) { + for (const pick of item) { + if (pick.eta) continue; + downloaded += pick.charge; + pick.eta = { kind: 'credit', ...creditFor(downloaded) }; + last = pick.eta; + } + } + + const etas = picks.map((item) => + item.reduce( + (worst, pick) => (pick.eta && etaRank(pick.eta) > etaRank(worst) ? pick.eta : worst), + { kind: item.length > 0 && item.every((pick) => pick.charge === 0) ? 'free' : 'fits' }, + ), + ); + const all = items.flat(); + return { + keepRatio, + roomBytes, + totalBytes: all.reduce((total, pick) => total + positive(pick.sizeBytes), 0), + chargeBytes: all.reduce((total, pick) => total + positive(pick.chargeBytes), 0), + creditGb: last.kind === 'credit' ? last.creditGb : 0, + points: last.kind === 'credit' ? last.points : 0, + hours: last.kind === 'credit' ? last.hours : 0, + etas, + }; +}; diff --git a/tests/prowlarr/test_mam_account.py b/tests/prowlarr/test_mam_account.py index 11ce8805c..8dff92162 100644 --- a/tests/prowlarr/test_mam_account.py +++ b/tests/prowlarr/test_mam_account.py @@ -255,6 +255,9 @@ def test_account(self, main_module, mam): assert body["stats"]["username"] == "mouse" assert body["stats"]["buffer_bytes"] == int(80.5 * GIB) assert (body["points_per_gb"], body["step_gb"]) == (500, 50) + # Room before the ratio drops below Keep Ratio At Least (2.0): 120.5 / 2 - 40 + assert body["keep_ratio"] == 2.0 + assert body["room_bytes"] == int(20.25 * GIB) def test_account_error_is_shown(self, main_module, mam): mam.user = MamAuthError("rejected (403)") @@ -667,3 +670,18 @@ def test_without_an_earlier_reading_every_snatch_counts(self, mam, monkeypatch): self._snatch(monkeypatch, 13, at=time.time() - 60) mam.user = _unsat_user(132, 150) assert mam_account.unsat_free_slots(refresh=True) == 0 + + +class TestRatioRoom: + def test_room_to_ratio(self): + assert mam_account.ratio_room_bytes(100 * GIB, 30 * GIB, 2.0) == 20 * GIB + assert mam_account.ratio_room_bytes(100 * GIB, 30 * GIB, 1.0) == 70 * GIB + + def test_negative_once_below(self): + assert mam_account.ratio_room_bytes(100 * GIB, 60 * GIB, 2.0) == -10 * GIB + + def test_keep_ratio_falls_back_to_the_buffer_when_off(self): + with patch("shelfmark.core.saved_autoget.target_ratio", return_value=0.0): + assert mam_account.keep_ratio() == 1.0 + with patch("shelfmark.core.saved_autoget.target_ratio", return_value=2.5): + assert mam_account.keep_ratio() == 2.5 From 70cc0c9d0f9971e15a0081011666a004d536ae45 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 16:55:15 +0000 Subject: [PATCH 2/2] feat: Queued page ETAs account for unsatisfied slots The ETA is now when an item can start once both the ratio and an unsatisfied slot allow: MAM picks take the free slots (after Up next and the kept-free slots), then each as a seeding torrent reaches 72 hours, then the queue's own 72 hours after they start; MAM's download pause holds them all. /api/mam/unsat-timing reports every seeding torrent's remaining time, and /api/mam/ratio whether the slot check is on. The summary adds when the last of the queue can start. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ULvMD6k72dAoo7JXgkhk6p --- CHANGELOG.md | 5 +- docs/saved-for-later.md | 25 ++- .../release_sources/prowlarr/mam_account.py | 12 +- src/frontend/src/App.tsx | 3 + .../src/components/activity/QueueSummary.tsx | 118 +++++++--- src/frontend/src/hooks/useMamQueue.ts | 21 +- src/frontend/src/tests/mamRatio.test.ts | 111 +++++++-- src/frontend/src/utils/mamAccount.ts | 1 + src/frontend/src/utils/mamRatio.ts | 212 +++++++++++++----- tests/prowlarr/test_mam_unsat_timing.py | 5 +- 10 files changed, 385 insertions(+), 128 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7361302c0..36af3e918 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,9 @@ Releases of this fork. Images: `ghcr.io/mikaeltarquin/shelfmark:` and ` - **The Queued page shows the size of the queue and when each item fits your ratio.** At the top: the total size, how much counts against your MyAnonamouse ratio, the room left before it drops below **Keep Ratio At Least**, and the upload credit the whole - queue needs. Each saved item gets an **ETA**: fits now, freeleech, or the upload credit - it needs and, for admins, roughly how long the bonus points for it take to earn. + queue needs. Each saved item gets an **ETA**, when both the ratio and an unsatisfied slot + allow it to start: from the upload credit it needs and (for admins) how long the bonus + points take to earn, and from when torrents seeding in your client reach 72 hours. - **MyAnonamouse has its own settings tab**, under **Settings → Indexers**, after Prowlarr. ### Changed diff --git a/docs/saved-for-later.md b/docs/saved-for-later.md index 8d8a0253d..c08165e9e 100644 --- a/docs/saved-for-later.md +++ b/docs/saved-for-later.md @@ -38,7 +38,7 @@ top, its total size, and with MyAnonamouse set up: how much of the saved picks c against your ratio (freeleech doesn't), the room left before the ratio drops below **Keep Ratio At Least**, and the upload credit it would take for all of it to download without going under (with, for admins, the bonus points that costs and roughly how long earning -them takes at your points-per-hour rate). +them takes at your points-per-hour rate), and when the last of it can start. - **Up next**: downloads already handed to Shelfmark that haven't started, such as a MyAnonamouse torrent waiting for an unsatisfied slot. They're numbered in the order they @@ -49,12 +49,23 @@ them takes at your points-per-hour rate). set up), when it was **Last checked** and a countdown to the **Next check**. Drag these to reorder them too: earlier ones get free room first. **+ Get** downloads one now without waiting; **Remove** drops it. -The **ETA** goes by the ratio alone, as the automatic check does: **Fits now**, **Freeleech**, -or, once the room runs out, the upload credit that makes room for it (counting the picks -ahead of it, in queue order) and how long the bonus points for it take, or **Buy credit** -when you have them already. Picks that fit go ahead of earlier ones that don't. It -doesn't know when an unsatisfied slot frees up, or a torrent turns freeleech, and upload -credit for automatic downloads is only bought by you or auto-buy. +The **ETA** is when an item can start, as the automatic check would get to it: once both +the ratio and an unsatisfied slot allow. + +- **Ratio:** a pick that fits now goes; once the room runs out, a pick waits for the + upload credit that makes room for it (counting the picks ahead of it, in queue order) + and the bonus points to buy it at your points-per-hour rate. **Buy credit** means you + have the points already. Picks that fit go ahead of earlier ones that don't. +- **Slots:** MAM picks (freeleech too) take the free slots first (after **Up next** and the + slots kept free), then each slot as a torrent seeding in your client reaches 72 hours, + then the slots the queue's own torrents free 72 hours after they start. Without the + client's seeding times, a pick past the free slots shows **For a slot**, time unknown. + MAM's download pause holds everything until it lifts. + +It doesn't know when a torrent turns freeleech, counts seeding time as the client reports +it (MAM keeps its own clock, so pausing or going offline pushes slots later), and upload +credit for automatic downloads is only bought by you or auto-buy. A wait on bonus points +needs an admin: other users see the credit needed. **Wanted** lists books saved without picks. **Choose releases** opens the release list; pick and save, and the book moves to Queued. Rows with picks that couldn't be queued diff --git a/shelfmark/release_sources/prowlarr/mam_account.py b/shelfmark/release_sources/prowlarr/mam_account.py index 4252c14c1..9487b79db 100644 --- a/shelfmark/release_sources/prowlarr/mam_account.py +++ b/shelfmark/release_sources/prowlarr/mam_account.py @@ -441,6 +441,8 @@ def summarize_unsat_timing(torrents: list[Any], client_name: str | None) -> dict "available": True, "client": client_name, "next_seconds": remaining[0] if remaining else None, + # When each one frees its slot, soonest first: the Queued page's slot ETAs + "free_seconds": remaining, "within_window": sum(1 for r in remaining if r <= UNSAT_WINDOW_SECONDS), "window_hours": UNSAT_WINDOW_SECONDS // 3600, "seeding": len(remaining), @@ -483,12 +485,13 @@ def _client_torrents(*, refresh: bool = False) -> _ClientRead: def _aged_timing(timing: dict[str, Any], age: float) -> dict[str, Any]: - """A cached timing as of now: the next slot is `age` seconds closer than when read.""" + """A cached timing as of now: each slot is `age` seconds closer than when read.""" + # Rounded up, so a slot is never promised early. + aged = [max(0, math.ceil(seconds - age)) for seconds in timing.get("free_seconds", [])] next_seconds = timing.get("next_seconds") if not isinstance(next_seconds, (int, float)): - return timing - # Rounded up, so a slot is never promised early. - return {**timing, "next_seconds": max(0, math.ceil(next_seconds - age))} + return {**timing, "free_seconds": aged} + return {**timing, "next_seconds": max(0, math.ceil(next_seconds - age)), "free_seconds": aged} def client_unsat_count( @@ -919,4 +922,5 @@ def ratio_snapshot() -> dict[str, Any]: "unsat_limit": stats.unsat_limit, "unsat_pending": pending_unsat_count(stats.fetched_at, stats.unsat_count), "unsat_reserve": unsat_reserve_slots(), + "unsat_check": unsat_check_enabled(), } diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index bb9cb9cfc..cec71bbd7 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -129,6 +129,7 @@ import { getDynamicOptionGroup } from './utils/dynamicFieldOptions'; import { resolveDefaultLanguageCodes } from './utils/languageFilters'; import { estimateQueue, + isMamRelease, releaseChargeBytes, type MamBufferCheck, type MamRatioSnapshot, @@ -984,9 +985,11 @@ function App() { item.releases.map((pick) => ({ sizeBytes: pick.release.size_bytes ?? 0, chargeBytes: releaseChargeBytes(pick.release), + mam: isMamRelease(pick.release), })), ), mamQueue.points, + mamQueue.slots, ) : null, [mamQueue, savedQueue], diff --git a/src/frontend/src/components/activity/QueueSummary.tsx b/src/frontend/src/components/activity/QueueSummary.tsx index 608d2a07a..e9ec59057 100644 --- a/src/frontend/src/components/activity/QueueSummary.tsx +++ b/src/frontend/src/components/activity/QueueSummary.tsx @@ -1,5 +1,5 @@ import { formatGib, formatPoints, formatRatio, formatWait } from '../../utils/mamAccount'; -import type { QueueEstimate, QueueEta, QueuePoints } from '../../utils/mamRatio'; +import type { QueueCredit, QueueEstimate, QueueEta, QueuePoints } from '../../utils/mamRatio'; interface QueueSummaryProps { count: number; // Downloads up next plus saved items waiting @@ -38,7 +38,37 @@ const describeCreditWait = (hours: number | null, points: QueuePoints | null): s return points ? 'Points rate not known yet' : 'Ask an admin to buy upload credit'; }; -/** A queued item's ETA cell: when it fits the ratio, and what that takes. */ +const creditLine = (credit: QueueCredit): string => + `${credit.gb} GB credit · ${formatPoints(credit.points)} BP`; + +/** In words, for a tooltip: why an item starts when it does. */ +const describeEta = (eta: QueueEta, keepRatio: number, points: QueuePoints | null): string => { + const ratio = formatRatio(keepRatio); + const lines: string[] = []; + if (eta.credit) { + lines.push( + `Counting what's ahead of it, ${eta.credit.gb} GB of upload credit (${formatPoints( + eta.credit.points, + )} bonus points) keeps the ratio at ${ratio}. ${describeCreditWait(eta.credit.hours, points)}${ + points?.perHour ? ` at ~${Math.round(points.perHour)} an hour` : '' + }.`, + ); + } else if (!eta.free) { + lines.push(`Fits without the ratio dropping below ${ratio}.`); + } + if (eta.waitsFor === 'slot') { + lines.push( + eta.seconds === null + ? "Waits for an unsatisfied slot; the torrent client can't say when one frees up." + : 'Waits for an unsatisfied slot: estimated from when seeding torrents reach 72 hours (and those ahead of it free theirs).', + ); + } else if (eta.waitsFor === 'pause') { + lines.push('Waits for MyAnonamouse to lift its download pause.'); + } + return lines.join('\n'); +}; + +/** A queued item's ETA cell: when it can start, and what it waits for. */ export const QueueEtaCell = ({ eta, keepRatio, @@ -50,40 +80,47 @@ export const QueueEtaCell = ({ points: QueuePoints | null; isMam: boolean; }) => { - if (eta.kind === 'free') { - return {isMam ? 'Freeleech' : '—'}; - } - if (eta.kind === 'fits') { - return ( - - Fits now - - ); + const title = describeEta(eta, keepRatio, points); + let headline: string; + let detail: string | null = null; + let tone = ''; + if (eta.seconds === 0) { + tone = 'text-emerald-700 dark:text-emerald-400'; + headline = 'Now'; + if (eta.credit) { + headline = 'Buy credit'; + tone = 'text-amber-700 dark:text-amber-400'; + detail = creditLine(eta.credit); + } else if (eta.free && isMam) { + detail = 'Freeleech'; + } + } else if (eta.seconds !== null) { + headline = `in ${formatWait(eta.seconds / 3600)}`; + if (eta.waitsFor === 'slot') detail = 'For a slot'; + else if (eta.waitsFor === 'pause') detail = 'MAM download pause'; + else if (eta.credit) detail = creditLine(eta.credit); + } else if (eta.waitsFor === 'slot') { + headline = 'For a slot'; + detail = 'Time unknown'; + } else { + headline = eta.credit ? `${eta.credit.gb} GB credit` : 'Unknown'; + detail = points ? 'Points rate unknown' : 'Ask an admin'; } - let headline = `${eta.creditGb} GB credit`; - if (eta.hours === 0) headline = 'Buy credit'; - else if (eta.hours !== null) headline = `~${formatWait(eta.hours)}`; + if (!isMam && eta.seconds === 0) return —; return ( - - {headline} - - {eta.creditGb} GB · {formatPoints(eta.points)} BP - + + {headline} + {detail && {detail}} ); }; -/** The top of the Queued page: how much is waiting, and how it sits with the ratio. */ +const describeStart = (seconds: number | null): string => { + if (seconds === null) return 'Unknown'; + return seconds === 0 ? 'Now' : `in ${formatWait(seconds / 3600)}`; +}; + +/** The top of the Queued page: how much is waiting, and how it sits with the ratio and slots. */ export const QueueSummary = ({ count, sizeBytes, estimate, points }: QueueSummaryProps) => { if (count === 0) return null; const tiles = [ @@ -96,6 +133,7 @@ export const QueueSummary = ({ count, sizeBytes, estimate, points }: QueueSummar ]; if (estimate) { const ratio = formatRatio(estimate.keepRatio); + const { credit } = estimate; tiles.push( , - ); - tiles.push( - estimate.creditGb > 0 ? ( + credit && credit.gb > 0 ? ( ), + , ); } return ( -
+
{tiles}
); diff --git a/src/frontend/src/hooks/useMamQueue.ts b/src/frontend/src/hooks/useMamQueue.ts index cb6e65201..7d823b5c6 100644 --- a/src/frontend/src/hooks/useMamQueue.ts +++ b/src/frontend/src/hooks/useMamQueue.ts @@ -1,18 +1,19 @@ import { useState } from 'react'; -import { getMamAccount, getMamRatio } from '../services/api'; -import type { MamRatioSnapshot, QueuePoints } from '../utils/mamRatio'; +import { getMamAccount, getMamRatio, getMamUnsatTiming } from '../services/api'; +import type { MamRatioSnapshot, QueuePoints, QueueSlots } from '../utils/mamRatio'; import { useDependencyEffect } from './useMountEffect'; export interface MamQueueData { snapshot: MamRatioSnapshot | null; points: QueuePoints | null; // Admins only: bonus points are kept from other users + slots: QueueSlots | null; } -const EMPTY: MamQueueData = { snapshot: null, points: null }; +const EMPTY: MamQueueData = { snapshot: null, points: null, slots: null }; /** - * The MAM ratio, and for admins the bonus points, that the Queued page estimates from. + * The MAM ratio, slot timing and, for admins, bonus points the Queued page estimates from. * Read while `enabled`, again whenever `refreshKey` changes (the queue moved). */ export const useMamQueue = ( @@ -39,9 +40,15 @@ export const useMamQueue = ( ) .catch(() => null) : Promise.resolve(null); - void Promise.all([getMamRatio().catch(() => null), pointsRequest]).then( - ([snapshot, points]) => { - if (!cancelled) setData({ snapshot, points }); + const slotsRequest: Promise = getMamUnsatTiming() + .then((timing) => ({ + freeSeconds: timing.available ? (timing.free_seconds ?? null) : null, + pausedSeconds: timing.frozen_seconds ?? 0, + })) + .catch(() => null); + void Promise.all([getMamRatio().catch(() => null), pointsRequest, slotsRequest]).then( + ([snapshot, points, slots]) => { + if (!cancelled) setData({ snapshot, points, slots }); }, ); return () => { diff --git a/src/frontend/src/tests/mamRatio.test.ts b/src/frontend/src/tests/mamRatio.test.ts index ca27a3c6f..aae5a9ef4 100644 --- a/src/frontend/src/tests/mamRatio.test.ts +++ b/src/frontend/src/tests/mamRatio.test.ts @@ -127,10 +127,13 @@ describe('unsatSlotsFull', () => { const pick = (gib: number, charged = true) => ({ sizeBytes: gib * GIB, chargeBytes: charged ? gib * GIB : 0, + mam: true, }); +const HOUR = 3600; +const freeleech = () => [pick(1, false)]; describe('estimateQueue', () => { - // 300 up, 100 down: 50 GiB of room before the ratio drops below 2. + // 300 up, 100 down: 50 GiB of room before the ratio drops below 2. No slot limit. const base: MamRatioSnapshot = { ...snapshot, keep_ratio: 2 }; const points = { balance: 10_000, perHour: 100, perGb: 500, stepGb: 50 }; @@ -144,10 +147,17 @@ describe('estimateQueue', () => { expect(estimate?.totalBytes).toBe(68 * GIB); expect(estimate?.chargeBytes).toBe(65 * GIB); // The 40 GiB one waits; the 5 GiB one behind it fits and goes ahead. - expect(estimate?.etas.map((eta) => eta.kind)).toEqual(['fits', 'credit', 'fits', 'free']); + expect(estimate?.etas.map((eta) => eta.seconds)).toEqual([0, 150 * HOUR, 0, 0]); + expect(estimate?.etas.map((eta) => eta.free)).toEqual([false, false, false, true]); // 165 GiB down needs 330 up: 30 short, so 50 GB (25,000 BP), 15,000 more at 100 an hour. - expect(estimate?.etas[1]).toEqual({ kind: 'credit', creditGb: 50, points: 25_000, hours: 150 }); - expect(estimate).toMatchObject({ creditGb: 50, points: 25_000, hours: 150 }); + expect(estimate?.etas[1]).toEqual({ + seconds: 150 * HOUR, + waitsFor: 'ratio', + credit: { gb: 50, points: 25_000, hours: 150 }, + free: false, + }); + expect(estimate?.credit).toEqual({ gb: 50, points: 25_000, hours: 150 }); + expect(estimate?.seconds).toBe(150 * HOUR); }); it('counts the credit cumulatively, in queue order', () => { @@ -155,33 +165,104 @@ describe('estimateQueue', () => { ...points, balance: 100_000, }); - // 160 down: 20 short (50 GB); 220 down: 140 short (150 GB). - expect(estimate?.etas).toEqual([ - { kind: 'credit', creditGb: 50, points: 25_000, hours: 0 }, - { kind: 'credit', creditGb: 150, points: 75_000, hours: 0 }, + // 160 down: 20 short (50 GB); 220 down: 140 short (150 GB). Both affordable now. + expect(estimate?.etas.map((eta) => [eta.seconds, eta.credit?.gb])).toEqual([ + [0, 50], + [0, 150], ]); }); it('takes an item at its slowest pick', () => { const estimate = estimateQueue(base, [[pick(1), pick(80)]], points); - expect(estimate?.etas[0]).toMatchObject({ kind: 'credit', creditGb: 100 }); + expect(estimate?.etas[0]).toMatchObject({ seconds: 400 * HOUR, credit: { gb: 100 } }); }); it('lets an ebook-sized download through below the ratio, as the automatic check does', () => { const low = { ...base, uploaded_bytes: 150 * GIB }; - const estimate = estimateQueue(low, [[{ sizeBytes: 5e7, chargeBytes: 5e7 }]], points); - expect(estimate?.etas[0]).toEqual({ kind: 'fits' }); + const ebook = { sizeBytes: 5e7, chargeBytes: 5e7, mam: true }; + const estimate = estimateQueue(low, [[ebook]], points); + expect(estimate?.etas[0]).toMatchObject({ seconds: 0, credit: null }); expect(estimate?.roomBytes).toBe(-25 * GIB); }); - it('has no wait without the bonus points rate', () => { - expect(estimateQueue(base, [[pick(80)]], null)?.etas[0]).toMatchObject({ hours: null }); - expect(estimateQueue(base, [[pick(80)]], { ...points, perHour: null })?.etas[0]).toMatchObject({ - hours: null, + it('has no time without the bonus points rate', () => { + expect(estimateQueue(base, [[pick(80)]], null)?.etas[0]).toMatchObject({ + seconds: null, + waitsFor: 'ratio', }); + expect(estimateQueue(base, [[pick(80)]], { ...points, perHour: null })?.seconds).toBeNull(); }); it('needs the account', () => { expect(estimateQueue({ available: false }, [[pick(1)]], points)).toBeNull(); }); + + describe('unsatisfied slots', () => { + // 2 of 10 slots free; two seeding torrents reach 72 hours in 1 and 2 hours. + const slotted: MamRatioSnapshot = { + ...base, + unsat_count: 8, + unsat_limit: 10, + unsat_reserve: 0, + unsat_pending: 0, + }; + const timing = { freeSeconds: [2 * HOUR, HOUR], pausedSeconds: 0 }; + + it('takes the free slots, then each as it frees, then the queue’s own after 72 hours', () => { + const estimate = estimateQueue(slotted, Array.from({ length: 5 }, freeleech), points, timing); + expect(estimate?.etas.map((eta) => [eta.seconds, eta.waitsFor])).toEqual([ + [0, null], + [0, null], + [HOUR, 'slot'], + [2 * HOUR, 'slot'], + [72 * HOUR, 'slot'], + ]); + }); + + it('lets up next downloads and the kept-free slots go first', () => { + const full = { ...slotted, unsat_pending: 1, unsat_reserve: 2 }; + const estimate = estimateQueue(full, [freeleech()], points, timing); + expect(estimate?.etas[0].seconds).toBe(2 * HOUR); + }); + + it('gives a slot to what the ratio lets go first', () => { + const one = { ...slotted, unsat_count: 9 }; + const estimate = estimateQueue(one, [[pick(60)], [pick(5)]], points, { + freeSeconds: [], + pausedSeconds: 0, + }); + // The 5 GiB one takes the free slot; the 60 GiB one's credit takes 150 hours, + // by when the 5 GiB one has freed its slot. + expect(estimate?.etas.map((eta) => [eta.seconds, eta.waitsFor])).toEqual([ + [150 * HOUR, 'ratio'], + [0, null], + ]); + }); + + it("doesn't promise a slot past the free ones without the client's timing", () => { + const estimate = estimateQueue(slotted, [freeleech(), freeleech(), freeleech()], points, { + freeSeconds: null, + pausedSeconds: 0, + }); + expect(estimate?.etas.map((eta) => eta.seconds)).toEqual([0, 0, null]); + expect(estimate?.etas[2].waitsFor).toBe('slot'); + expect(estimate?.seconds).toBeNull(); + }); + + it("waits out MAM's download pause", () => { + const estimate = estimateQueue(slotted, [freeleech()], points, { + ...timing, + pausedSeconds: 5000, + }); + expect(estimate?.etas[0]).toMatchObject({ seconds: 5000, waitsFor: 'pause' }); + }); + + it('ignores slots when the check is off, and for releases not on MAM', () => { + const off = { ...slotted, unsat_count: 10, unsat_check: false }; + expect(estimateQueue(off, [freeleech()], points, timing)?.etas[0].seconds).toBe(0); + const full = { ...slotted, unsat_count: 10 }; + const other = [{ sizeBytes: GIB, chargeBytes: 0, mam: false }]; + expect(estimateQueue(full, [other], points, timing)?.etas[0].seconds).toBe(0); + }); + }); }); diff --git a/src/frontend/src/utils/mamAccount.ts b/src/frontend/src/utils/mamAccount.ts index ea6099836..3ba44482a 100644 --- a/src/frontend/src/utils/mamAccount.ts +++ b/src/frontend/src/utils/mamAccount.ts @@ -183,6 +183,7 @@ export interface MamUnsatTiming { reason?: string; // Why there's no timing (no client, client can't report it...) client?: string | null; next_seconds?: number | null; // Until the next torrent reaches 72 hours seeded + free_seconds?: number[]; // Until each seeding one reaches 72 hours, soonest first within_window?: number; // Torrents reaching 72 hours within window_hours window_hours?: number; seeding?: number; // MAM torrents seeding but not yet at 72 hours diff --git a/src/frontend/src/utils/mamRatio.ts b/src/frontend/src/utils/mamRatio.ts index 243d1d343..15b7300db 100644 --- a/src/frontend/src/utils/mamRatio.ts +++ b/src/frontend/src/utils/mamRatio.ts @@ -17,6 +17,7 @@ export interface MamRatioSnapshot { unsat_limit?: number | null; unsat_pending?: number; // Shelfmark's MAM downloads not yet started unsat_reserve?: number; // Slots kept free + unsat_check?: boolean; // Whether MAM downloads wait for a slot at all } export interface MamBufferCheck { @@ -133,11 +134,14 @@ const GIB = 1024 ** 3; // Mirrors saved_autoget: an ebook-sized download that barely moves the ratio goes anyway. const SMALL_DOWNLOAD_BYTES = 100 * 1024 ** 2; const SMALL_RATIO_DROP = 0.01; +// MAM counts a torrent satisfied once it has seeded this long, freeing its slot. +const UNSAT_SEED_SECONDS = 72 * 3600; -/** One release waiting in the queue: its size, and what it adds to MAM's downloaded total. */ +/** One release waiting in the queue. */ export interface QueuedPick { sizeBytes: number; - chargeBytes: number; // 0 for freeleech and non-MAM releases + chargeBytes: number; // What it adds to MAM's downloaded total: 0 for freeleech and non-MAM + mam: boolean; // A MAM torrent, so it takes an unsatisfied slot (freeleech too) } /** Bonus points and what they buy, from /api/mam/account (admins only). */ @@ -148,43 +152,87 @@ export interface QueuePoints { stepGb: number; } +/** When unsatisfied slots free up, from /api/mam/unsat-timing. */ +export interface QueueSlots { + // Until each seeding unsatisfied torrent reaches 72 hours, soonest first; null when the + // torrent client can't say, so only the slots free now are known. + freeSeconds: number[] | null; + pausedSeconds: number; // MAM's download pause still to run, 0 when none +} + +/** Upload credit that keeps the ratio, counting the picks ahead; `hours` as on QueueEta. */ +export interface QueueCredit { + gb: number; + points: number; + hours: number | null; // To earn the points still missing: 0 when there are enough now +} + /** - * When a queued item fits the ratio: now (`fits`, `free` when nothing counts against it), or - * once `creditGb` of upload credit is bought with `points` bonus points. `hours` is the wait - * to earn the points still missing: 0 when there are enough now, null when the rate is unknown. + * When a queued item can start: `seconds` from now (0 now, null when it can't be told), + * and what sets it when it isn't now. `credit` is the upload credit it needs, if any; + * `free` when none of it counts against the ratio. */ -export type QueueEta = - | { kind: 'free' } - | { kind: 'fits' } - | { kind: 'credit'; creditGb: number; points: number; hours: number | null }; +export interface QueueEta { + seconds: number | null; + waitsFor: 'ratio' | 'slot' | 'pause' | null; + credit: QueueCredit | null; + free: boolean; +} export interface QueueEstimate { keepRatio: number; roomBytes: number; // Before the queue, after the active downloads totalBytes: number; // Every queued release chargeBytes: number; // The part that counts against the ratio - creditGb: number; // Upload credit to buy for all of it to keep the ratio - points: number; // Bonus points that credit costs - hours: number | null; // Wait for those points; see QueueEta + credit: QueueCredit | null; // For all of it to keep the ratio + seconds: number | null; // Until the last of it can start etas: QueueEta[]; // One per item, in queue order } -const etaRank = (eta: QueueEta): number => { - if (eta.kind === 'free') return 0; - if (eta.kind === 'fits') return 1; - return 2 + eta.creditGb; +interface PickPlan { + index: number; // Place in the queue, across items + mam: boolean; + charge: number; + credit: QueueCredit | null; + ratioSeconds: number | null; // When the ratio lets it go + eta: QueueEta; +} + +// Later is worse; never (null) is worst. +const laterThan = (a: number | null, b: number | null): boolean => + a === null ? b !== null : b !== null && a > b; + +/** + * The slots MAM picks can take, as seconds from now: one at 0 for each free now, then one + * as each seeding torrent reaches 72 hours. Up next downloads take the first ones. + */ +const slotTimes = (snapshot: MamRatioSnapshot, slots: QueueSlots | null): number[] | null => { + const { unsat_count: count, unsat_limit: limit } = snapshot; + if (snapshot.unsat_check === false || typeof count !== 'number' || typeof limit !== 'number') { + return null; // Nothing to wait for + } + const free = limit - (snapshot.unsat_reserve ?? 0) - count - (snapshot.unsat_pending ?? 0); + const freeing = (slots?.freeSeconds ?? []).toSorted((a, b) => a - b); + return [ + ...Array.from({ length: Math.max(free, 0) }, () => 0), + ...freeing.slice(Math.max(-free, 0)), + ]; }; /** - * Estimates when each queued item fits the ratio (Keep Ratio At Least). Like the automatic - * check, a release that fits now goes ahead of an earlier one that doesn't; the rest go in - * queue order as upload credit, bought with bonus points, makes room. Doesn't know about - * unsatisfied slots, or a torrent turning freeleech. + * Estimates when each queued item can start, as the automatic check would get to it: + * once the ratio (Keep Ratio At Least) allows and an unsatisfied slot is free. + * + * Ratio: a release that fits now goes ahead of an earlier one that doesn't; the rest go + * in queue order as upload credit, bought with bonus points, makes room. Slots: free + * ones first, then as seeding torrents reach 72 hours, each grab freeing its own 72 hours + * after it starts. Doesn't know about a torrent turning freeleech. */ export const estimateQueue = ( snapshot: MamRatioSnapshot, items: QueuedPick[][], points: QueuePoints | null, + slots: QueueSlots | null = null, ): QueueEstimate | null => { if (!snapshot.available) return null; const uploaded = positive(snapshot.uploaded_bytes); @@ -194,57 +242,109 @@ export const estimateQueue = ( const perGb = points?.perGb ?? 500; const stepGb = points?.stepGb ?? 50; const ratio = (down: number) => (down > 0 ? uploaded / down : Number.POSITIVE_INFINITY); - - // What fits now, in queue order; the rest wait for credit. - const picks = items.map((item) => - item.map((pick): { charge: number; eta: QueueEta | null } => { - const charge = positive(pick.chargeBytes); - if (charge === 0) return { charge, eta: { kind: 'free' } }; - const after = ratio(downloaded + charge); - const small = charge <= SMALL_DOWNLOAD_BYTES && ratio(downloaded) - after < SMALL_RATIO_DROP; - if (after >= keepRatio || small) { - downloaded += charge; - return { charge, eta: { kind: 'fits' } }; - } - return { charge, eta: null }; - }), - ); - - const creditFor = (down: number) => { + const creditFor = (down: number): QueueCredit => { const shortGb = Math.max(0, keepRatio * down - uploaded) / GIB; - const creditGb = Math.ceil(shortGb / stepGb) * stepGb; - const cost = creditGb * perGb; + const gb = Math.ceil(shortGb / stepGb) * stepGb; + const cost = gb * perGb; let hours: number | null = null; if (points && cost <= points.balance) hours = 0; else if (points?.perHour) hours = (cost - points.balance) / points.perHour; - return { creditGb, points: cost, hours }; + return { gb, points: cost, hours }; }; - let last: QueueEta = { kind: 'fits' }; - for (const item of picks) { - for (const pick of item) { - if (pick.eta) continue; - downloaded += pick.charge; - pick.eta = { kind: 'credit', ...creditFor(downloaded) }; - last = pick.eta; + let index = 0; + const plans: PickPlan[][] = items.map((item) => + item.map((pick) => ({ + index: index++, + mam: pick.mam, + charge: positive(pick.chargeBytes), + credit: null, + ratioSeconds: 0, + eta: { seconds: 0, waitsFor: null, credit: null, free: positive(pick.chargeBytes) === 0 }, + })), + ); + const all = plans.flat(); + + // Ratio: what fits now goes, in queue order; the rest wait for credit, in queue order. + const waiting = all.filter((plan) => { + if (plan.charge === 0) return false; + const after = ratio(downloaded + plan.charge); + const small = + plan.charge <= SMALL_DOWNLOAD_BYTES && ratio(downloaded) - after < SMALL_RATIO_DROP; + if (after < keepRatio && !small) return true; + downloaded += plan.charge; + return false; + }); + let lastCredit: QueueCredit | null = null; + for (const plan of waiting) { + downloaded += plan.charge; + plan.credit = creditFor(downloaded); + plan.ratioSeconds = plan.credit.hours === null ? null : plan.credit.hours * 3600; + lastCredit = plan.credit; + } + + // Slots: each MAM pick, in the order the ratio lets them go, takes the soonest one. + const times = slotTimes(snapshot, slots); + const paused = positive(slots?.pausedSeconds); + const mamPicks = all + .filter((plan) => plan.mam) + .toSorted( + (a, b) => + (a.ratioSeconds ?? Number.POSITIVE_INFINITY) - + (b.ratioSeconds ?? Number.POSITIVE_INFINITY) || a.index - b.index, + ); + for (const plan of all) { + plan.eta = { ...plan.eta, credit: plan.credit, seconds: plan.ratioSeconds }; + if (plan.ratioSeconds !== 0) plan.eta.waitsFor = 'ratio'; + } + if (times) { + for (const plan of mamPicks) { + if (plan.ratioSeconds === null) continue; // Never gets as far as a slot + const slot = times.shift(); + if (slot === undefined) { + plan.eta = { ...plan.eta, seconds: null, waitsFor: 'slot' }; + continue; + } + const start = Math.max(plan.ratioSeconds, slot, paused); + let waitsFor: QueueEta['waitsFor'] = null; + if (start > 0 && start === paused) waitsFor = 'pause'; + else if (start > 0 && start === slot && slot > plan.ratioSeconds) waitsFor = 'slot'; + else if (start > 0) waitsFor = 'ratio'; + plan.eta = { ...plan.eta, seconds: start, waitsFor }; + // Its own slot frees once it has seeded 72 hours; without the client's timing the + // others' are unknown too, so a slot past the free ones isn't promised at all. + if (slots?.freeSeconds) { + times.push(start + UNSAT_SEED_SECONDS); + times.sort((a, b) => a - b); + } } } - const etas = picks.map((item) => + // An item starts when its last pick does. + const etas = plans.map((item) => item.reduce( - (worst, pick) => (pick.eta && etaRank(pick.eta) > etaRank(worst) ? pick.eta : worst), - { kind: item.length > 0 && item.every((pick) => pick.charge === 0) ? 'free' : 'fits' }, + (worst, plan) => { + const later = laterThan(plan.eta.seconds, worst.seconds) ? plan.eta : worst; + const credit = + plan.credit && (!worst.credit || plan.credit.gb > worst.credit.gb) + ? plan.credit + : worst.credit; + return { ...later, credit, free: worst.free && plan.eta.free }; + }, + { seconds: 0, waitsFor: null, credit: null, free: true }, ), ); - const all = items.flat(); + const picks = items.flat(); return { keepRatio, roomBytes, - totalBytes: all.reduce((total, pick) => total + positive(pick.sizeBytes), 0), - chargeBytes: all.reduce((total, pick) => total + positive(pick.chargeBytes), 0), - creditGb: last.kind === 'credit' ? last.creditGb : 0, - points: last.kind === 'credit' ? last.points : 0, - hours: last.kind === 'credit' ? last.hours : 0, + totalBytes: picks.reduce((total, pick) => total + positive(pick.sizeBytes), 0), + chargeBytes: picks.reduce((total, pick) => total + positive(pick.chargeBytes), 0), + credit: lastCredit, + seconds: etas.reduce( + (latest, eta) => (laterThan(eta.seconds, latest) ? eta.seconds : latest), + 0, + ), etas, }; }; diff --git a/tests/prowlarr/test_mam_unsat_timing.py b/tests/prowlarr/test_mam_unsat_timing.py index b59c2295a..b29239f00 100644 --- a/tests/prowlarr/test_mam_unsat_timing.py +++ b/tests/prowlarr/test_mam_unsat_timing.py @@ -28,6 +28,7 @@ def test_next_slot_and_slots_in_the_next_six_hours(): "available": True, "client": "Deluge", "next_seconds": 2 * HOUR, + "free_seconds": [2 * HOUR, 5 * HOUR, 12 * HOUR], "within_window": 2, "window_hours": 6, "seeding": 3, @@ -70,7 +71,9 @@ def test_cached_timing_counts_down(monkeypatch): monkeypatch.setattr(mam_account.time, "time", lambda: clock[0]) assert mam_account.unsat_timing()["next_seconds"] == HOUR clock[0] += 45 - assert mam_account.unsat_timing()["next_seconds"] == HOUR - 45 + timing = mam_account.unsat_timing() + assert timing["next_seconds"] == HOUR - 45 + assert timing["free_seconds"] == [HOUR - 45] client.list_tracker_torrents.assert_called_once()