Misc features: Retries, user search config, sort by format, admin download control (#679)

- Added the manual retry option for failed downloads
- Added the ability to retry failed post-processing using existing
downloaded file
- Added admin-visible "Download as" selector, admin chooses a user to
download on-behalf of - inherits their output preferences.
- Added search mode and default metadata provider / release source
options to User Preferences and My Account settings.
- Added sort by format option in release results
- Added {OriginalName} renaming field option, to retain the exact
downloaded filename
- Frontend dependency updates - fixes rollup vulnerability from this
week

Closes #662 #656 #649 #562
This commit is contained in:
Alex
2026-03-01 19:47:57 +00:00
committed by GitHub
parent ea0d06ae08
commit 9593c040b0
52 changed files with 3288 additions and 658 deletions
+195 -3
View File
@@ -16,9 +16,13 @@ def _build_config(
organization: str,
hardlink: bool = False,
rename_template: str = "{Author} - {Title}",
organize_template: str = "{Author}/{Title}",
audiobook_rename_template: str | None = None,
audiobook_organize_template: str = "{Author}/{Title}{ - PartNumber}",
supported_formats: list[str] | None = None,
supported_audiobook_formats: list[str] | None = None,
):
audiobook_rename = audiobook_rename_template or rename_template
values = {
"DESTINATION": str(destination),
"INGEST_DIR": str(destination),
@@ -26,9 +30,9 @@ def _build_config(
"FILE_ORGANIZATION": organization,
"FILE_ORGANIZATION_AUDIOBOOK": organization,
"TEMPLATE_RENAME": rename_template,
"TEMPLATE_ORGANIZE": "{Author}/{Title}",
"TEMPLATE_AUDIOBOOK_RENAME": rename_template,
"TEMPLATE_AUDIOBOOK_ORGANIZE": "{Author}/{Title}{ - PartNumber}",
"TEMPLATE_ORGANIZE": organize_template,
"TEMPLATE_AUDIOBOOK_RENAME": audiobook_rename,
"TEMPLATE_AUDIOBOOK_ORGANIZE": audiobook_organize_template,
"SUPPORTED_FORMATS": supported_formats or ["epub"],
"SUPPORTED_AUDIOBOOK_FORMATS": supported_audiobook_formats or ["mp3"],
"HARDLINK_TORRENTS": hardlink,
@@ -82,6 +86,56 @@ def test_direct_download_rename_moves_file(tmp_path):
assert any("Moving" in msg for _, msg in statuses)
@pytest.mark.parametrize("source_kind", ["direct", "torrent"])
def test_original_name_rename_single_file_for_direct_and_torrent(tmp_path, source_kind: str):
from shelfmark.download.postprocess.router import post_process_download as _post_process_download
staging = tmp_path / "staging"
downloads = tmp_path / "downloads"
ingest = tmp_path / "ingest"
staging.mkdir()
downloads.mkdir()
ingest.mkdir()
base_dir = staging if source_kind == "direct" else downloads
input_path = base_dir / "Some.Release.v2.epub"
input_path.write_text("content")
task = DownloadTask(
task_id=f"original-name-single-{source_kind}",
source="direct_download" if source_kind == "direct" else "prowlarr",
title="Ignored Title",
author="Ignored Author",
format="epub",
search_mode=SearchMode.DIRECT if source_kind == "direct" else SearchMode.UNIVERSAL,
original_download_path=str(input_path) if source_kind == "torrent" else None,
)
with patch("shelfmark.core.config.config") as mock_config, \
patch("shelfmark.config.env.TMP_DIR", staging):
mock_config.get = _build_config(
ingest,
organization="rename",
rename_template="{OriginalName}",
supported_formats=["epub"],
)
mock_config.CUSTOM_SCRIPT = None
_sync_config(mock_config, mock_config)
result = _post_process_download(input_path, task, Event(), lambda *_args: None)
assert result is not None
result_path = Path(result)
assert result_path.exists()
assert result_path.parent == ingest
assert result_path.name == "Some.Release.v2.epub"
if source_kind == "direct":
assert not input_path.exists()
else:
assert input_path.exists()
def test_torrent_hardlink_preserves_source(tmp_path):
from shelfmark.download.postprocess.router import post_process_download as _post_process_download
@@ -120,6 +174,47 @@ def test_torrent_hardlink_preserves_source(tmp_path):
assert os.stat(original).st_ino == os.stat(result_path).st_ino
def test_archive_extraction_rename_single_file_can_use_original_name(tmp_path):
from shelfmark.download.postprocess.router import post_process_download as _post_process_download
staging = tmp_path / "staging"
ingest = tmp_path / "ingest"
staging.mkdir()
ingest.mkdir()
archive_path = staging / "book.zip"
with zipfile.ZipFile(archive_path, "w") as zf:
zf.writestr("book.v2.epub", "content")
task = DownloadTask(
task_id="archive-single-original-name",
source="direct_download",
title="Ignored",
author="Ignored",
format="epub",
search_mode=SearchMode.DIRECT,
)
with patch("shelfmark.core.config.config") as mock_config, \
patch("shelfmark.config.env.TMP_DIR", staging):
mock_config.get = _build_config(
ingest,
organization="rename",
rename_template="{OriginalName}",
supported_formats=["epub"],
)
mock_config.CUSTOM_SCRIPT = None
_sync_config(mock_config, mock_config)
result = _post_process_download(archive_path, task, Event(), lambda *_args: None)
assert result is not None
result_path = Path(result)
assert result_path.exists()
assert result_path.parent == ingest
assert result_path.name == "book.v2.epub"
def test_torrent_hardlink_enabled_archive_is_hardlinked_without_extraction(tmp_path):
from shelfmark.download.postprocess.router import post_process_download as _post_process_download
@@ -172,6 +267,49 @@ def test_torrent_hardlink_enabled_archive_is_hardlinked_without_extraction(tmp_p
assert list(ingest.glob("*.epub")) == []
def test_multifile_rename_ignores_template_even_with_original_name(tmp_path):
from shelfmark.download.postprocess.router import post_process_download as _post_process_download
staging = tmp_path / "staging"
ingest = tmp_path / "ingest"
staging.mkdir()
ingest.mkdir()
source_dir = staging / "release"
source_dir.mkdir()
(source_dir / "Part 2 of 2.mp3").write_text("audio2")
(source_dir / "Part 1 of 2.mp3").write_text("audio1")
task = DownloadTask(
task_id="multi-rename-template-ignored",
source="direct_download",
title="Ignored",
author="Ignored",
format="mp3",
content_type="audiobook",
search_mode=SearchMode.DIRECT,
)
with patch("shelfmark.core.config.config") as mock_config, \
patch("shelfmark.config.env.TMP_DIR", staging):
mock_config.get = _build_config(
ingest,
organization="rename",
rename_template="{Author} - {Title}",
audiobook_rename_template="{OriginalName} - RENAMED",
supported_audiobook_formats=["mp3"],
)
mock_config.CUSTOM_SCRIPT = None
_sync_config(mock_config, mock_config)
result = _post_process_download(source_dir, task, Event(), lambda *_args: None)
assert result is not None
files = sorted(path.name for path in ingest.glob("*.mp3"))
assert files == ["Part 1 of 2.mp3", "Part 2 of 2.mp3"]
assert all("RENAMED" not in name for name in files)
def test_torrent_hardlink_enabled_copy_fallback_does_not_extract_archives(tmp_path):
from shelfmark.download.postprocess.router import post_process_download as _post_process_download
@@ -436,6 +574,60 @@ def test_archive_extraction_organize_multifile_assigns_part_numbers(tmp_path):
assert files[1].name == "Archive Audio - 02.mp3"
def test_archive_extraction_organize_multifile_can_use_original_name(tmp_path):
from shelfmark.download.postprocess.router import post_process_download as _post_process_download
staging = tmp_path / "staging"
ingest = tmp_path / "ingest"
staging.mkdir()
ingest.mkdir()
archive_path = staging / "audio.zip"
with zipfile.ZipFile(archive_path, "w") as zf:
zf.writestr("Part 2 of 2.mp3", "audio2")
zf.writestr("Part 1 of 2.mp3", "audio1")
task = DownloadTask(
task_id="direct-archive-audio-original-name",
source="direct_download",
title="Archive Audio",
author="Tester",
format="mp3",
content_type="audiobook",
search_mode=SearchMode.DIRECT,
)
status_cb = lambda *_args: None
values = {
"DESTINATION": str(ingest),
"INGEST_DIR": str(ingest),
"DESTINATION_AUDIOBOOK": str(ingest),
"FILE_ORGANIZATION": "organize",
"FILE_ORGANIZATION_AUDIOBOOK": "organize",
"TEMPLATE_RENAME": "{Author} - {Title}",
"TEMPLATE_ORGANIZE": "{Author}/{Title}",
"TEMPLATE_AUDIOBOOK_RENAME": "{Author} - {Title}",
"TEMPLATE_AUDIOBOOK_ORGANIZE": "{Author}/{Title}/{OriginalName}",
"SUPPORTED_FORMATS": ["epub"],
"SUPPORTED_AUDIOBOOK_FORMATS": ["mp3"],
"HARDLINK_TORRENTS": False,
"HARDLINK_TORRENTS_AUDIOBOOK": False,
}
with patch("shelfmark.core.config.config") as mock_config, \
patch("shelfmark.config.env.TMP_DIR", staging):
mock_config.get = MagicMock(side_effect=lambda key, default=None, **_kwargs: values.get(key, default))
mock_config.CUSTOM_SCRIPT = None
_sync_config(mock_config, mock_config)
result = _post_process_download(archive_path, task, Event(), status_cb)
assert result is not None
author_title_dir = ingest / "Tester" / "Archive Audio"
files = sorted(path.name for path in author_title_dir.glob("*.mp3"))
assert files == ["Part 1 of 2.mp3", "Part 2 of 2.mp3"]
def test_booklore_mode_uploads_and_cleans_staging(tmp_path):
from shelfmark.download.postprocess.router import post_process_download as _post_process_download