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
10 changes: 10 additions & 0 deletions shelfmark/core/download_history_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ def to_download_payload(row: dict[str, Any]) -> dict[str, Any]:
"id": row.get("task_id"),
"title": row.get("title"),
"author": row.get("author"),
"narrators": DownloadHistoryService._narrators(row),
"format": row.get("format"),
"size": row.get("size"),
"preview": row.get("preview"),
Expand All @@ -221,6 +222,15 @@ def to_download_payload(row: dict[str, Any]) -> dict[str, Any]:
"retry_available": DownloadHistoryService.is_retry_available(row),
}

@classmethod
def _narrators(cls, row: dict[str, Any]) -> list[str]:
"""The release's narrators, as kept with the download for a retry."""
payload = cls._deserialize_retry_payload(row.get("retry_payload"))
narrators = payload.get("narrators") if payload else None
if not isinstance(narrators, list):
return []
return [str(name) for name in narrators if isinstance(name, str) and name.strip()]

@classmethod
def _mam_page(cls, row: dict[str, Any]) -> str | None:
"""The MyAnonamouse page of a download recorded before pages were kept."""
Expand Down
16 changes: 11 additions & 5 deletions shelfmark/core/saved_autoget.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@
- the ratio stays healthy, by one rule for every item rather than per-item settings:
freeleech torrents (checked live on MAM) never touch the ratio, so they go as soon as
there is room; anything else goes when the ratio afterwards is still at the target
(``SAVED_AUTO_GET_TARGET_RATIO``), or when it is small enough to barely move the ratio
(an ebook, say). The rest waits until the ratio recovers or the torrent turns
freeleech, whichever comes first.
(``SAVED_AUTO_GET_TARGET_RATIO``), or when it is an ebook-sized download (up to
``SMALL_DOWNLOAD_BYTES``) that barely moves the ratio. The rest waits until the ratio
recovers or the torrent turns freeleech, whichever comes first.

Items wait in order of saving, so earlier ones get the free slots first. Every check
records why an item is still waiting, shown in the Saved tab.
Expand Down Expand Up @@ -49,7 +49,11 @@
_STARTUP_DELAY_SECONDS = 90
_SOON_DELAY_SECONDS = 5
_DEFAULT_TARGET_RATIO = 2.0
# A download that lowers the ratio by less than this goes even below the target.
# A small download (an ebook) that lowers the ratio by less than SMALL_RATIO_DROP goes even
# below the target. Measured by size too, not by the drop alone: at 92 GiB up and 73 GiB
# down (1.26), a 539 MB audiobook lowers the ratio by only 0.009, so each one went, one
# after another, while the ratio sank below the target.
SMALL_DOWNLOAD_BYTES = 100 * 1024**2
SMALL_RATIO_DROP = 0.01


Expand Down Expand Up @@ -132,7 +136,9 @@ def _ratio_wait(stats: mam_account.MamStats, charge: int) -> str | None:
downloaded = stats.downloaded_bytes + mam_account.pending_charge_bytes()
before = _ratio(stats.uploaded_bytes, downloaded)
after = _ratio(stats.uploaded_bytes, downloaded + charge)
if after >= target or before - after < SMALL_RATIO_DROP:
if after >= target:
return None
if charge <= SMALL_DOWNLOAD_BYTES and before - after < SMALL_RATIO_DROP:
return None
return (
f"Waiting for freeleech or ratio {target:.2f}: {_size(charge)} would take it "
Expand Down
1 change: 1 addition & 0 deletions shelfmark/download/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -737,6 +737,7 @@ def _task_to_dict(
"id": task.task_id,
"title": task.title,
"author": task.author,
"narrators": list(task.narrators or []),
"format": task.format,
"size": task.size,
"downloads": task.downloads,
Expand Down
3 changes: 2 additions & 1 deletion shelfmark/release_sources/prowlarr/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,8 @@ def prowlarr_config_settings() -> list[SettingsField]:
label="Keep Ratio At Least",
description=(
"Non-freeleech downloads that would take the ratio below this wait, unless "
"they'd lower it by less than 0.01. 0 turns the ratio check off."
"they're ebook-sized (100 MB or less) and lower it by less than 0.01. "
"0 turns the ratio check off."
),
default=2.0,
min_value=0,
Expand Down
9 changes: 9 additions & 0 deletions src/frontend/src/components/activity/ActivityItemParts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { ReactNode } from 'react';
import { useLayoutEffect, useRef, useState } from 'react';

import { withBasePath } from '../../utils/basePath';
import { shortNarrators } from '../../utils/narrators';
import { LibraryAppIcon } from '../shared/LibraryAppIcon';
import { Tooltip } from '../shared/Tooltip';
import type { ActivityCardAction, ActivityCardBadge } from './activityCardModel';
Expand Down Expand Up @@ -176,6 +177,14 @@ export const sourcePageName = (url: string): string => {
}
};

/** "Narrated by Tony Robinson +2" under an audiobook's author; nothing without narrators. */
export const NarratorLine = ({ narrators }: { narrators?: readonly string[] }) =>
narrators && narrators.length > 0 ? (
<p className="truncate text-xs opacity-60" title={`Narrated by ${narrators.join(', ')}`}>
Narrated by {shortNarrators(narrators)}
</p>
) : null;

/** 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">
Expand Down
2 changes: 2 additions & 0 deletions src/frontend/src/components/activity/ActivityTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
ActivityActionButton,
ActivityLinkButtons,
ActivityStatusBadges,
NarratorLine,
actionKey,
} from './ActivityItemParts';
import type { ActivityItem } from './activityTypes';
Expand Down Expand Up @@ -133,6 +134,7 @@ const ActivityRow = ({
{item.author}
</p>
)}
<NarratorLine narrators={item.narrators} />
{model.noteLine && (
<p className="truncate text-xs italic opacity-60" title={model.noteLine}>
{model.noteLine}
Expand Down
3 changes: 2 additions & 1 deletion src/frontend/src/components/activity/DownloadQueue.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
rowClassName,
tableClassName,
} from '../shared/DataTable';
import { SourcePageLink } from './ActivityItemParts';
import { NarratorLine, SourcePageLink } from './ActivityItemParts';
import type { ActivityItem } from './activityTypes';
import {
DragHandle,
Expand Down Expand Up @@ -149,6 +149,7 @@ export const DownloadQueue = ({
{item.author}
</p>
)}
<NarratorLine narrators={item.narrators} />
</td>
<td className={`${cellClassName} min-w-[10rem] text-xs opacity-70`}>
{item.statusDetail || 'Waiting to start'}
Expand Down
2 changes: 2 additions & 0 deletions src/frontend/src/components/activity/activityMappers.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Book, RequestRecord, StatusData } from '../../types';
import { getDownloadsCount } from '../../types';
import { narratorNames } from '../../utils/narrators';
import { STATUS_LABELS, isActiveDownloadStatus } from './activityStyles.js';
import type { ActivityItem, ActivityVisualStatus } from './activityTypes';

Expand Down Expand Up @@ -110,6 +111,7 @@ export const downloadToActivityItem = (book: Book, statusKey: DownloadStatusKey)
visualStatus,
title: toText(book.title, 'Unknown title'),
author: toText(book.author, 'Unknown author'),
narrators: narratorNames(book.narrators),
preview: toOptionalText(book.preview),
metaLine,
statusLabel: STATUS_LABELS[visualStatus],
Expand Down
1 change: 1 addition & 0 deletions src/frontend/src/components/activity/activityTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export interface ActivityItem {

title: string;
author: string;
narrators?: string[]; // An audiobook's narrators, to tell editions apart
preview?: string;

metaLine: string;
Expand Down
20 changes: 20 additions & 0 deletions src/frontend/src/tests/activityNarrators.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';

import { downloadToActivityItem } from '../components/activity/activityMappers';
import type { Book } from '../types';

const book = (fields: Partial<Book>): Book => ({
id: 'b',
title: 'Good Omens',
author: 'A',
...fields,
});

describe('download narrators', () => {
it("carries an audiobook's narrators, to tell editions apart", () => {
expect(
downloadToActivityItem(book({ narrators: ['Martin Jarvis'] }), 'queued').narrators,
).toEqual(['Martin Jarvis']);
expect(downloadToActivityItem(book({}), 'complete').narrators).toEqual([]);
});
});
1 change: 1 addition & 0 deletions src/frontend/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export interface Book {
status_message?: string; // Detailed status message (e.g., "Trying Libgen (2/5)")
added_time?: number; // Timestamp when added to queue
priority?: number; // Downloads: place in the queue, lower first
narrators?: string[] | null; // Downloads: the release's narrators (audiobooks)
content_type?: string; // "ebook", "audiobook", or related book subtype
book_key?: string | null; // Downloads: the metadata book it was got for ("hardcover:42")
info_url?: string | null; // Downloads: the release's page at its source (MAM torrent page)
Expand Down
12 changes: 12 additions & 0 deletions tests/core/test_download_history_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,15 @@ def test_record_download_stores_utc_iso_timestamps():
assert row is not None
assert "+00:00" in row["queued_at"]
assert "+00:00" in row["terminal_at"]


def test_history_rows_carry_the_narrators_kept_for_a_retry():
import json

row = {
"task_id": "t1",
"title": "Good Omens",
"retry_payload": json.dumps({"narrators": ["Martin Jarvis", " "]}),
}
assert DownloadHistoryService.to_download_payload(row)["narrators"] == ["Martin Jarvis"]
assert DownloadHistoryService.to_download_payload({"task_id": "t2"})["narrators"] == []
19 changes: 19 additions & 0 deletions tests/core/test_saved_autoget.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,25 @@ def test_a_small_download_goes_even_below_the_target_ratio(service, mam):
assert SavedAutoGetter(service, recorder.hooks()).run_check().queued == [item_id]


def test_an_audiobook_waits_below_the_target_though_it_barely_moves_the_ratio(service, mam):
# 92.27 GiB up / 73.35 GiB down (1.26): a 539 MB audiobook lowers that by only 0.009,
# but it isn't an ebook, so it waits for the ratio (or freeleech) like any other.
mam.stats = _stats(uploaded=int(92.27 * GIB), downloaded=int(73.35 * GIB))
recorder = Recorder()
item_id = _mark(service, [_payload("audiobook", size=539 * 1000**2)])
report = SavedAutoGetter(service, recorder.hooks()).run_check()
assert report.queued == []
assert report.waiting[item_id].startswith("Waiting for freeleech or ratio 2.00")
assert recorder.queued == []
# An ebook still goes: 5 MB moves it by well under 0.01.
ebook_id = _mark(
service,
[_payload("ebook", torrent_id=9, size=5 * 1024**2)],
book={**BOOK, "provider_id": "43", "title": "Ebook"},
)
assert SavedAutoGetter(service, recorder.hooks()).run_check().queued == [ebook_id]


def test_a_large_download_waits_for_the_ratio_or_freeleech(service, mam):
# 300 up / 100 down; a 60 GB download leaves 300 / 160 = 1.88.
mam.stats = _stats(uploaded=300 * GIB, downloaded=100 * GIB)
Expand Down
9 changes: 9 additions & 0 deletions tests/download/test_orchestrator_retry.py
Original file line number Diff line number Diff line change
Expand Up @@ -348,3 +348,12 @@ def test_os_error_message_names_the_reason():
orchestrator._format_download_exception_message(RuntimeError("boom"))
== "Download failed: RuntimeError"
)


def test_queue_status_names_the_narrators():
import shelfmark.download.orchestrator as orchestrator

task = DownloadTask(
task_id="n1", source="prowlarr", title="Good Omens", narrators=["Martin Jarvis"]
)
assert orchestrator._task_to_dict(task, QueueStatus.QUEUED)["narrators"] == ["Martin Jarvis"]
Loading