refactor: R12 S3 — invert scheduler->routes, publish cross-module privates

Layering cleanup, no behaviour change (ruff clean backend+scripts, pytest 193):

- Invert the scheduler->routes dependency: reset_demo_state (+ its demo-seed
  helpers) move from routes/admin.py to a new service module app/demo.py.
  Both routes/admin.py and scheduler.py now import it from app.demo, so the
  scheduler no longer reaches into the routes layer — its lazy import (the
  routes<->scheduler cycle workaround) is gone, hoisted to a top-level import.

- Publish the private symbols that were imported across module boundaries (the
  underscore convention was lying): feed._serialize -> serialize,
  feed._to_tsquery_str -> to_tsquery_str, videos._insert_stubs -> insert_stubs,
  videos._stub_from_playlist_item -> stub_from_playlist_item,
  worker._uploader_url -> uploader_url, plex._shared._item_or_404 ->
  item_or_404. Scoped renames — the 5 other route modules keep their own truly
  private _serialize helpers.

- _release_asset moves from routes/downloads.py to app/downloads/service.py as
  the public release_asset (asset bookkeeping is a service concern); the routes
  call it via service.release_asset.
This commit is contained in:
2026-08-11 22:58:10 +02:00
parent ac6383e92c
commit 51463b226e
16 changed files with 153 additions and 148 deletions
+2 -2
View File
@@ -21,7 +21,7 @@ from app.db import SessionLocal
from app.downloads import edit as editmod
from app.downloads import formats, service
from app.models import MediaAsset
from app.worker import _uploader_url
from app.worker import uploader_url
log = logging.getLogger("siftlode.backfill")
@@ -73,7 +73,7 @@ def run() -> dict:
if a.uploader_url is None and _should_refetch(a):
url = a.source_webpage_url or service.source_url(a.source_kind, a.source_ref)
info = _extract_meta(url)
uu = _uploader_url(info) if info else None
uu = uploader_url(info) if info else None
if uu:
a.uploader_url = uu
filled_url += 1
+29 -1
View File
@@ -12,8 +12,9 @@ from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.config import settings
from app.downloads import edit as editmod
from app.downloads import formats, quota
from app.downloads import formats, quota, storage
from app.models import Channel, DownloadJob, MediaAsset, Video
@@ -220,3 +221,30 @@ def enqueue_edit(
db.commit()
db.refresh(job)
return job
def release_asset(db: Session, job: DownloadJob) -> None:
"""Drop this job's hold on its asset when it leaves a holding state. Once NO job holds the
asset anymore, delete the file + row immediately — a deleted download should free its disk,
and the shared cache only needs to span *overlapping* holders (a later re-add just downloads
again). `error` counts as holding: enqueue always +1'd ref_count and the worker never
decrements on failure (ref_count is the route's job), so an errored job releases here on
delete — else its +1 leaks and a later resume→ready keeps the file pinned past its last holder.
Idempotent for the cancel path: cancel already released while the job was holding, then set it
`canceled` (∉ the set below), so delete-after-cancel is a no-op and never double-releases.
We DON'T delete an errored asset row here even at ref==0: another request may be concurrently
re-enqueuing the same (source,format) — get_or_create_asset would reset that row to `pending` and
+1 it, and deleting it under that would FK-null the new job's asset (a lost update on ref_count).
A ready asset is still freed at ref==0 (its file is the point); an orphaned errored row is cheap
(no file) and gets reused+reset by the next enqueue, or reclaimed by a later GC pass."""
if not job.asset_id or job.status not in ("queued", "running", "paused", "done", "error"):
return
asset = db.get(MediaAsset, job.asset_id)
if asset is None:
return
asset.ref_count = max(0, (asset.ref_count or 0) - 1)
if asset.ref_count == 0 and asset.status == "ready":
if asset.rel_path:
storage.delete_asset_files(settings.download_root, asset.rel_path)
db.delete(asset)