Merge chore/code-hygiene: Downloads FE follow-up + Phase 2 #2 Feed (cleanup + 2 bug fixes)

Behavior-preserving cleanup (feed/search/channels/videos/client/models dedup +
Feed.tsx hoist/wrapper/cache-dedup + Downloads FE-C4) plus two review-found bug
fixes (search quota-per-page cap, Feed override flash on infinite-scroll).
Verified: tsc, ruff, localdev boots healthy.
This commit is contained in:
npeter83 2026-07-11 17:44:11 +02:00
commit 58aac9e8ec
36 changed files with 249 additions and 260 deletions

View file

@ -85,14 +85,6 @@ def format_sig(spec: dict) -> str:
return hashlib.sha256(payload.encode()).hexdigest()[:24] return hashlib.sha256(payload.encode()).hexdigest()[:24]
def target_ext(spec: dict) -> str:
"""Best-effort final extension for the produced file (also used to name the staging output)."""
s = normalize(spec)
if s["mode"] == "a":
return s["audio_format"] or "m4a"
return s["container"] or "mp4"
def build_ydl_opts( def build_ydl_opts(
spec: dict, spec: dict,
outtmpl: str, outtmpl: str,

View file

@ -75,13 +75,7 @@ def public_meta(link: DownloadLink, job, asset, file_url: str, poster_url: str |
auto-extracted values, and surfaces the source + any extra reference links as clickable URLs.""" auto-extracted values, and surfaces the source + any extra reference links as clickable URLs."""
from app.downloads import service from app.downloads import service
source_url = None source_url = service.reference_url(job, asset)
if job is not None and job.job_kind != "edit":
source_url = asset.source_webpage_url or (
service.source_url(job.source_kind, job.source_ref)
if job.source_kind in ("youtube", "url")
else None
)
return { return {
"title": (job and job.display_name) or asset.title, "title": (job and job.display_name) or asset.title,
"uploader": (job and job.display_uploader) or asset.uploader, "uploader": (job and job.display_uploader) or asset.uploader,

View file

@ -32,6 +32,20 @@ def source_url(source_kind: str, source_ref: str) -> str:
return source_ref return source_ref
def reference_url(job, asset) -> str | None:
"""The clean "downloaded from" URL for a job — shared by the private Downloads page and the public
watch page so both surfaces always agree. None for edit derivatives (a clip's source is the user's
own earlier download, not a web page); else the canonical page URL yt-dlp recorded; else one derived
from source_kind+source_ref (covers queued/older rows before the worker fills it)."""
if job is None or job.job_kind == "edit":
return None
if asset is not None and asset.source_webpage_url:
return asset.source_webpage_url
if job.source_kind in ("youtube", "url"):
return source_url(job.source_kind, job.source_ref)
return None
def get_or_create_asset( def get_or_create_asset(
db: Session, source_kind: str, source_ref: str, format_sig: str db: Session, source_kind: str, source_ref: str, format_sig: str
) -> MediaAsset: ) -> MediaAsset:

View file

@ -96,10 +96,32 @@ def edit_rel_path(user_id: int, asset_id: int, ext: str) -> str:
return f".edits/{user_id}/clip_{asset_id}.{ext}" return f".edits/{user_id}/clip_{asset_id}.{ext}"
def download_filename(display_name: str, container: str | None, path: Path) -> str:
"""The Content-Disposition filename for a served download: the cleaned display name carrying a single
correct extension the asset container, else the file's own suffix — without doubling it when the
name already ends in that extension. Shared by the private download and the public watch endpoints."""
ext = container or path.suffix.lstrip(".")
base = display_filename(display_name)
if base.lower().endswith(f".{ext.lower()}"):
base = base[: -(len(ext) + 1)]
return f"{base}.{ext}"
def abs_path(download_root: str, rel: str) -> Path: def abs_path(download_root: str, rel: str) -> Path:
return Path(download_root) / rel return Path(download_root) / rel
def safe_abs_path(download_root: str, rel: str) -> Path | None:
"""The resolved absolute path for `rel`, but ONLY if it stays inside DOWNLOAD_ROOT and exists on
disk otherwise None. Centralizes the path-traversal + existence guard every file-serving endpoint
shares, so the containment check lives in exactly one place."""
root = Path(download_root).resolve()
path = abs_path(download_root, rel).resolve()
if root not in path.parents or not path.exists():
return None
return path
def place_file(staging_file: Path, download_root: str, rel: str) -> None: def place_file(staging_file: Path, download_root: str, rel: str) -> None:
"""Move a completed staging file into its final location under DOWNLOAD_ROOT.""" """Move a completed staging file into its final location under DOWNLOAD_ROOT."""
dest = abs_path(download_root, rel) dest = abs_path(download_root, rel)

View file

@ -232,6 +232,11 @@ class Subscription(Base, TimestampMixin):
channel: Mapped["Channel"] = relationship(back_populates="subscriptions") channel: Mapped["Channel"] = relationship(back_populates="subscriptions")
# Video.live_status values that mean "currently a live/upcoming broadcast" — transient states
# hidden from feeds/search and revisited by the refresh job until they settle into a VOD.
LIVE_OR_UPCOMING = ("live", "upcoming")
class Video(Base, TimestampMixin): class Video(Base, TimestampMixin):
"""A YouTube video. Shared across users; per-user state lives elsewhere.""" """A YouTube video. Shared across users; per-user state lives elsewhere."""

View file

@ -5,13 +5,14 @@ import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import and_, func, select, update from sqlalchemy import func, select, update
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import quota, sysconfig from app import quota, sysconfig
from app.auth import current_user, has_write_scope, require_human from app.auth import current_user, has_write_scope, require_human
from app.db import get_db from app.db import get_db
from app.models import ( from app.models import (
LIVE_OR_UPCOMING,
BlockedChannel, BlockedChannel,
Channel, Channel,
ChannelTag, ChannelTag,
@ -59,7 +60,7 @@ def list_channels(
func.max(Video.published_at), func.max(Video.published_at),
func.coalesce(func.sum(Video.duration_seconds), 0), func.coalesce(func.sum(Video.duration_seconds), 0),
func.count(Video.id).filter(Video.is_short.is_(True)), func.count(Video.id).filter(Video.is_short.is_(True)),
func.count(Video.id).filter(Video.live_status.in_(("live", "upcoming"))), func.count(Video.id).filter(Video.live_status.in_(LIVE_OR_UPCOMING)),
) )
.where(Video.channel_id.in_(channel_ids)) .where(Video.channel_id.in_(channel_ids))
.group_by(Video.channel_id) .group_by(Video.channel_id)

View file

@ -7,7 +7,6 @@ serves finished files (range-aware, with the user's custom display name), and sh
""" """
import re import re
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, urlparse
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
@ -69,20 +68,6 @@ def resolve_source(raw: str) -> tuple[str, str]:
raise HTTPException(status_code=400, detail="That doesn't look like a valid video link.") raise HTTPException(status_code=400, detail="That doesn't look like a valid video link.")
def _reference_url(job: DownloadJob, asset: MediaAsset | None) -> str | None:
"""The clean "downloaded from" URL for the Downloads page: the canonical page URL yt-dlp
recorded, else one derived from source_kind+source_ref (covers queued/older rows before the
worker fills it). Returns None for edit derivatives a clip's source is the user's own earlier
download, not a web page, so there's no meaningful external URL to show."""
if job.job_kind == "edit":
return None
if asset is not None and asset.source_webpage_url:
return asset.source_webpage_url
if job.source_kind in ("youtube", "url"):
return service.source_url(job.source_kind, job.source_ref)
return None
def _serialize(job: DownloadJob, asset: MediaAsset | None) -> dict: def _serialize(job: DownloadJob, asset: MediaAsset | None) -> dict:
ready = asset is not None and asset.status == "ready" and bool(asset.rel_path) ready = asset is not None and asset.status == "ready" and bool(asset.rel_path)
return { return {
@ -97,7 +82,7 @@ def _serialize(job: DownloadJob, asset: MediaAsset | None) -> dict:
"created_at": job.created_at.isoformat() if job.created_at else None, "created_at": job.created_at.isoformat() if job.created_at else None,
"source_kind": job.source_kind, "source_kind": job.source_kind,
"source_ref": job.source_ref, "source_ref": job.source_ref,
"source_url": _reference_url(job, asset), "source_url": service.reference_url(job, asset),
# A locally-generated poster to fall back to when the source had no thumbnail. # A locally-generated poster to fall back to when the source had no thumbnail.
"poster_url": f"/api/downloads/{job.id}/poster.jpg" "poster_url": f"/api/downloads/{job.id}/poster.jpg"
if (asset is not None and asset.poster_path) if (asset is not None and asset.poster_path)
@ -141,6 +126,13 @@ def _assets_for(db: Session, jobs: list[DownloadJob]) -> dict[int, MediaAsset]:
return {a.id: a for a in rows} return {a.id: a for a in rows}
def _serialize_job(db: Session, job: DownloadJob) -> dict:
"""Resolve a job's cache asset (if any) and serialize the pair — the resolve-then-serialize step
every single-job handler shares."""
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
return _serialize(job, asset)
def _own_job(db: Session, user: User, job_id: int) -> DownloadJob: def _own_job(db: Session, user: User, job_id: int) -> DownloadJob:
job = db.get(DownloadJob, job_id) job = db.get(DownloadJob, job_id)
if job is None or job.user_id != user.id: if job is None or job.user_id != user.id:
@ -274,8 +266,7 @@ def enqueue_download(
) )
except quota.QuotaExceeded as e: except quota.QuotaExceeded as e:
raise HTTPException(status_code=422, detail=_quota_message(e)) raise HTTPException(status_code=422, detail=_quota_message(e))
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None return _serialize_job(db, job)
return _serialize(job, asset)
@router.post("/edit") @router.post("/edit")
@ -300,8 +291,7 @@ def enqueue_edit(
raise HTTPException(status_code=422, detail=_quota_message(e)) raise HTTPException(status_code=422, detail=_quota_message(e))
except service.EditError as e: except service.EditError as e:
raise HTTPException(status_code=400, detail=_edit_message(e)) raise HTTPException(status_code=400, detail=_edit_message(e))
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None return _serialize_job(db, job)
return _serialize(job, asset)
def _edit_message(e: service.EditError) -> str: def _edit_message(e: service.EditError) -> str:
@ -431,8 +421,7 @@ def update_download_meta(
cleaned = [u for u in (_clean_url(x) for x in raw) if u][:_MAX_EXTRA_LINKS] cleaned = [u for u in (_clean_url(x) for x in raw) if u][:_MAX_EXTRA_LINKS]
job.extra_links = cleaned or None job.extra_links = cleaned or None
db.commit() db.commit()
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None return _serialize_job(db, job)
return _serialize(job, asset)
def _set_status(db: Session, job: DownloadJob, status: str) -> None: def _set_status(db: Session, job: DownloadJob, status: str) -> None:
@ -447,8 +436,7 @@ def pause_download(
job = _own_job(db, user, job_id) job = _own_job(db, user, job_id)
if job.status in ("queued", "running"): if job.status in ("queued", "running"):
_set_status(db, job, "paused") # a running worker aborts cooperatively via the hook _set_status(db, job, "paused") # a running worker aborts cooperatively via the hook
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None return _serialize_job(db, job)
return _serialize(job, asset)
@router.post("/{job_id}/resume") @router.post("/{job_id}/resume")
@ -468,8 +456,7 @@ def resume_download(
asset.status = "pending" asset.status = "pending"
asset.error = None asset.error = None
_set_status(db, job, "queued") _set_status(db, job, "queued")
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None return _serialize_job(db, job)
return _serialize(job, asset)
@router.post("/{job_id}/cancel") @router.post("/{job_id}/cancel")
@ -481,8 +468,7 @@ def cancel_download(
_release_asset(db, job) # release while the job still counts as holding _release_asset(db, job) # release while the job still counts as holding
job.status = "canceled" job.status = "canceled"
db.commit() db.commit()
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None return _serialize_job(db, job)
return _serialize(job, asset)
@router.delete("/{job_id}") @router.delete("/{job_id}")
@ -525,11 +511,6 @@ def _release_asset(db: Session, job: DownloadJob) -> None:
# --- file download (range-aware, custom display name) -------------------------------------- # --- file download (range-aware, custom display name) --------------------------------------
def _clean_basename(name: str) -> str:
# Emoji/symbol-free but keeps spaces + accents (a readable device filename).
return storage.display_filename(name)
def _accessible_job(db: Session, user: User, job_id: int) -> DownloadJob: def _accessible_job(db: Session, user: User, job_id: int) -> DownloadJob:
job = db.get(DownloadJob, job_id) job = db.get(DownloadJob, job_id)
if job is None: if job is None:
@ -557,16 +538,13 @@ def download_file(
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
if asset is None or asset.status != "ready" or not asset.rel_path: if asset is None or asset.status != "ready" or not asset.rel_path:
raise HTTPException(status_code=409, detail="This download isn't ready.") raise HTTPException(status_code=409, detail="This download isn't ready.")
path = storage.abs_path(settings.download_root, asset.rel_path).resolve() path = storage.safe_abs_path(settings.download_root, asset.rel_path)
root = Path(settings.download_root).resolve() if path is None:
if root not in path.parents or not path.exists():
raise HTTPException(status_code=404, detail="File is no longer available.") raise HTTPException(status_code=404, detail="File is no longer available.")
ext = asset.container or path.suffix.lstrip(".") filename = storage.download_filename(
base = _clean_basename(job.display_name or asset.title or asset.source_ref) job.display_name or asset.title or asset.source_ref, asset.container, path
if base.lower().endswith(f".{ext.lower()}"): )
base = base[: -(len(ext) + 1)]
filename = f"{base}.{ext}"
asset.last_access_at = datetime.now(timezone.utc) asset.last_access_at = datetime.now(timezone.utc)
db.commit() db.commit()
@ -611,9 +589,8 @@ def poster_image(
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
if asset is None or not asset.poster_path: if asset is None or not asset.poster_path:
raise HTTPException(status_code=404, detail="No poster.") raise HTTPException(status_code=404, detail="No poster.")
path = storage.abs_path(settings.download_root, asset.poster_path).resolve() path = storage.safe_abs_path(settings.download_root, asset.poster_path)
root = Path(settings.download_root).resolve() if path is None:
if root not in path.parents or not path.exists():
raise HTTPException(status_code=404, detail="No poster.") raise HTTPException(status_code=404, detail="No poster.")
return FileResponse(path, media_type="image/jpeg") return FileResponse(path, media_type="image/jpeg")
@ -626,9 +603,8 @@ def storyboard_image(
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
if asset is None or not asset.storyboard_path: if asset is None or not asset.storyboard_path:
raise HTTPException(status_code=404, detail="No filmstrip.") raise HTTPException(status_code=404, detail="No filmstrip.")
path = storage.abs_path(settings.download_root, asset.storyboard_path).resolve() path = storage.safe_abs_path(settings.download_root, asset.storyboard_path)
root = Path(settings.download_root).resolve() if path is None:
if root not in path.parents or not path.exists():
raise HTTPException(status_code=404, detail="No filmstrip.") raise HTTPException(status_code=404, detail="No filmstrip.")
return FileResponse(path, media_type="image/jpeg") return FileResponse(path, media_type="image/jpeg")
@ -685,7 +661,7 @@ def unshare_download(
user: User = Depends(require_human), user: User = Depends(require_human),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
job = _own_job(db, user, job_id) _own_job(db, user, job_id) # ownership guard (404s if not the caller's job)
recipient = db.execute( recipient = db.execute(
select(User).where(func.lower(User.email) == email.strip().lower()) select(User).where(func.lower(User.email) == email.strip().lower())
).scalar_one_or_none() ).scalar_one_or_none()

View file

@ -14,6 +14,7 @@ from app import quota
from app.auth import current_user from app.auth import current_user
from app.db import get_db from app.db import get_db
from app.models import ( from app.models import (
LIVE_OR_UPCOMING,
BlockedChannel, BlockedChannel,
Channel, Channel,
ChannelTag, ChannelTag,
@ -34,7 +35,6 @@ router = APIRouter(prefix="/api", tags=["feed"])
# "saved" used to be a status; it's now membership in the built-in Watch later playlist. # "saved" used to be a status; it's now membership in the built-in Watch later playlist.
VALID_STATES = {"new", "watched", "hidden"} VALID_STATES = {"new", "watched", "hidden"}
HIDDEN_LIVE = ("live", "upcoming")
# Resume-position thresholds (mirror the client): positions below this are "didn't # Resume-position thresholds (mirror the client): positions below this are "didn't
# really start" and within this of the end are "basically finished" — neither is worth # really start" and within this of the end are "basically finished" — neither is worth
@ -132,7 +132,7 @@ def _filtered_query(
exclude_tag_category: str | None = None, exclude_tag_category: str | None = None,
) -> tuple[Select, object]: ) -> tuple[Select, object]:
"""Build the feed query (joins + all WHERE filters), shared by /feed and /feed/count. """Build the feed query (joins + all WHERE filters), shared by /feed and /feed/count.
Returns the column-bearing select plus the watch-status expression for sorting. Returns the column-bearing select plus the priority/relevance rank expression for sorting.
`scope="my"` (default) restricts the feed to the user's own non-hidden subscriptions. `scope="my"` (default) restricts the feed to the user's own non-hidden subscriptions.
`scope="all"` shows every video in the shared catalog (any user's ingested channels); `scope="all"` shows every video in the shared catalog (any user's ingested channels);
@ -313,12 +313,12 @@ def _filtered_query(
type_clauses = [] type_clauses = []
if show_normal: if show_normal:
type_clauses.append( type_clauses.append(
and_(Video.is_short.is_(False), Video.live_status.notin_(HIDDEN_LIVE)) and_(Video.is_short.is_(False), Video.live_status.notin_(LIVE_OR_UPCOMING))
) )
if include_shorts: if include_shorts:
type_clauses.append(Video.is_short.is_(True)) type_clauses.append(Video.is_short.is_(True))
if include_live: if include_live:
type_clauses.append(Video.live_status.in_(HIDDEN_LIVE)) type_clauses.append(Video.live_status.in_(LIVE_OR_UPCOMING))
query = query.where(or_(*type_clauses) if type_clauses else false()) query = query.where(or_(*type_clauses) if type_clauses else false())
if show == "unwatched": if show == "unwatched":
@ -335,7 +335,7 @@ def _filtered_query(
else: # all else: # all
query = query.where(status_expr != "hidden") query = query.where(status_expr != "hidden")
return query, status_expr, rank_expr return query, rank_expr
def _to_tsquery_str(q: str) -> str | None: def _to_tsquery_str(q: str) -> str | None:
@ -492,7 +492,7 @@ def get_feed(
user: User = Depends(current_user), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
query, _status, rank_expr = _filtered_query(db, user, **params) query, rank_expr = _filtered_query(db, user, **params)
keys = _sort_keys(sort, seed, rank_expr) keys = _sort_keys(sort, seed, rank_expr)
# Expose each key column on the row so we can build the next cursor from the last item. # Expose each key column on the row so we can build the next cursor from the last item.
@ -522,7 +522,7 @@ def get_feed_count(
user: User = Depends(current_user), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
query, _status, _rank = _filtered_query(db, user, **params) query, _rank = _filtered_query(db, user, **params)
total = db.scalar(select(func.count()).select_from(query.subquery())) total = db.scalar(select(func.count()).select_from(query.subquery()))
return {"count": total or 0} return {"count": total or 0}
@ -550,7 +550,7 @@ def get_facets(
# keeps them applied, so each remaining chip narrows to channels that ALSO have all # keeps them applied, so each remaining chip narrows to channels that ALSO have all
# already-selected topics — and tags that can't co-occur drop to zero (hidden). # already-selected topics — and tags that can't co-occur drop to zero (hidden).
conjunctive = category == "topic" and params.get("tag_mode") == "and" conjunctive = category == "topic" and params.get("tag_mode") == "and"
base, _status, _rank = _filtered_query( base, _rank = _filtered_query(
db, db,
user, user,
**{**params, "exclude_tag_category": None if conjunctive else category}, **{**params, "exclude_tag_category": None if conjunctive else category},

View file

@ -5,7 +5,6 @@ credential (a capability URL). They only ever expose one file's stream + minimal
the app or any account data. Password-protected links exchange the password once at `/unlock` for the app or any account data. Password-protected links exchange the password once at `/unlock` for
a short-lived signed grant that the media URL carries (a `<video src>` can't send a header). a short-lived signed grant that the media URL carries (a `<video src>` can't send a header).
""" """
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
@ -42,9 +41,8 @@ def _ready_target(db: Session, link: DownloadLink) -> tuple[DownloadJob, MediaAs
raise HTTPException(status_code=404, detail="This video is no longer available.") raise HTTPException(status_code=404, detail="This video is no longer available.")
# Verify the file is actually on disk here (not just flagged ready), so the watch page never # Verify the file is actually on disk here (not just flagged ready), so the watch page never
# loads a player for a missing file — meta and file agree. # loads a player for a missing file — meta and file agree.
path = storage.abs_path(settings.download_root, asset.rel_path).resolve() path = storage.safe_abs_path(settings.download_root, asset.rel_path)
root = Path(settings.download_root).resolve() if path is None:
if root not in path.parents or not path.exists():
raise HTTPException(status_code=404, detail="This video is no longer available.") raise HTTPException(status_code=404, detail="This video is no longer available.")
return job, asset return job, asset
@ -116,16 +114,11 @@ def watch_file(token: str, g: str | None = None, db: Session = Depends(get_db)):
if link.password_hash and not linksmod.check_grant(token, g): if link.password_hash and not linksmod.check_grant(token, g):
raise HTTPException(status_code=403, detail="This link needs a password.") raise HTTPException(status_code=403, detail="This link needs a password.")
asset = _ready_asset(db, link) asset = _ready_asset(db, link)
path = storage.abs_path(settings.download_root, asset.rel_path).resolve() path = storage.safe_abs_path(settings.download_root, asset.rel_path)
root = Path(settings.download_root).resolve() if path is None:
if root not in path.parents or not path.exists():
raise HTTPException(status_code=404, detail="This video is no longer available.") raise HTTPException(status_code=404, detail="This video is no longer available.")
ext = asset.container or path.suffix.lstrip(".") filename = storage.download_filename(asset.title or "video", asset.container, path)
base = storage.display_filename(asset.title or "video")
if base.lower().endswith(f".{ext.lower()}"):
base = base[: -(len(ext) + 1)]
filename = f"{base}.{ext}"
disposition = "attachment" if link.allow_download else "inline" disposition = "attachment" if link.allow_download else "inline"
# Starlette's FileResponse honours the Range header (206) for seeking/scrubbing. # Starlette's FileResponse honours the Range header (206) for seeking/scrubbing.
return FileResponse(path, filename=filename, content_disposition_type=disposition) return FileResponse(path, filename=filename, content_disposition_type=disposition)
@ -141,8 +134,7 @@ def watch_poster(token: str, g: str | None = None, db: Session = Depends(get_db)
job, asset = _ready_target(db, link) job, asset = _ready_target(db, link)
if not asset.poster_path: if not asset.poster_path:
raise HTTPException(status_code=404, detail="No poster.") raise HTTPException(status_code=404, detail="No poster.")
path = storage.abs_path(settings.download_root, asset.poster_path).resolve() path = storage.safe_abs_path(settings.download_root, asset.poster_path)
root = Path(settings.download_root).resolve() if path is None:
if root not in path.parents or not path.exists():
raise HTTPException(status_code=404, detail="No poster.") raise HTTPException(status_code=404, detail="No poster.")
return FileResponse(path, media_type="image/jpeg") return FileResponse(path, media_type="image/jpeg")

View file

@ -24,6 +24,7 @@ from app import quota, sysconfig
from app.auth import require_human from app.auth import require_human
from app.db import get_db from app.db import get_db
from app.models import ( from app.models import (
LIVE_OR_UPCOMING,
BlockedChannel, BlockedChannel,
Channel, Channel,
Playlist, Playlist,
@ -45,8 +46,6 @@ router = APIRouter(prefix="/api/search", tags=["search"])
# search.list costs this many quota units per page. # search.list costs this many quota units per page.
SEARCH_COST = 100 SEARCH_COST = 100
# live_status values we never surface from a live search (was_live VODs are real content).
_LIVE_HIDDEN = ("live", "upcoming")
def _classify_shorts(db: Session, videos: list[Video]) -> None: def _classify_shorts(db: Session, videos: list[Video]) -> None:
@ -165,7 +164,7 @@ def _ingest_candidates(
# 4) Confirm/deny Shorts (no quota) so none enter via search. # 4) Confirm/deny Shorts (no quota) so none enter via search.
_classify_shorts(db, videos) _classify_shorts(db, videos)
keep = {v.id for v in videos if not v.is_short and v.live_status not in _LIVE_HIDDEN} keep = {v.id for v in videos if not v.is_short and v.live_status not in LIVE_OR_UPCOMING}
return [vid for vid in ids if vid in keep] return [vid for vid in ids if vid in keep]
@ -254,8 +253,6 @@ def search_youtube(
page = yt.search_videos(term, page_token=next_cursor) page = yt.search_videos(term, page_token=next_cursor)
else: else:
page = search_scrape.search(term, continuation=next_cursor) page = search_scrape.search(term, continuation=next_cursor)
# Zero-cost, but logged so the per-user daily cap above keeps counting it.
quota.log_action(db, user.id, quota.QuotaAction.VIDEOS_SEARCH)
except (YouTubeError, search_scrape.ScrapeError) as exc: except (YouTubeError, search_scrape.ScrapeError) as exc:
if collected: if collected:
break # keep what we already gathered break # keep what we already gathered
@ -275,6 +272,14 @@ def search_youtube(
if source == "api" or len(collected) >= limit or not next_cursor: if source == "api" or len(collected) >= limit or not next_cursor:
break break
# Count this as ONE per-user search against the daily cap. The scrape source spends no
# quota, so it never logs an event on its own; the API source already logged exactly one
# (via record_usage — it never auto-pages). Logging once here, after a search that produced
# at least one page (a total failure raises 502 above and never reaches this), keeps the cap
# counting user searches instead of internal continuation pages.
if source != "api":
quota.log_action(db, user.id, quota.QuotaAction.VIDEOS_SEARCH)
ordered = collected[:limit] ordered = collected[:limit]
if ordered: if ordered:

View file

@ -13,7 +13,7 @@ from sqlalchemy import delete, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import progress, quota from app import progress, quota
from app.auth import has_read_scope, has_write_scope from app.auth import has_read_scope
from app.models import Channel, OAuthToken, Playlist, PlaylistItem, User, Video from app.models import Channel, OAuthToken, Playlist, PlaylistItem, User, Video
from app.sync.videos import apply_video_details, parse_dt from app.sync.videos import apply_video_details, parse_dt
from app.youtube.client import YouTubeClient, YouTubeError from app.youtube.client import YouTubeClient, YouTubeError

View file

@ -2,6 +2,7 @@
uploads playlist, and enrichment via videos.list (duration, stats, category, Shorts uploads playlist, and enrichment via videos.list (duration, stats, category, Shorts
and livestream classification).""" and livestream classification)."""
import re import re
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
@ -10,7 +11,7 @@ from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import progress, sysconfig from app import progress, sysconfig
from app.models import Channel, Video from app.models import LIVE_OR_UPCOMING, Channel, Video
from app.titles import normalize_title from app.titles import normalize_title
from app.youtube.client import YouTubeClient, best_thumbnail from app.youtube.client import YouTubeClient, best_thumbnail
from app.youtube.rss import fetch_channel_feed from app.youtube.rss import fetch_channel_feed
@ -260,6 +261,37 @@ def apply_video_details(video: Video, item: dict) -> None:
# is_short is decided separately by the youtube.com/shorts probe (run_shorts_classification). # is_short is decided separately by the youtube.com/shorts probe (run_shorts_classification).
def _apply_video_batch(
db: Session,
yt: YouTubeClient,
videos: list[Video],
on_missing: Callable[[Video], None],
) -> int:
"""Fetch videos.list details for the given rows, apply them (stamping enriched_at), and
return how many were applied. Rows YouTube omits (deleted/private) are handled by the
caller-supplied `on_missing` so each caller decides how to retire them."""
if not videos:
return 0
items = {it["id"]: it for it in yt.get_videos([v.id for v in videos])}
now = _now()
applied = 0
for video in videos:
item = items.get(video.id)
if item is None:
on_missing(video)
continue
apply_video_details(video, item)
video.enriched_at = now
applied += 1
db.commit()
return applied
def _mark_enriched(video: Video) -> None:
# Unavailable (deleted/private): stamp so we don't keep retrying.
video.enriched_at = _now()
def enrich_pending(db: Session, yt: YouTubeClient, limit: int | None = None) -> int: def enrich_pending(db: Session, yt: YouTubeClient, limit: int | None = None) -> int:
limit = limit or sysconfig.get_int(db, "enrich_batch_size") limit = limit or sysconfig.get_int(db, "enrich_batch_size")
videos = ( videos = (
@ -267,22 +299,7 @@ def enrich_pending(db: Session, yt: YouTubeClient, limit: int | None = None) ->
.scalars() .scalars()
.all() .all()
) )
if not videos: return _apply_video_batch(db, yt, list(videos), _mark_enriched)
return 0
items = {it["id"]: it for it in yt.get_videos([v.id for v in videos])}
now = _now()
enriched = 0
for video in videos:
item = items.get(video.id)
if item is None:
# Unavailable (deleted/private): stamp so we don't keep retrying.
video.enriched_at = now
continue
apply_video_details(video, item)
video.enriched_at = now
enriched += 1
db.commit()
return enriched
def refresh_live(db: Session, yt: YouTubeClient, limit: int | None = None) -> int: def refresh_live(db: Session, yt: YouTubeClient, limit: int | None = None) -> int:
@ -300,7 +317,7 @@ def refresh_live(db: Session, yt: YouTubeClient, limit: int | None = None) -> in
select(Video) select(Video)
.where( .where(
or_( or_(
Video.live_status.in_(("live", "upcoming")), Video.live_status.in_(LIVE_OR_UPCOMING),
and_( and_(
Video.live_status == "was_live", Video.live_status == "was_live",
Video.duration_seconds.is_(None), Video.duration_seconds.is_(None),
@ -312,23 +329,13 @@ def refresh_live(db: Session, yt: YouTubeClient, limit: int | None = None) -> in
.scalars() .scalars()
.all() .all()
) )
if not videos:
return 0 def _retire(video: Video) -> None:
items = {it["id"]: it for it in yt.get_videos([v.id for v in videos])}
now = _now()
updated = 0
for video in videos:
item = items.get(video.id)
if item is None:
# The broadcast vanished (deleted/private). Stop treating it as live so it # The broadcast vanished (deleted/private). Stop treating it as live so it
# doesn't get refreshed forever; it just becomes an ordinary (dead) row. # doesn't get refreshed forever; it just becomes an ordinary (dead) row.
video.live_status = "none" video.live_status = "none"
continue
apply_video_details(video, item) return _apply_video_batch(db, yt, list(videos), _retire)
video.enriched_at = now
updated += 1
db.commit()
return updated
def run_shorts_classification(db: Session, limit: int | None = None) -> dict: def run_shorts_classification(db: Session, limit: int | None = None) -> dict:

View file

@ -12,7 +12,6 @@ from datetime import datetime, timedelta, timezone
import httpx import httpx
from app import quota, sysconfig from app import quota, sysconfig
from app.config import settings
from app.models import User from app.models import User
from app.security import decrypt from app.security import decrypt
@ -168,9 +167,9 @@ class YouTubeClient:
if not page_token: if not page_token:
break break
def iter_my_playlist_video_ids(self, playlist_id: str) -> Iterator[str]: def _iter_playlist_items(self, playlist_id: str) -> Iterator[dict]:
"""Yield the video ids of one of the user's own playlists, in playlist order """Paginate one of the user's playlists' items (playlistItems, contentDetails part;
(OAuth, so private/unlisted playlists work).""" OAuth so private/unlisted work), yielding each raw item in playlist order."""
page_token = None page_token = None
while True: while True:
params = { params = {
@ -181,14 +180,19 @@ class YouTubeClient:
if page_token: if page_token:
params["pageToken"] = page_token params["pageToken"] = page_token
data = self._get("playlistItems", params, cost=1, allow_key=False) data = self._get("playlistItems", params, cost=1, allow_key=False)
for item in data.get("items", []): yield from data.get("items", [])
vid = item.get("contentDetails", {}).get("videoId")
if vid:
yield vid
page_token = data.get("nextPageToken") page_token = data.get("nextPageToken")
if not page_token: if not page_token:
break break
def iter_my_playlist_video_ids(self, playlist_id: str) -> Iterator[str]:
"""Yield the video ids of one of the user's own playlists, in playlist order
(OAuth, so private/unlisted playlists work)."""
for item in self._iter_playlist_items(playlist_id):
vid = item.get("contentDetails", {}).get("videoId")
if vid:
yield vid
def get_my_channel_id(self) -> str | None: def get_my_channel_id(self) -> str | None:
"""The authenticated user's own channel id (channels.list?mine=true). OAuth only; """The authenticated user's own channel id (channels.list?mine=true). OAuth only;
1 quota unit. Returns None if the account has no channel.""" 1 quota unit. Returns None if the account has no channel."""
@ -265,23 +269,10 @@ class YouTubeClient:
"""Yield {item_id, video_id} for each item of one of the user's playlists, in """Yield {item_id, video_id} for each item of one of the user's playlists, in
playlist order. `item_id` is the playlistItem resource id, needed to delete or playlist order. `item_id` is the playlistItem resource id, needed to delete or
reposition the item via the write API. OAuth (private playlists work).""" reposition the item via the write API. OAuth (private playlists work)."""
page_token = None for item in self._iter_playlist_items(playlist_id):
while True:
params = {
"part": "contentDetails",
"playlistId": playlist_id,
"maxResults": 50,
}
if page_token:
params["pageToken"] = page_token
data = self._get("playlistItems", params, cost=1, allow_key=False)
for item in data.get("items", []):
vid = item.get("contentDetails", {}).get("videoId") vid = item.get("contentDetails", {}).get("videoId")
if vid: if vid:
yield {"item_id": item.get("id"), "video_id": vid} yield {"item_id": item.get("id"), "video_id": vid}
page_token = data.get("nextPageToken")
if not page_token:
break
# --- write endpoints (OAuth only, never the API key; each costs 50 quota units) --- # --- write endpoints (OAuth only, never the API key; each costs 50 quota units) ---
def _write(self, method: str, path: str, *, params: dict, json: dict | None = None) -> dict: def _write(self, method: str, path: str, *, params: dict, json: dict | None = None) -> dict:

View file

@ -9,7 +9,7 @@ import { useDismiss } from "../lib/useDismiss";
// other modules (e.g. the playlist manager) can reuse it. Below `md` it falls back to a card // other modules (e.g. the playlist manager) can reuse it. Below `md` it falls back to a card
// list built from the same column definitions. // list built from the same column definitions.
export type ColumnFilter<T> = type ColumnFilter<T> =
| { kind: "text"; get: (row: T) => string } | { kind: "text"; get: (row: T) => string }
| { kind: "select"; options: { value: string; label: string }[]; test: (row: T, value: string) => boolean } | { kind: "select"; options: { value: string; label: string }[]; test: (row: T, value: string) => boolean }
| { kind: "multi"; options: { value: string; label: string }[]; test: (row: T, values: string[]) => boolean }; | { kind: "multi"; options: { value: string; label: string }[]; test: (row: T, values: string[]) => boolean };

View file

@ -1,4 +1,4 @@
import { lazy, Suspense, useMemo, useState } from "react"; import { lazy, Suspense, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
@ -27,6 +27,7 @@ const ShareDialog = lazy(() => import("./ShareDialog"));
import { useConfirm } from "./ConfirmProvider"; import { useConfirm } from "./ConfirmProvider";
import { useLiveQuery } from "../lib/useLiveQuery"; import { useLiveQuery } from "../lib/useLiveQuery";
import { api, type DownloadJob, type Me } from "../lib/api"; import { api, type DownloadJob, type Me } from "../lib/api";
import { invalidateDownloads } from "../lib/downloads";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { formatBytes, formatDuration, formatEta, formatSpeed } from "../lib/format"; import { formatBytes, formatDuration, formatEta, formatSpeed } from "../lib/format";
@ -546,7 +547,7 @@ export default function DownloadCenter({ me }: { me: Me }) {
const [addUrl, setAddUrl] = useState(""); const [addUrl, setAddUrl] = useState("");
const [addSource, setAddSource] = useState<string | null>(null); const [addSource, setAddSource] = useState<string | null>(null);
const [manage, setManage] = useState(false); const [manage, setManage] = useState(false);
const [renameJob, setRenameJob] = useState<DownloadJob | null>(null); const [metaJob, setMetaJob] = useState<DownloadJob | null>(null);
const [shareJob, setShareJob] = useState<DownloadJob | null>(null); const [shareJob, setShareJob] = useState<DownloadJob | null>(null);
const [editJob, setEditJob] = useState<DownloadJob | null>(null); const [editJob, setEditJob] = useState<DownloadJob | null>(null);
@ -556,11 +557,7 @@ export default function DownloadCenter({ me }: { me: Me }) {
const queue = jobs.filter((j) => ["queued", "running", "paused", "error"].includes(j.status)); const queue = jobs.filter((j) => ["queued", "running", "paused", "error"].includes(j.status));
const library = jobs.filter((j) => j.status === "done"); const library = jobs.filter((j) => j.status === "done");
const invalidate = () => { const invalidate = () => invalidateDownloads(qc);
qc.invalidateQueries({ queryKey: ["downloads"] });
qc.invalidateQueries({ queryKey: ["download-index"] });
qc.invalidateQueries({ queryKey: ["download-usage"] });
};
const act = useMutation({ const act = useMutation({
mutationFn: ({ id, op }: { id: number; op: "pause" | "resume" | "cancel" | "delete" }) => mutationFn: ({ id, op }: { id: number; op: "pause" | "resume" | "cancel" | "delete" }) =>
op === "pause" ? api.pauseDownload(id) op === "pause" ? api.pauseDownload(id)
@ -586,6 +583,12 @@ export default function DownloadCenter({ me }: { me: Me }) {
{ id: "shared", label: t("downloads.tabs.shared") }, { id: "shared", label: t("downloads.tabs.shared") },
...(me.role === "admin" ? [{ id: "system", label: t("downloads.tabs.system") }] : []), ...(me.role === "admin" ? [{ id: "system", label: t("downloads.tabs.system") }] : []),
]; ];
// A persisted tab can point at one no longer available (e.g. "system" restored for a now-non-admin
// account) — that would render a blank page with no active tab. Fall back to the default.
useEffect(() => {
if (!tabs.some((tb) => tb.id === tab)) setTab("queue");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tab, me.role]);
const saveBtn = (job: DownloadJob) => ( const saveBtn = (job: DownloadJob) => (
<a <a
@ -668,7 +671,7 @@ export default function DownloadCenter({ me }: { me: Me }) {
<Scissors className="w-4 h-4" /> <Scissors className="w-4 h-4" />
</IconBtn> </IconBtn>
)} )}
<IconBtn onClick={() => setRenameJob(job)} title={t("downloads.actions.editDetails")}> <IconBtn onClick={() => setMetaJob(job)} title={t("downloads.actions.editDetails")}>
<Pencil className="w-4 h-4" /> <Pencil className="w-4 h-4" />
</IconBtn> </IconBtn>
<IconBtn onClick={() => setShareJob(job)} title={t("downloads.actions.share")}> <IconBtn onClick={() => setShareJob(job)} title={t("downloads.actions.share")}>
@ -732,7 +735,7 @@ export default function DownloadCenter({ me }: { me: Me }) {
/> />
)} )}
{manage && <ProfileEditor onClose={() => setManage(false)} />} {manage && <ProfileEditor onClose={() => setManage(false)} />}
{renameJob && <MetaEditModal job={renameJob} onClose={() => setRenameJob(null)} />} {metaJob && <MetaEditModal job={metaJob} onClose={() => setMetaJob(null)} />}
{shareJob && <ShareDialog job={shareJob} onClose={() => setShareJob(null)} />} {shareJob && <ShareDialog job={shareJob} onClose={() => setShareJob(null)} />}
{editJob && <VideoEditor job={editJob} onClose={() => setEditJob(null)} />} {editJob && <VideoEditor job={editJob} onClose={() => setEditJob(null)} />}
</Suspense> </Suspense>

View file

@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import Modal from "./Modal"; import Modal from "./Modal";
import { api } from "../lib/api"; import { api } from "../lib/api";
import { invalidateDownloads } from "../lib/downloads";
// Lazy: the full profile editor only opens from the dialog's "manage presets" affordance. // Lazy: the full profile editor only opens from the dialog's "manage presets" affordance.
const ProfileEditor = lazy(() => import("./ProfileEditor")); const ProfileEditor = lazy(() => import("./ProfileEditor"));
@ -43,9 +44,7 @@ export default function DownloadDialog({
profile_id: chosen, profile_id: chosen,
display_name: name.trim() || undefined, display_name: name.trim() || undefined,
}); });
qc.invalidateQueries({ queryKey: ["downloads"] }); invalidateDownloads(qc);
qc.invalidateQueries({ queryKey: ["download-index"] });
qc.invalidateQueries({ queryKey: ["download-usage"] });
notify({ notify({
level: "success", level: "success",
message: t("downloads.dialog.added"), message: t("downloads.dialog.added"),

View file

@ -184,9 +184,18 @@ export default function Feed({
setOverrides({}); setOverrides({});
setSavedOverrides({}); setSavedOverrides({});
}, [filters]); }, [filters]);
// ...but NOT on infinite-scroll appends: fetchNextPage also bumps dataUpdatedAt while page 1's
// rows are unchanged, so clearing overrides there would make a just-hidden card flash back.
// Only a real refetch (page count doesn't grow) is authoritative.
const pagesLenRef = useRef(0);
useEffect(() => { useEffect(() => {
const len = query.data?.pages?.length ?? 0;
const appended = len > pagesLenRef.current;
pagesLenRef.current = len;
if (!appended) {
setOverrides({}); setOverrides({});
setSavedOverrides({}); setSavedOverrides({});
}
}, [query.dataUpdatedAt]); }, [query.dataUpdatedAt]);
const countQuery = useQuery({ const countQuery = useQuery({
@ -310,12 +319,21 @@ export default function Feed({
list list
.map((v) => (overrides[v.id] ? { ...v, status: overrides[v.id] } : v)) .map((v) => (overrides[v.id] ? { ...v, status: overrides[v.id] } : v))
.map((v) => (savedOverrides[v.id] !== undefined ? { ...v, saved: savedOverrides[v.id] } : v)); .map((v) => (savedOverrides[v.id] !== undefined ? { ...v, saved: savedOverrides[v.id] } : v));
const items: Video[] = withOverrides(loaded).filter((v) => matchesView(v.status, filters.show)); const merged: Video[] = withOverrides(loaded);
const items: Video[] = merged.filter((v) => matchesView(v.status, filters.show));
// The in-app player's queue (auto-advance / loop / prev-next). It's the filtered feed, but the // The in-app player's queue (auto-advance / loop / prev-next). It's the filtered feed, but the
// watch-state view filter is NOT applied — so marking the current video watched keeps it in the // watch-state view filter is NOT applied — so marking the current video watched keeps it in the
// sequence (no reindex/reload mid-play), matching "watched doesn't affect the list". A video that // sequence (no reindex/reload mid-play), matching "watched doesn't affect the list". A video that
// goes hidden still drops out ("visible affects"). // goes hidden still drops out ("visible affects").
const playerQueue: Video[] = withOverrides(loaded).filter((v) => v.status !== "hidden"); const playerQueue: Video[] = merged.filter((v) => v.status !== "hidden");
// A live search ingests new catalog videos, so the disabled feed queries still hold their stale
// pre-search cache. Drop them so the feed re-fetches fresh on return.
const dropFeedCaches = () => {
qc.removeQueries({ queryKey: ["feed"] });
qc.removeQueries({ queryKey: ["feed-count"] });
qc.removeQueries({ queryKey: ["facets"] });
};
// --- Live YouTube search mode ------------------------------------------------------------- // --- Live YouTube search mode -------------------------------------------------------------
// A separate data source rendered in the same cards/player. No watch-state view filter (the // A separate data source rendered in the same cards/player. No watch-state view filter (the
@ -341,16 +359,11 @@ export default function Feed({
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<button <button
onClick={() => { onClick={() => {
// The search just ingested new catalog videos, so the normal feed (which was
// disabled and still holds its pre-search cache) is stale. Drop those caches so
// it re-fetches fresh on return instead of showing the old (often empty) result.
// Surface the just-searched videos in the feed you return to: switch the Source // Surface the just-searched videos in the feed you return to: switch the Source
// filter to "search" (your own search finds) and rank them by relevance. // filter to "search" (your own search finds) and rank them by relevance.
setFilters({ ...filters, librarySource: "search", sort: "relevance" }); setFilters({ ...filters, librarySource: "search", sort: "relevance" });
onExitYtSearch(); onExitYtSearch();
qc.removeQueries({ queryKey: ["feed"] }); dropFeedCaches();
qc.removeQueries({ queryKey: ["feed-count"] });
qc.removeQueries({ queryKey: ["facets"] });
}} }}
className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-accent transition shrink-0" className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-accent transition shrink-0"
> >
@ -394,9 +407,7 @@ export default function Feed({
.catch(() => {}); .catch(() => {});
setFilters({ ...filters, librarySource: "organic", sort: "newest" }); setFilters({ ...filters, librarySource: "organic", sort: "newest" });
onExitYtSearch(); onExitYtSearch();
qc.removeQueries({ queryKey: ["feed"] }); dropFeedCaches();
qc.removeQueries({ queryKey: ["feed-count"] });
qc.removeQueries({ queryKey: ["facets"] });
qc.removeQueries({ queryKey: ["yt-search"] }); qc.removeQueries({ queryKey: ["yt-search"] });
}} }}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full border border-border text-muted hover:text-danger hover:border-danger transition" className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full border border-border text-muted hover:text-danger hover:border-danger transition"
@ -541,8 +552,6 @@ export default function Feed({
</button> </button>
); );
})} })}
{(
<>
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" /> <span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
<label className="inline-flex items-center gap-1.5 text-xs text-muted"> <label className="inline-flex items-center gap-1.5 text-xs text-muted">
<span>{t("feed.source.label")}</span> <span>{t("feed.source.label")}</span>
@ -562,8 +571,6 @@ export default function Feed({
<option value="search">{t("feed.source.only")}</option> <option value="search">{t("feed.source.only")}</option>
</select> </select>
</label> </label>
</>
)}
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" /> <span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
<span className="text-xs text-muted whitespace-nowrap"> <span className="text-xs text-muted whitespace-nowrap">
{countQuery.data {countQuery.data

View file

@ -87,7 +87,12 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
if (!v) return; if (!v) return;
const d = v.duration && isFinite(v.duration) ? v.duration : srcDur; const d = v.duration && isFinite(v.duration) ? v.duration : srcDur;
setDuration(d); setDuration(d);
setSegments((s) => (s.length === 1 && s[0].end <= 0 ? [{ id: 0, start: 0, end: d, keep: true }] : s)); // Reconcile the still-pristine full-length segment to the REAL decoded duration. The initial
// segment ends at the (integer, often-rounded) metadata `srcDur`; once the media loads we know
// the true length (e.g. 213.44 vs 213). Without this the untouched last segment stays short, so
// the no-op guard (isFullSingle: end >= duration) reads false and enables a "Create" that files a
// clip of an unmodified video and drops its tail. Only touches the untouched segment (end===srcDur).
setSegments((s) => (s.length === 1 && s[0].end === srcDur ? [{ ...s[0], end: d }] : s));
}; };
const onTimeUpdate = () => { const onTimeUpdate = () => {
const v = videoRef.current; const v = videoRef.current;
@ -242,7 +247,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
segments.length === 1 && kept.length === 1 && kept[0].start <= 0.01 && kept[0].end >= duration - 0.01; segments.length === 1 && kept.length === 1 && kept[0].start <= 0.01 && kept[0].end >= duration - 0.01;
const valid = kept.length >= 1 && keptDur >= MIN_SEG && (cropOn || !isFullSingle); const valid = kept.length >= 1 && keptDur >= MIN_SEG && (cropOn || !isFullSingle);
const willJoin = output === "join" && kept.length > 1; const willJoin = output === "join" && kept.length > 1;
const reencode = cropOn || (willJoin ? accurate : accurate); const reencode = cropOn || accurate;
const cropPx = (): { x: number; y: number; w: number; h: number } | undefined => { const cropPx = (): { x: number; y: number; w: number; h: number } | undefined => {
const v = videoRef.current; const v = videoRef.current;

View file

@ -4,7 +4,7 @@
// with no react-query provider: the operator contact is fetched with a plain fetch below. // with no react-query provider: the operator contact is fetched with a plain fetch below.
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
export const LAST_UPDATED = "June 14, 2026"; const LAST_UPDATED = "June 14, 2026";
// Contact shown on the legal pages: the instance operator's configured admin email, from the // Contact shown on the legal pages: the instance operator's configured admin email, from the
// public /auth/config. One shared fetch across the page; null when the operator set none. // public /auth/config. One shared fetch across the page; null when the operator set none.
@ -19,7 +19,7 @@ function fetchOperatorContact(): Promise<string | null> {
return contactPromise; return contactPromise;
} }
export function useOperatorContact(): string | null { function useOperatorContact(): string | null {
const [contact, setContact] = useState<string | null>(null); const [contact, setContact] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;

View file

@ -59,7 +59,7 @@ export function Section({
/** A label that reveals an explanatory caption on hover (dotted underline) when `hint` is set. /** A label that reveals an explanatory caption on hover (dotted underline) when `hint` is set.
* No-op styling when there's no hint, so it's safe to use unconditionally. */ * No-op styling when there's no hint, so it's safe to use unconditionally. */
export function HintLabel({ hint, children }: { hint?: string; children: ReactNode }) { function HintLabel({ hint, children }: { hint?: string; children: ReactNode }) {
return ( return (
<Tooltip hint={hint ?? ""}> <Tooltip hint={hint ?? ""}>
<span <span

View file

@ -10,7 +10,7 @@ export const LANGUAGES = [
] as const; ] as const;
export type LangCode = (typeof LANGUAGES)[number]["code"]; export type LangCode = (typeof LANGUAGES)[number]["code"];
const SUPPORTED = LANGUAGES.map((l) => l.code) as readonly string[]; const SUPPORTED = LANGUAGES.map((l) => l.code) as readonly string[];
export const LANG_KEY = LS.lang; const LANG_KEY = LS.lang;
// Auto-load every locale file (locales/<lang>/<area>.json) and merge each <area> under one // Auto-load every locale file (locales/<lang>/<area>.json) and merge each <area> under one
// `translation` namespace, so components use t("area.key") and adding an area needs no wiring. // `translation` namespace, so components use t("area.key") and adding an area needs no wiring.
@ -33,7 +33,7 @@ export function isSupported(code: string | null | undefined): code is LangCode {
// Initial language before the server preference is known: stored choice, else the browser // Initial language before the server preference is known: stored choice, else the browser
// language, else English. (After login, App adopts the server-persisted preference.) // language, else English. (After login, App adopts the server-persisted preference.)
export function detectInitialLang(): LangCode { function detectInitialLang(): LangCode {
const stored = localStorage.getItem(LANG_KEY); const stored = localStorage.getItem(LANG_KEY);
if (isSupported(stored)) return stored; if (isSupported(stored)) return stored;
const nav = (navigator.language || "en").slice(0, 2).toLowerCase(); const nav = (navigator.language || "en").slice(0, 2).toLowerCase();

View file

@ -78,12 +78,6 @@
"copied": "Quell-Link in die Zwischenablage kopiert", "copied": "Quell-Link in die Zwischenablage kopiert",
"copyFailed": "Link konnte nicht kopiert werden" "copyFailed": "Link konnte nicht kopiert werden"
}, },
"rename": {
"title": "Download umbenennen",
"label": "Anzeigename",
"hint": "Ändert nur den angezeigten und beim Speichern verwendeten Namen — die gespeicherte Datei behält ihren Namen.",
"save": "Speichern"
},
"edit": { "edit": {
"title": "Details bearbeiten", "title": "Details bearbeiten",
"name": "Titel", "name": "Titel",

View file

@ -78,12 +78,6 @@
"copied": "Source link copied to clipboard", "copied": "Source link copied to clipboard",
"copyFailed": "Couldn't copy the link" "copyFailed": "Couldn't copy the link"
}, },
"rename": {
"title": "Rename download",
"label": "Display name",
"hint": "Only changes the name you see and download with — the stored file keeps its own name.",
"save": "Save"
},
"edit": { "edit": {
"title": "Edit details", "title": "Edit details",
"name": "Title", "name": "Title",

View file

@ -78,12 +78,6 @@
"copied": "Forráslink a vágólapra másolva", "copied": "Forráslink a vágólapra másolva",
"copyFailed": "Nem sikerült a link másolása" "copyFailed": "Nem sikerült a link másolása"
}, },
"rename": {
"title": "Letöltés átnevezése",
"label": "Megjelenített név",
"hint": "Csak a megjelenített és letöltéskor használt nevet módosítja — a tárolt fájl neve nem változik.",
"save": "Mentés"
},
"edit": { "edit": {
"title": "Adatok szerkesztése", "title": "Adatok szerkesztése",
"name": "Cím", "name": "Cím",

View file

@ -8,7 +8,7 @@ import { LS, setAccountRaw } from "./storage";
// WITHOUT statically importing the — now lazy-loaded — admin page, which would otherwise pull // WITHOUT statically importing the — now lazy-loaded — admin page, which would otherwise pull
// it back into the main bundle and defeat the code-split. // it back into the main bundle and defeat the code-split.
export const ADMIN_USERS_TAB_KEY = LS.adminUsersTab; export const ADMIN_USERS_TAB_KEY = LS.adminUsersTab;
export const ADMIN_USERS_ACCESS_TAB = "access"; const ADMIN_USERS_ACCESS_TAB = "access";
export function focusAccessRequestsTab(): void { export function focusAccessRequestsTab(): void {
setAccountRaw(ADMIN_USERS_TAB_KEY, ADMIN_USERS_ACCESS_TAB); setAccountRaw(ADMIN_USERS_TAB_KEY, ADMIN_USERS_ACCESS_TAB);

View file

@ -78,7 +78,7 @@ export interface VideoDetail {
duration_seconds: number | null; duration_seconds: number | null;
} }
export interface ChannelLink { interface ChannelLink {
title: string | null; title: string | null;
url: string; url: string;
} }
@ -611,7 +611,7 @@ export interface SystemConfigData {
} }
// Admin: result of testing the Plex server connection (also drives the library-picker). // Admin: result of testing the Plex server connection (also drives the library-picker).
export interface PlexSection { interface PlexSection {
key: string; key: string;
title: string; title: string;
type: string; // "movie" | "show" type: string; // "movie" | "show"
@ -656,13 +656,6 @@ export interface PlexCard {
show_id?: string | null; // episode: the show's rating_key (for grouping a playlist by show/season) show_id?: string | null; // episode: the show's rating_key (for grouping a playlist by show/season)
summary?: string | null; // episode summary?: string | null; // episode
} }
export interface PlexBrowseResult {
kind: "movie" | "show";
total: number;
offset: number;
limit: number;
items: PlexCard[];
}
// Unified cross-library browse (movies + shows mixed). `episodes` is populated only on a search that // Unified cross-library browse (movies + shows mixed). `episodes` is populated only on a search that
// also matches episodes (the grouped "Episodes" section). // also matches episodes (the grouped "Episodes" section).
export interface PlexUnifiedResult { export interface PlexUnifiedResult {
@ -823,7 +816,7 @@ export function plexFilterCount(f: PlexFilters): number {
// Serialize the shared PlexFilters metadata fields onto a query string. Used by both the library grid // Serialize the shared PlexFilters metadata fields onto a query string. Used by both the library grid
// and the facets request so the two endpoints always agree on the filter set (paging/sort/sort_dir are // and the facets request so the two endpoints always agree on the filter set (paging/sort/sort_dir are
// each caller's own concern and stay out of here). // each caller's own concern and stay out of here).
export function appendPlexFilters(u: URLSearchParams, f: PlexFilters): void { function appendPlexFilters(u: URLSearchParams, f: PlexFilters): void {
if (f.genres?.length) u.set("genres", f.genres.join(",")); if (f.genres?.length) u.set("genres", f.genres.join(","));
if (f.genreMode === "all") u.set("genre_mode", "all"); if (f.genreMode === "all") u.set("genre_mode", "all");
if (f.contentRatings?.length) u.set("content_ratings", f.contentRatings.join(",")); if (f.contentRatings?.length) u.set("content_ratings", f.contentRatings.join(","));
@ -903,7 +896,7 @@ export interface DownloadProfile {
sort_order: number; sort_order: number;
} }
export interface DownloadAsset { interface DownloadAsset {
id: number; id: number;
status: string; status: string;
title: string | null; title: string | null;

View file

@ -0,0 +1,9 @@
import type { QueryClient } from "@tanstack/react-query";
// The download queue, the per-card download index, and the storage-usage meter all move together
// whenever a job is enqueued, deleted, or its state changes — invalidate them as one unit.
export function invalidateDownloads(qc: QueryClient) {
qc.invalidateQueries({ queryKey: ["downloads"] });
qc.invalidateQueries({ queryKey: ["download-index"] });
qc.invalidateQueries({ queryKey: ["download-usage"] });
}

View file

@ -5,7 +5,7 @@ import { createStore } from "./store";
// server can't carry out an action (a definitive 4xx/5xx response). Distinct from toasts // server can't carry out an action (a definitive 4xx/5xx response). Distinct from toasts
// (transient, e.g. connection blips) and from silent handling. Module-level so the non-React // (transient, e.g. connection blips) and from silent handling. Module-level so the non-React
// api layer can raise it; an <ErrorDialog> mounted at the app root renders the current one. // api layer can raise it; an <ErrorDialog> mounted at the app root renders the current one.
export interface AppError { interface AppError {
title: string; title: string;
message: string; message: string;
} }

View file

@ -5,7 +5,7 @@
import { getActiveAccount, type Message, type MessageUser } from "./api"; import { getActiveAccount, type Message, type MessageUser } from "./api";
// A pushed message plus both parties, so the dock can open/flash the right window. // A pushed message plus both parties, so the dock can open/flash the right window.
export interface IncomingMessage { interface IncomingMessage {
message: Message; message: Message;
from?: MessageUser; from?: MessageUser;
to?: MessageUser; to?: MessageUser;

View file

@ -17,7 +17,7 @@ export async function partnerPub(partnerId: number): Promise<string> {
} }
// Live "is secure messaging unlocked on this device" — shared so the page and the dock agree. // Live "is secure messaging unlocked on this device" — shared so the page and the dock agree.
export function useUnlocked(): boolean { function useUnlocked(): boolean {
return useSyncExternalStore(subscribeUnlock, isUnlocked, isUnlocked); return useSyncExternalStore(subscribeUnlock, isUnlocked, isUnlocked);
} }

View file

@ -6,7 +6,7 @@ import { LS, readAccount, readAccountMerged, writeAccount } from "./storage";
export type NotifLevel = "info" | "success" | "warning" | "error" | "fatal"; export type NotifLevel = "info" | "success" | "warning" | "error" | "fatal";
export interface NotifAction { interface NotifAction {
label: string; label: string;
onClick: () => void; onClick: () => void;
} }
@ -34,7 +34,7 @@ export type ChannelSubscribedMeta = {
}; };
// Admin nudge that pending access requests are waiting — carries a durable inbox link to the // Admin nudge that pending access requests are waiting — carries a durable inbox link to the
// Users page (where Access requests live). No payload; the panel provides the navigation. // Users page (where Access requests live). No payload; the panel provides the navigation.
export type AccessRequestsMeta = { type AccessRequestsMeta = {
kind: "access-requests"; kind: "access-requests";
}; };
export type NotifMeta = export type NotifMeta =
@ -224,7 +224,7 @@ export function notify(input: NotifyInput): number {
} }
/** Back-compat: a simple info toast with an optional inline action (e.g. Undo). */ /** Back-compat: a simple info toast with an optional inline action (e.g. Undo). */
export function toast(message: string, action?: NotifAction): number { function toast(message: string, action?: NotifAction): number {
return notify({ message, action }); return notify({ message, action });
} }

View file

@ -14,10 +14,10 @@
import { getAccountRaw, LS, setAccountRaw } from "./storage"; import { getAccountRaw, LS, setAccountRaw } from "./storage";
export const ONBOARD_ACTIVE = "siftlode.onboarding.active"; // sessionStorage (not in LS, which is localStorage) const ONBOARD_ACTIVE = "siftlode.onboarding.active"; // sessionStorage (not in LS, which is localStorage)
// Per-account (see accountKey): a fresh account should still get the onboarding nudge even if a // Per-account (see accountKey): a fresh account should still get the onboarding nudge even if a
// different account in the same browser dismissed it. // different account in the same browser dismissed it.
export const ONBOARD_DISMISSED = LS.onboardingDismissed; const ONBOARD_DISMISSED = LS.onboardingDismissed;
export function shouldAutoOpenOnboarding(me: { export function shouldAutoOpenOnboarding(me: {
can_read: boolean; can_read: boolean;

View file

@ -8,15 +8,7 @@ import { LS, readAccount, writeAccount } from "./storage";
export type WidgetId = "savedviews" | "date" | "language" | "topic" | "tags"; export type WidgetId = "savedviews" | "date" | "language" | "topic" | "tags";
export const ALL_WIDGETS: WidgetId[] = ["savedviews", "date", "tags", "language", "topic"]; const ALL_WIDGETS: WidgetId[] = ["savedviews", "date", "tags", "language", "topic"];
export const WIDGET_TITLES: Record<WidgetId, string> = {
savedviews: "Saved views",
date: "Upload date",
language: "Language",
topic: "Topic",
tags: "Tags",
};
export interface SidebarLayout { export interface SidebarLayout {
order: WidgetId[]; order: WidgetId[];

View file

@ -129,7 +129,7 @@ export function writeJSON(key: string, value: unknown): void {
* Validation is deferred to the caller (clamp at render) so it can be used before an * Validation is deferred to the caller (clamp at render) so it can be used before an
* async-loaded option list is known, keeping hook order stable. Generalizes the per-component * async-loaded option list is known, keeping hook order stable. Generalizes the per-component
* "persisted active tab" pattern (Settings, Stats, admin pages, the channel filter). */ * "persisted active tab" pattern (Settings, Stats, admin pages, the channel filter). */
export function usePersistedState( function usePersistedState(
key: string, key: string,
fallback = "", fallback = "",
): [string, (value: string) => void] { ): [string, (value: string) => void] {

View file

@ -1,6 +1,6 @@
import { LS, readAccountMerged, writeAccount } from "./storage"; import { LS, readAccountMerged, writeAccount } from "./storage";
export type Mode = "dark" | "light"; type Mode = "dark" | "light";
export type Scheme = "midnight" | "forest" | "slate" | "youtube"; export type Scheme = "midnight" | "forest" | "slate" | "youtube";
export const SCHEMES: { id: Scheme; name: string; swatch: string }[] = [ export const SCHEMES: { id: Scheme; name: string; swatch: string }[] = [

View file

@ -89,7 +89,7 @@ export function hasFilterParams(params: URLSearchParams): boolean {
// The single source of truth for valid page ids; `Page` and the runtime validator both // The single source of truth for valid page ids; `Page` and the runtime validator both
// derive from it, so adding a page is a one-line change (no parallel allowlists to keep in // derive from it, so adding a page is a one-line change (no parallel allowlists to keep in
// sync). "feed" is the default/fallback. // sync). "feed" is the default/fallback.
export const PAGES = [ const PAGES = [
"feed", "feed",
"channels", "channels",
"stats", "stats",