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
18 changes: 18 additions & 0 deletions shelfmark/core/saved_autoget.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import copy
import math
import threading
import time
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any

Expand Down Expand Up @@ -239,6 +240,10 @@ def __init__(self, service: SavedItemsService, hooks: AutoGetHooks) -> None:
self._thread: threading.Thread | None = None
self._thread_lock = threading.Lock()
self._soon: threading.Timer | None = None
# When the scheduled check and a pending quick check (`check_soon`) run, in epoch
# seconds, for the Queued page.
self._next_check_at: float | None = None
self._soon_at: float | None = None

def run_check(self) -> RunReport:
"""Check every item marked for automatic download, oldest first."""
Expand Down Expand Up @@ -302,6 +307,7 @@ def _check_item(self, item: dict[str, Any], run: _RunContext, report: RunReport)
def _loop(self) -> None:
delay: float = _STARTUP_DELAY_SECONDS
while True:
self._next_check_at = time.time() + delay
self._wake.wait(delay)
self._wake.clear()
try:
Expand All @@ -310,6 +316,15 @@ def _loop(self) -> None:
logger.exception("Saved items automatic download check failed")
delay = interval_minutes() * 60

def next_check_at(self) -> float | None:
"""When the next check is due (epoch seconds), or None when none is scheduled."""
if not enabled():
return None
with self._thread_lock:
running = self._thread is not None and self._thread.is_alive()
due = [at for at in (self._next_check_at, self._soon_at) if at is not None]
return min(due) if running and due else None

def start(self) -> None:
"""Start the background schedule (idempotent)."""
with self._thread_lock:
Expand All @@ -325,11 +340,14 @@ def check_soon(self) -> None:
with self._thread_lock:
if self._soon is not None and self._soon.is_alive():
return
self._soon_at = time.time() + _SOON_DELAY_SECONDS
self._soon = threading.Timer(_SOON_DELAY_SECONDS, self._run_soon)
self._soon.daemon = True
self._soon.start()

def _run_soon(self) -> None:
with self._thread_lock:
self._soon_at = None # Running now: the schedule says when the next one is
try:
self.run_check()
except Exception: # a background check must not raise
Expand Down
14 changes: 12 additions & 2 deletions shelfmark/core/saved_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,16 +46,26 @@ def register_saved_routes(
login_required: Callable[..., Any],
resolve_auth_mode: Callable[[], str],
on_auto_get: Callable[[], None] | None = None,
next_check_at: Callable[[], float | None] | None = None,
) -> None:
"""Register the saved items API; `on_auto_get` runs when an item is marked."""
"""Register the saved items API.

`on_auto_get` runs when an item is marked; `next_check_at` says when the automatic
check next runs (epoch seconds), shown beside the queued items.
"""

@app.route("/api/saved", methods=["GET"])
@login_required
def api_saved_list() -> Response | tuple[Response, int]:
owner = _owner(resolve_auth_mode)
if owner is None:
return _no_owner()
return jsonify({"items": service.list_items(owner)})
return jsonify(
{
"items": service.list_items(owner),
"next_check_at": next_check_at() if next_check_at is not None else None,
}
)

@app.route("/api/saved", methods=["POST"])
@login_required
Expand Down
1 change: 1 addition & 0 deletions shelfmark/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -1248,6 +1248,7 @@ def _saved_notify(item: dict[str, Any], owner: SavedOwner) -> None:
login_required,
get_auth_mode,
on_auto_get=saved_autogetter.check_soon,
next_check_at=saved_autogetter.next_check_at,
)
saved_autogetter.start()

Expand Down
134 changes: 109 additions & 25 deletions shelfmark/release_sources/prowlarr/handler.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
"""Prowlarr download handler - resolves releases and delegates lifecycle to shared clients."""

from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from urllib.parse import urlparse

import requests

from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
from shelfmark.core.naming import derive_primary_title
from shelfmark.core.request_helpers import normalize_optional_text
from shelfmark.core.search_plan import build_release_search_plan
from shelfmark.core.search_plan import build_release_search_plan, pick_search_author
from shelfmark.core.utils import normalize_http_url
from shelfmark.download.clients import (
DownloadClient,
Expand Down Expand Up @@ -89,6 +91,15 @@ def _coerce_positive_minutes(raw_minutes: object) -> int | None:
return minutes if minutes > 0 else None


@dataclass(frozen=True)
class _RefreshSearch:
"""One Prowlarr search for re-finding an expired release."""

book: BookMetadata
query: str | None = None # Searched as typed, like a manual search
expand: bool = False # Without the content-type categories


@register_handler("prowlarr")
class ProwlarrHandler(ExternalClientHandler):
"""Handler for Prowlarr downloads via configured torrent or usenet client."""
Expand Down Expand Up @@ -306,45 +317,118 @@ def _resolve_download(
)

def _refresh_release(self, task: DownloadTask) -> dict[str, Any] | None:
"""Re-query Prowlarr and cache the exact original release if it still exists."""
"""Re-query Prowlarr and cache the exact original release if it still exists.

Searched the way the user would find it again (see `_refresh_searches`). A
search built only from the download's title can miss a release that is still
there: an indexer that ANDs its terms finds nothing for a long title with its
subtitle, and a short title ("Eric") brings back so many other books that the
release isn't among the results. That left the user to search by hand.
"""
title = normalize_optional_text(task.title)
if title is None:
return None

context = getattr(task, "retry_source_context", None)
if not isinstance(context, dict):
context = {}

indexer = normalize_optional_text(context.get("indexer"))
content_type = task.content_type or "ebook"

source = ProwlarrSource()
for search in self._refresh_searches(task, title):
# No language default here on purpose: this re-finds one exact release by
# its guid, and Prowlarr does not filter on plan.languages anyway.
plan = build_release_search_plan(
search.book,
manual_query=search.query,
indexers=[indexer] if indexer is not None else None,
)
results = source.search(
search.book, plan, expand_search=search.expand, content_type=content_type
)
for release in results:
raw_release = get_release(release.source_id)
if raw_release is None:
continue
if not self._raw_release_matches_task(raw_release, task.task_id):
continue

cache_release(task.task_id, raw_release)
logger.info("Refreshed Prowlarr release: %s", task.task_id)
return raw_release

return None

def _refresh_searches(self, task: DownloadTask, title: str) -> list[_RefreshSearch]:
"""The searches that re-find a release, in order, stopping at the first hit.

With the metadata provider's record of the book (when the download noted it):
the book's own release search, then its title and author as one query (for a
title so common the release is lost among other books), then without the
content-type categories. Without it: the download's title without its
subtitle, then title and author.
"""
provider_book = self._provider_book(task)
if provider_book is not None:
query = self._title_author_query(provider_book)
return [
_RefreshSearch(provider_book),
*([_RefreshSearch(provider_book, query=query)] if query else []),
_RefreshSearch(provider_book, expand=True),
]

authors = [a.strip() for a in (task.author or "").split(",") if a.strip()]
book = BookMetadata(
provider="shelfmark",
provider_id=task.task_id,
title=title,
authors=[task.author] if task.author else [],
search_title=title,
search_author=task.author,
)
# No language default here on purpose: this re-finds one exact release by its
# guid, and Prowlarr does not filter on plan.languages anyway.
plan = build_release_search_plan(
book,
indexers=[indexer] if indexer is not None else None,
authors=authors,
search_title=derive_primary_title(title, task.subtitle) or title,
search_author=authors[0] if authors else None,
)
query = self._title_author_query(book)
return [_RefreshSearch(book), *([_RefreshSearch(book, query=query)] if query else [])]

source = ProwlarrSource()
results = source.search(book, plan, content_type=task.content_type or "ebook")
for release in results:
raw_release = get_release(release.source_id)
if raw_release is None:
continue
if not self._raw_release_matches_task(raw_release, task.task_id):
continue

cache_release(task.task_id, raw_release)
logger.info("Refreshed Prowlarr release: %s", task.task_id)
return raw_release
@staticmethod
def _title_author_query(book: BookMetadata) -> str | None:
""" "Eric Terry Pratchett": the search title and first author, or None without one."""
author = pick_search_author(book)
search_title = book.search_title or book.title
if not author or not search_title:
return None
return f"{search_title} {author}"

return None
@staticmethod
def _provider_book(task: DownloadTask) -> BookMetadata | None:
"""The metadata provider's record of the book the download was for, if known."""
book_key = normalize_optional_text(getattr(task, "book_key", None))
if book_key is None or ":" not in book_key:
return None
provider, _, provider_id = book_key.partition(":")
if not provider or not provider_id or provider in {"manual", "id"}:
return None

from shelfmark.metadata_providers import (
get_provider,
get_provider_kwargs,
is_provider_registered,
)

if not is_provider_registered(provider):
return None
try:
return get_provider(provider, **get_provider_kwargs(provider)).get_book(provider_id)
except (
OSError,
RuntimeError,
ValueError,
KeyError,
TypeError,
requests.exceptions.RequestException,
) as exc:
logger.debug("Could not load %s to refresh %s: %s", book_key, task.task_id, exc)
return None

@staticmethod
def _raw_release_matches_task(raw_release: dict[str, Any], task_id: str) -> bool:
Expand Down
18 changes: 16 additions & 2 deletions src/frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2064,25 +2064,39 @@ function App() {
}
};

// Wanted: pick releases for the book; saving them there moves it into the queue.
const handleSavedChoose = async (item: SavedItem): Promise<void> => {
try {
if (effectiveSearchMode === 'universal') {
await handleGetReleases(item.book);
} else {
await handleDownload(item.book);
}
} catch (error) {
console.warn('Could not open releases for saved item:', error);
}
};

const savedPanelFor = (stage: SavedStage) => (
<SavedPanel
stage={stage}
items={savedStore.items}
loaded={savedStore.loaded}
autoGetAvailable={Boolean(config?.saved_auto_get_enabled)}
onGet={handleSavedGet}
onRemove={(item) => savedStore.remove(item)}
onRefresh={savedStore.refresh}
onAutoGet={savedStore.setAutoGet}
onOpenDetails={(item) => {
setDetailsNotice(null);
void showBookDetails(item.book, item.book.id);
}}
onChooseReleases={handleSavedChoose}
// Queued: numbered on from the downloads up next, which go first.
onReorder={stage === 'queued' ? savedStore.reorder : undefined}
positionOffset={stage === 'queued' ? statusCounts.queued : 0}
nextCheckAt={stage === 'queued' ? savedStore.nextCheckAt : null}
/>
);

const savedCounts: Record<SavedStage, number> = {
queued: savedStore.items.filter((item) => savedStage(item) === 'queued').length,
later: savedStore.items.filter((item) => savedStage(item) === 'later').length,
Expand Down
Loading
Loading