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:
commit
58aac9e8ec
36 changed files with 249 additions and 260 deletions
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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."""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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},
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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])}
|
# The broadcast vanished (deleted/private). Stop treating it as live so it
|
||||||
now = _now()
|
# doesn't get refreshed forever; it just becomes an ordinary (dead) row.
|
||||||
updated = 0
|
video.live_status = "none"
|
||||||
for video in videos:
|
|
||||||
item = items.get(video.id)
|
return _apply_video_batch(db, yt, list(videos), _retire)
|
||||||
if item is None:
|
|
||||||
# The broadcast vanished (deleted/private). Stop treating it as live so it
|
|
||||||
# doesn't get refreshed forever; it just becomes an ordinary (dead) row.
|
|
||||||
video.live_status = "none"
|
|
||||||
continue
|
|
||||||
apply_video_details(video, item)
|
|
||||||
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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
vid = item.get("contentDetails", {}).get("videoId")
|
||||||
params = {
|
if vid:
|
||||||
"part": "contentDetails",
|
yield {"item_id": item.get("id"), "video_id": vid}
|
||||||
"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")
|
|
||||||
if 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:
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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(() => {
|
||||||
setOverrides({});
|
const len = query.data?.pages?.length ?? 0;
|
||||||
setSavedOverrides({});
|
const appended = len > pagesLenRef.current;
|
||||||
|
pagesLenRef.current = len;
|
||||||
|
if (!appended) {
|
||||||
|
setOverrides({});
|
||||||
|
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,29 +552,25 @@ export default function Feed({
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{(
|
<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">
|
||||||
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
|
<span>{t("feed.source.label")}</span>
|
||||||
<label className="inline-flex items-center gap-1.5 text-xs text-muted">
|
<select
|
||||||
<span>{t("feed.source.label")}</span>
|
value={filters.librarySource ?? "organic"}
|
||||||
<select
|
onChange={(e) =>
|
||||||
value={filters.librarySource ?? "organic"}
|
setFilters({
|
||||||
onChange={(e) =>
|
...filters,
|
||||||
setFilters({
|
librarySource: e.target.value as FeedFilters["librarySource"],
|
||||||
...filters,
|
})
|
||||||
librarySource: e.target.value as FeedFilters["librarySource"],
|
}
|
||||||
})
|
title={t("feed.source.tip")}
|
||||||
}
|
className="bg-card border border-border rounded-lg px-2 py-1 text-xs outline-none focus:border-accent"
|
||||||
title={t("feed.source.tip")}
|
>
|
||||||
className="bg-card border border-border rounded-lg px-2 py-1 text-xs outline-none focus:border-accent"
|
<option value="organic">{t("feed.source.organic")}</option>
|
||||||
>
|
<option value="all">{t("feed.source.all")}</option>
|
||||||
<option value="organic">{t("feed.source.organic")}</option>
|
<option value="search">{t("feed.source.only")}</option>
|
||||||
<option value="all">{t("feed.source.all")}</option>
|
</select>
|
||||||
<option value="search">{t("feed.source.only")}</option>
|
</label>
|
||||||
</select>
|
|
||||||
</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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
9
frontend/src/lib/downloads.ts
Normal file
9
frontend/src/lib/downloads.ts
Normal 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"] });
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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[];
|
||||||
|
|
|
||||||
|
|
@ -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] {
|
||||||
|
|
|
||||||
|
|
@ -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 }[] = [
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue