diff --git a/.env.example b/.env.example index 18c3325..06725d1 100644 --- a/.env.example +++ b/.env.example @@ -54,3 +54,11 @@ SMTP_FROM= # more than one instance against the same database, keep SCHEDULER_ENABLED=true on exactly one # of them and false on the rest, to avoid double quota use and write races. SCHEDULER_ENABLED=true + +# --- Download center --- +# The Download Center adds a `worker` container (runs the yt-dlp/ffmpeg job loop) and a small +# `bgutil-pot` sidecar (mints YouTube tokens) — both come up automatically with docker compose. +# Downloaded media defaults to a Docker-managed named volume. Set DOWNLOAD_HOST_PATH to a host +# directory instead — e.g. one your Plex server can read — to keep the Plex-style tree there. +# The path must be writable by the container user (uid of `appuser`, 1000): chown 1000:1000 . +# DOWNLOAD_HOST_PATH=/mnt/media/youtube diff --git a/Dockerfile b/Dockerfile index 08f9839..5775471 100644 --- a/Dockerfile +++ b/Dockerfile @@ -54,7 +54,13 @@ COPY backend/ . COPY VERSION ./VERSION COPY --from=frontend /fe/dist ./app/static_spa -RUN adduser --disabled-password --gecos "" appuser && chown -R appuser /app +# Create the download-center mount point owned by the app user, so a fresh named volume mounted +# at /downloads inherits appuser ownership (Docker copies the image dir's ownership into a new +# empty volume) — the worker/API can then write there without a manual chown. A bind mount to a +# host path instead needs that path writable by this uid (see docs/self-hosting.md). +RUN adduser --disabled-password --gecos "" appuser \ + && mkdir -p /downloads \ + && chown -R appuser /app /downloads USER appuser EXPOSE 8000 diff --git a/README.md b/README.md index c9df4e6..cd72694 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,9 @@ blocker and SponsorBlock keep working. tags to slice the feed by. - **Playlists with two-way YouTube sync** — build them locally, keep them in sync in both directions. - **In-app player** with resume, plus keyboard/scroll controls. +- **Download Center** — save videos to the server with yt-dlp in a Plex-friendly layout (format + presets, per-user storage quota), trim / crop / split & join them in a built-in editor, then save + to your device, share with another user, or hand out a public watch link. - **Multi-user** with per-user private state, a shared catalog, and a fair daily API-quota guard. - **Self-hosted & private**, with a first-run web setup wizard and the interface in **English, Hungarian and German**. diff --git a/VERSION b/VERSION index 8854156..2157409 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.21.0 +0.22.0 diff --git a/backend/alembic/versions/0041_download_edit.py b/backend/alembic/versions/0041_download_edit.py new file mode 100644 index 0000000..d077a12 --- /dev/null +++ b/backend/alembic/versions/0041_download_edit.py @@ -0,0 +1,66 @@ +"""download editor (phase 2): per-user trim/crop derivatives + +Revision ID: 0041_download_edit +Revises: 0040_reset_profiles +Create Date: 2026-07-04 + +Adds the editor columns to `download_jobs`. An edit job (`job_kind='edit'`) derives a new per-user +clip from an already-downloaded job by running ffmpeg (trim = stream-copy, crop = re-encode). It +still hangs off a `media_assets` row (with `source_kind='edit'` and a per-user cache key) so the +existing file-serve / ref-count / GC / quota machinery is reused unchanged. `source_job_id` / +`source_asset_id` point at the parent download; `edit_spec` is the trim/crop recipe. +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0041_download_edit" +down_revision: Union[str, None] = "0040_reset_profiles" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "download_jobs", + sa.Column( + "job_kind", sa.String(length=16), server_default="download", nullable=False + ), + ) + op.add_column( + "download_jobs", sa.Column("source_job_id", sa.Integer(), nullable=True) + ) + op.add_column( + "download_jobs", sa.Column("source_asset_id", sa.Integer(), nullable=True) + ) + op.add_column("download_jobs", sa.Column("edit_spec", sa.JSON(), nullable=True)) + op.create_foreign_key( + "fk_download_jobs_source_job", + "download_jobs", + "download_jobs", + ["source_job_id"], + ["id"], + ondelete="SET NULL", + ) + op.create_foreign_key( + "fk_download_jobs_source_asset", + "download_jobs", + "media_assets", + ["source_asset_id"], + ["id"], + ondelete="SET NULL", + ) + + +def downgrade() -> None: + op.drop_constraint( + "fk_download_jobs_source_asset", "download_jobs", type_="foreignkey" + ) + op.drop_constraint( + "fk_download_jobs_source_job", "download_jobs", type_="foreignkey" + ) + op.drop_column("download_jobs", "edit_spec") + op.drop_column("download_jobs", "source_asset_id") + op.drop_column("download_jobs", "source_job_id") + op.drop_column("download_jobs", "job_kind") diff --git a/backend/alembic/versions/0042_download_links.py b/backend/alembic/versions/0042_download_links.py new file mode 100644 index 0000000..be49218 --- /dev/null +++ b/backend/alembic/versions/0042_download_links.py @@ -0,0 +1,45 @@ +"""public watch links for downloads (share-by-link) + +Revision ID: 0042_download_links +Revises: 0041_download_edit +Create Date: 2026-07-04 + +A `download_links` row is a capability URL for one download: anyone holding the unguessable token +can watch the file on the public `/watch/{token}` player page — no account needed. Optional +per-link controls: an expiry, an "allow download" toggle, and an (argon2-hashed) password. Revoke += delete the row. Distinct from `download_shares` (an ACL grant to another *registered* user). +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0042_download_links" +down_revision: Union[str, None] = "0041_download_edit" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "download_links", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("job_id", sa.Integer(), nullable=False), + sa.Column("token", sa.String(length=64), nullable=False), + sa.Column("created_by", sa.Integer(), nullable=True), + sa.Column("allow_download", sa.Boolean(), server_default="false", nullable=False), + sa.Column("password_hash", sa.Text(), nullable=True), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("view_count", sa.Integer(), server_default="0", nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.ForeignKeyConstraint(["job_id"], ["download_jobs.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["created_by"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("token", name="uq_download_link_token"), + ) + op.create_index("ix_download_links_job_id", "download_links", ["job_id"]) + + +def downgrade() -> None: + op.drop_index("ix_download_links_job_id", table_name="download_links") + op.drop_table("download_links") diff --git a/backend/app/downloads/edit.py b/backend/app/downloads/edit.py new file mode 100644 index 0000000..814d49f --- /dev/null +++ b/backend/app/downloads/edit.py @@ -0,0 +1,333 @@ +"""Video editor (phase 2): per-user trim/crop derivatives via ffmpeg. + +An edit derives a NEW per-user clip from an already-downloaded ready asset. It is never shared- +cached across users (the cache key embeds the user id), so it fully counts against the owner's +quota — but it still rides the same `MediaAsset`/`DownloadJob`/worker machinery as a download, +with the worker branching on the asset's `source_kind` to run ffmpeg instead of yt-dlp. + + * trim, fast → stream copy (`-c copy`), instant, no quality loss, cuts SNAP to keyframes + (the in/out point can be off by a second or two) + * trim, accurate → re-encode (libx264 + aac), frame-accurate; cost scales with CLIP length + (the `-ss` seeks first), so a short clip is cheap even from a long source + * crop (± trim) → always re-encode (a filter can't be stream-copied), frame-accurate + +`edit_spec = {trim?: {start_s, end_s}, crop?: {x, y, w, h}, accurate?: bool}`. `accurate` is the +per-edit user choice for a trim-only cut (ignored when a crop forces a re-encode anyway). `split` +is a frontend concept: the +UI fans a split out into N trim jobs, so there is one output file per job here. + +Also builds the editor **filmstrip** — a single tiled sprite of the source video used as a visual +scrub track. Cached on the source asset's `storyboard_path` (reserved in phase 1) and reusable +for a player hover-preview later. +""" +import hashlib +import json +import logging +import math +import subprocess +from pathlib import Path + +log = logging.getLogger("siftlode.edit") + + +class EditAborted(Exception): + """Raised by run_ffmpeg when the job was paused/canceled mid-encode.""" + + +# --- edit spec ------------------------------------------------------------------------------ + +def normalize_edit_spec(spec: dict | None) -> dict: + """Canonicalize a raw edit spec: clamp/round trim, coerce crop to ints, drop empties. + + The result is what gets hashed (so two equivalent specs share a cache row) and stored.""" + spec = spec or {} + out: dict = {} + + # Crop applies to both a single trim and a multi-segment join. + crop = spec.get("crop") or {} + if crop: + try: + c = {k: int(round(float(crop[k]))) for k in ("x", "y", "w", "h")} + except (KeyError, TypeError, ValueError): + c = None + if c and c["w"] > 0 and c["h"] > 0 and c["x"] >= 0 and c["y"] >= 0: + out["crop"] = c + + # Multi-segment JOIN (cut-list → one concatenated file). Takes precedence over `trim`. + segs = spec.get("segments") + if isinstance(segs, list) and segs: + norm: list[dict] = [] + for s in segs: + try: + st = max(0.0, float((s or {}).get("start_s") or 0.0)) + end_raw = (s or {}).get("end_s") + if end_raw is None: + continue + en = float(end_raw) + except (TypeError, ValueError): + continue + if en > st: + norm.append({"start_s": round(st, 3), "end_s": round(en, 3)}) + norm.sort(key=lambda x: x["start_s"]) + if norm: + out["segments"] = norm + # A join always chooses a codec path: accurate=filter-concat re-encode, + # fast=demuxer-concat stream-copy (keyframe-snapped). + out["accurate"] = bool(spec.get("accurate", True)) + return out + + # Single TRIM (one output file; the frontend fans a "separate" split out into N of these). + trim = spec.get("trim") or {} + if trim: + start = max(0.0, float(trim.get("start_s") or 0.0)) + end_raw = trim.get("end_s") + t: dict = {"start_s": round(start, 3)} + if end_raw is not None: + end = float(end_raw) + if end > start: + t["end_s"] = round(end, 3) + # A trim with only a start (open-ended) is valid (cut to the end). + if t.get("start_s") or "end_s" in t: + out["trim"] = t + + # `accurate` only changes behaviour for a trim-only cut (a crop always re-encodes). Default to + # frame-accurate — an editor should cut where you asked; the user opts into fast/keyframe. + if out.get("trim") and "crop" not in out: + out["accurate"] = bool(spec.get("accurate", True)) + + return out + + +def edit_sig(spec: dict) -> str: + """Stable 24-char signature of a (normalized) edit spec — the per-user cache identity.""" + payload = json.dumps(normalize_edit_spec(spec), sort_keys=True, separators=(",", ":")) + return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:24] + + +def has_crop(spec: dict) -> bool: + return bool(spec.get("crop")) + + +def needs_reencode(spec: dict) -> bool: + """A crop always re-encodes; a trim re-encodes only when the user asked for an accurate cut.""" + return bool(spec.get("crop")) or bool(spec.get("accurate")) + + +def clip_duration(spec: dict, source_duration: int | None) -> int | None: + """Duration of the derived clip (seconds), for display + progress totals.""" + segs = spec.get("segments") + if segs: + return max(0, round(sum(s["end_s"] - s["start_s"] for s in segs))) + trim = spec.get("trim") + if not trim: + return source_duration + start = trim.get("start_s") or 0.0 + end = trim.get("end_s") + if end is None: + end = source_duration + if end is None: + return None + return max(0, round(end - start)) + + +# --- ffmpeg: trim / crop -------------------------------------------------------------------- + +def build_edit_cmd(src: Path, dest: Path, spec: dict, out_ext: str) -> list[str]: + """ffmpeg command for a trim/crop edit. `-progress pipe:1` streams machine-readable progress. + + Trim uses fast input seek (`-ss` before `-i`) + `-t duration`, which is unambiguous across + ffmpeg versions. Trim-only stream-copies; a crop forces a re-encode (a filter can't be + stream-copied).""" + trim = spec.get("trim") or {} + start = trim.get("start_s") or 0.0 + end = trim.get("end_s") + crop = spec.get("crop") + reencode = needs_reencode(spec) + + cmd = [ + "ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-nostdin", + "-progress", "pipe:1", "-nostats", + ] + if start: + cmd += ["-ss", f"{start:.3f}"] + cmd += ["-i", str(src)] + if end is not None: + dur = end - start + if dur > 0: + cmd += ["-t", f"{dur:.3f}"] + + if reencode: + # Input -ss + re-encode is frame-accurate (decodes from the prior keyframe, discards up to + # the exact cut). A crop adds the filter; an accurate trim re-encodes with no filter. + if crop: + cmd += ["-vf", f"crop={crop['w']}:{crop['h']}:{crop['x']}:{crop['y']}"] + cmd += [ + "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", + "-c:a", "aac", "-b:a", "192k", + ] + else: + cmd += ["-c", "copy", "-avoid_negative_ts", "make_zero"] + + if out_ext in ("mp4", "m4a", "mov"): + cmd += ["-movflags", "+faststart"] + cmd += [str(dest)] + return cmd + + +def build_concat_plan(src: Path, dest: Path, spec: dict, out_ext: str, staging: Path): + """Plan a multi-segment JOIN (cut-list → one file). Returns (cmd, prep_files) where prep_files + maps a path → text content the worker must write before running (a concat list, if any). + + * accurate/crop → filter_complex trim+concat, frame-accurate re-encode + * fast → concat demuxer with per-segment inpoint/outpoint, stream-copy + (keyframe-snapped, like the fast single trim)""" + segs: list[dict] = spec["segments"] + crop = spec.get("crop") + prep: dict[Path, str] = {} + head = [ + "ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-nostdin", + "-progress", "pipe:1", "-nostats", + ] + + if needs_reencode(spec): + cropf = f",crop={crop['w']}:{crop['h']}:{crop['x']}:{crop['y']}" if crop else "" + parts, labels = [], [] + for i, s in enumerate(segs): + st, en = s["start_s"], s["end_s"] + parts.append(f"[0:v]trim=start={st}:end={en},setpts=PTS-STARTPTS{cropf}[v{i}]") + parts.append(f"[0:a]atrim=start={st}:end={en},asetpts=PTS-STARTPTS[a{i}]") + labels.append(f"[v{i}][a{i}]") + parts.append(f"{''.join(labels)}concat=n={len(segs)}:v=1:a=1[v][a]") + cmd = head + [ + "-i", str(src), "-filter_complex", ";".join(parts), + "-map", "[v]", "-map", "[a]", + "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", "-c:a", "aac", "-b:a", "192k", + ] + else: + listp = staging / "concat.txt" + lines = [] + for s in segs: + lines.append(f"file '{src.as_posix()}'") + lines.append(f"inpoint {s['start_s']}") + lines.append(f"outpoint {s['end_s']}") + prep[listp] = "\n".join(lines) + "\n" + cmd = head + ["-f", "concat", "-safe", "0", "-i", str(listp), "-c", "copy"] + + if out_ext in ("mp4", "m4a", "mov"): + cmd += ["-movflags", "+faststart"] + cmd += [str(dest)] + return cmd, prep + + +def _parse_out_time(line: str) -> float | None: + """Parse a `-progress` line into elapsed output seconds.""" + if line.startswith("out_time_us="): + try: + return int(line.split("=", 1)[1]) / 1_000_000 + except ValueError: + return None + if line.startswith("out_time_ms="): # (some builds mislabel this as microseconds) + try: + return int(line.split("=", 1)[1]) / 1_000_000 + except ValueError: + return None + return None + + +def run_ffmpeg(cmd, total_s, on_progress, should_cancel) -> None: + """Run an ffmpeg edit, reporting 0–100 progress and honouring cooperative cancel. + + Raises EditAborted if should_cancel() turns True mid-run, RuntimeError on a nonzero exit.""" + proc = subprocess.Popen( + cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, bufsize=1 + ) + try: + assert proc.stdout is not None + for line in proc.stdout: + line = line.strip() + if should_cancel(): + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + raise EditAborted + secs = _parse_out_time(line) + if secs is not None and total_s: + on_progress(max(0.0, min(99.0, secs * 100.0 / total_s))) + finally: + if proc.stdout: + proc.stdout.close() + ret = proc.wait() + if ret != 0: + err = (proc.stderr.read() if proc.stderr else "") or "" + raise RuntimeError(f"ffmpeg failed ({ret}): {err[:300]}") + + +# --- filmstrip (editor scrub track) --------------------------------------------------------- + +_SB_COLS = 12 +_SB_TILE_W = 160 + + +def storyboard_geometry(duration_s: int | None) -> dict: + """Deterministic sprite geometry for a source duration (so meta is recomputable, unstored). + + ~1 frame / 5 s, clamped to fill a 12-wide grid of 24–120 tiles.""" + dur = max(1, int(duration_s or 1)) + count = max(24, min(120, round(dur / 5))) + rows = math.ceil(count / _SB_COLS) + count = _SB_COLS * rows # fill the grid exactly + return { + "cols": _SB_COLS, + "rows": rows, + "count": count, + "interval_s": dur / count, + "tile_w": _SB_TILE_W, + } + + +def build_storyboard_cmd(src: Path, dest: Path, duration_s: int | None, geom: dict) -> list[str]: + """One-pass tiled sprite. fps picks `count` frames evenly across the whole clip.""" + dur = max(1, int(duration_s or 1)) + fps = geom["count"] / dur + vf = f"fps={fps:.6f},scale={_SB_TILE_W}:-2,tile={geom['cols']}x{geom['rows']}" + return [ + "ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-nostdin", + "-i", str(src), "-frames:v", "1", "-q:v", "5", "-vf", vf, str(dest), + ] + + +def ensure_storyboard(asset, download_root: str, timeout_s: int = 90) -> dict | None: + """Return the filmstrip meta for a ready source asset, generating the sprite on first use. + + Best-effort + time-bounded: for a very long/high-res source the one-pass decode can be slow, + so it's capped by `timeout_s`; on timeout/failure we return None and the editor falls back to + a plain timeline (no filmstrip). Caches the sprite path on `asset.storyboard_path`. + + Returns None (caller should NOT commit) if generation didn't happen; otherwise a meta dict and + sets `asset.storyboard_path` (caller commits).""" + if not asset.rel_path: + return None + geom = storyboard_geometry(asset.duration_s) + root = Path(download_root) + rel = f".storyboards/asset-{asset.id}.jpg" + dest = root / rel + + if asset.storyboard_path and (root / asset.storyboard_path).exists(): + return {**geom, "rel": asset.storyboard_path} + + src = root / asset.rel_path + if not src.exists(): + return None + dest.parent.mkdir(parents=True, exist_ok=True) + cmd = build_storyboard_cmd(src, dest, asset.duration_s, geom) + try: + subprocess.run(cmd, capture_output=True, timeout=timeout_s, check=True) + except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError) as exc: + log.info("storyboard gen skipped for asset %s: %s", asset.id, str(exc)[:120]) + return None + if not dest.exists(): + return None + asset.storyboard_path = rel # caller commits + return {**geom, "rel": rel} diff --git a/backend/app/downloads/links.py b/backend/app/downloads/links.py new file mode 100644 index 0000000..896af76 --- /dev/null +++ b/backend/app/downloads/links.py @@ -0,0 +1,81 @@ +"""Public "share by link" helpers for the download center. + +A DownloadLink is a capability URL: the unguessable `token` grants anyone read access to one +download's file on the login-free `/watch/{token}` page. Password-protected links additionally +require a short-lived signed **grant** on the file request — because a `