merge: Download Center editor + share + v0.22.0 release prep
Phase 2 video editor (trim/split/crop/join, cut-list, filmstrip), public share links + user picker + watch page, shared-item edit/remove, live storage bar, UI cosmetics (favicon, dynamic tab title, clickable logo, styled module headers), and the v0.22.0 release prep (compose worker/ sidecar + docs).
This commit is contained in:
commit
fe822598fa
46 changed files with 2824 additions and 110 deletions
|
|
@ -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 <dir>.
|
||||
# DOWNLOAD_HOST_PATH=/mnt/media/youtube
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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**.
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.21.0
|
||||
0.22.0
|
||||
|
|
|
|||
66
backend/alembic/versions/0041_download_edit.py
Normal file
66
backend/alembic/versions/0041_download_edit.py
Normal file
|
|
@ -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")
|
||||
45
backend/alembic/versions/0042_download_links.py
Normal file
45
backend/alembic/versions/0042_download_links.py
Normal file
|
|
@ -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")
|
||||
333
backend/app/downloads/edit.py
Normal file
333
backend/app/downloads/edit.py
Normal file
|
|
@ -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}
|
||||
81
backend/app/downloads/links.py
Normal file
81
backend/app/downloads/links.py
Normal file
|
|
@ -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 `<video src>` can't send a
|
||||
header, the password is exchanged once at `/unlock` for an HMAC grant appended to the file URL,
|
||||
so the raw password never rides in the media URL (or server logs).
|
||||
"""
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.config import settings
|
||||
from app.models import DownloadLink
|
||||
|
||||
_GRANT_TTL = 6 * 3600 # a watch session's grant is good for 6h, then the viewer re-unlocks
|
||||
|
||||
|
||||
def new_token() -> str:
|
||||
return secrets.token_urlsafe(24) # ~32 chars, 192 bits
|
||||
|
||||
|
||||
def is_expired(link: DownloadLink) -> bool:
|
||||
return link.expires_at is not None and link.expires_at <= datetime.now(timezone.utc)
|
||||
|
||||
|
||||
# --- signed grants (password-protected file access) ----------------------------------------
|
||||
|
||||
def _sign(msg: str) -> str:
|
||||
return hmac.new(
|
||||
settings.secret_key.encode(), msg.encode(), hashlib.sha256
|
||||
).hexdigest()[:32]
|
||||
|
||||
|
||||
def make_grant(token: str, now: float | None = None) -> str:
|
||||
"""A short-lived HMAC grant proving the viewer unlocked `token`. Format: `<exp>.<sig>`."""
|
||||
exp = int((now if now is not None else datetime.now(timezone.utc).timestamp())) + _GRANT_TTL
|
||||
return f"{exp}.{_sign(f'{token}.{exp}')}"
|
||||
|
||||
|
||||
def check_grant(token: str, grant: str | None) -> bool:
|
||||
if not grant or "." not in grant:
|
||||
return False
|
||||
exp_s, sig = grant.split(".", 1)
|
||||
try:
|
||||
exp = int(exp_s)
|
||||
except ValueError:
|
||||
return False
|
||||
if exp < datetime.now(timezone.utc).timestamp():
|
||||
return False
|
||||
return hmac.compare_digest(sig, _sign(f"{token}.{exp}"))
|
||||
|
||||
|
||||
# --- serialization -------------------------------------------------------------------------
|
||||
|
||||
def owner_view(link: DownloadLink, base_url: str = "") -> dict:
|
||||
"""What the file owner sees when managing their links."""
|
||||
return {
|
||||
"id": link.id,
|
||||
"token": link.token,
|
||||
"url": f"{base_url}/watch/{link.token}",
|
||||
"allow_download": link.allow_download,
|
||||
"has_password": link.password_hash is not None,
|
||||
"expires_at": link.expires_at.isoformat() if link.expires_at else None,
|
||||
"view_count": link.view_count,
|
||||
"created_at": link.created_at.isoformat() if link.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def public_meta(link: DownloadLink, asset, file_url: str) -> dict:
|
||||
"""Metadata for the public watch page (only ever returned once access is authorized)."""
|
||||
return {
|
||||
"title": asset.title,
|
||||
"uploader": asset.uploader,
|
||||
"duration_s": asset.duration_s,
|
||||
"width": asset.width,
|
||||
"height": asset.height,
|
||||
"allow_download": link.allow_download,
|
||||
"file_url": file_url,
|
||||
}
|
||||
|
|
@ -12,10 +12,19 @@ from sqlalchemy import func, select
|
|||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.downloads import edit as editmod
|
||||
from app.downloads import formats, quota
|
||||
from app.models import Channel, DownloadJob, MediaAsset, Video
|
||||
|
||||
|
||||
class EditError(Exception):
|
||||
"""Bad edit request (empty spec / source not ready); `reason` is an i18n-mappable key."""
|
||||
|
||||
def __init__(self, reason: str):
|
||||
self.reason = reason
|
||||
super().__init__(reason)
|
||||
|
||||
|
||||
def source_url(source_kind: str, source_ref: str) -> str:
|
||||
"""The URL yt-dlp should fetch. YouTube stores the 11-char id; ad-hoc stores the URL."""
|
||||
if source_kind == "youtube":
|
||||
|
|
@ -121,3 +130,71 @@ def enqueue(
|
|||
db.commit()
|
||||
db.refresh(job)
|
||||
return job
|
||||
|
||||
|
||||
def _clip_title(source_title: str | None, display_name: str | None) -> str:
|
||||
if display_name:
|
||||
return display_name[:255]
|
||||
base = source_title or "Clip"
|
||||
return f"{base} (clip)"[:255]
|
||||
|
||||
|
||||
def enqueue_edit(
|
||||
db: Session,
|
||||
user_id: int,
|
||||
source_job: DownloadJob,
|
||||
edit_spec: dict,
|
||||
display_name: str | None = None,
|
||||
) -> DownloadJob:
|
||||
"""Queue a phase-2 editor derivative (trim/crop) of `source_job` for `user_id`.
|
||||
|
||||
The derivative is per-user (never shared-cached): its asset uses `source_kind='edit'` with a
|
||||
cache key that embeds the user id, so an identical re-edit by the same user is a cache hit but
|
||||
it never dedups across users. Reuses the same worker/quota/GC path as a download.
|
||||
|
||||
Raises quota.QuotaExceeded (holdings cap) or EditError (empty spec / source not ready)."""
|
||||
src_asset = db.get(MediaAsset, source_job.asset_id) if source_job.asset_id else None
|
||||
if src_asset is None or src_asset.status != "ready" or not src_asset.rel_path:
|
||||
raise EditError("source_not_ready")
|
||||
|
||||
spec = editmod.normalize_edit_spec(edit_spec)
|
||||
if not spec:
|
||||
raise EditError("empty_edit")
|
||||
|
||||
quota.check_enqueue(db, user_id)
|
||||
|
||||
sig = editmod.edit_sig(spec)
|
||||
ref = f"{user_id}:{src_asset.id}"[:512]
|
||||
asset = get_or_create_asset(db, "edit", ref, sig)
|
||||
if not asset.title:
|
||||
asset.title = _clip_title(src_asset.title, display_name)
|
||||
asset.uploader = src_asset.uploader
|
||||
asset.thumbnail_url = src_asset.thumbnail_url
|
||||
asset.upload_date = src_asset.upload_date
|
||||
asset.duration_s = editmod.clip_duration(spec, src_asset.duration_s)
|
||||
|
||||
job = DownloadJob(
|
||||
user_id=user_id,
|
||||
asset_id=asset.id,
|
||||
job_kind="edit",
|
||||
source_kind="edit",
|
||||
source_ref=ref,
|
||||
source_job_id=source_job.id,
|
||||
source_asset_id=src_asset.id,
|
||||
edit_spec=spec,
|
||||
profile_snapshot={},
|
||||
display_name=display_name or asset.title,
|
||||
status="queued",
|
||||
queue_pos=_next_queue_pos(db),
|
||||
)
|
||||
db.add(job)
|
||||
asset.ref_count = (asset.ref_count or 0) + 1
|
||||
|
||||
if asset.status == "ready": # identical clip already produced for this user → instant
|
||||
job.status = "done"
|
||||
job.progress = 100
|
||||
asset.last_access_at = datetime.now(timezone.utc)
|
||||
|
||||
db.commit()
|
||||
db.refresh(job)
|
||||
return job
|
||||
|
|
|
|||
|
|
@ -87,6 +87,15 @@ def rel_path(meta: MediaMeta, ext: str, layout: str = "plex") -> str:
|
|||
return f"{channel}/Season_{year}/{epname}"
|
||||
|
||||
|
||||
def edit_rel_path(user_id: int, asset_id: int, ext: str) -> str:
|
||||
"""On-disk path (relative to DOWNLOAD_ROOT) for a phase-2 editor derivative.
|
||||
|
||||
Kept in a per-user `.edits/` tree — device-downloadable via the file endpoint, but NOT part of
|
||||
the Plex library (these are user clips, not canonical episodes; there are no .nfo/poster
|
||||
sidecars). The name is system-decided and bound to the derivative asset id."""
|
||||
return f".edits/{user_id}/clip_{asset_id}.{ext}"
|
||||
|
||||
|
||||
def abs_path(download_root: str, rel: str) -> Path:
|
||||
return Path(download_root) / rel
|
||||
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ from app.routes import (
|
|||
messages,
|
||||
notifications,
|
||||
playlists,
|
||||
public as public_routes,
|
||||
quota,
|
||||
saved_views,
|
||||
scheduler as scheduler_routes,
|
||||
|
|
@ -125,6 +126,7 @@ app.include_router(playlists.router)
|
|||
app.include_router(saved_views.router)
|
||||
app.include_router(downloads.router)
|
||||
app.include_router(downloads.admin_router)
|
||||
app.include_router(public_routes.router)
|
||||
app.include_router(admin.router)
|
||||
app.include_router(config_routes.router)
|
||||
app.include_router(scheduler_routes.router)
|
||||
|
|
|
|||
|
|
@ -779,7 +779,13 @@ class DownloadJob(Base, TimestampMixin, UpdatedAtMixin):
|
|||
freezes the spec at enqueue so later edits/deletion of the profile don't change this job.
|
||||
`display_name` is the user's own name for the file (display + the Content-Disposition on
|
||||
local download); the on-disk asset is never renamed. `asset_id` links to the shared file
|
||||
(SET NULL if the asset is GC'd — the job then shows as expired)."""
|
||||
(SET NULL if the asset is GC'd — the job then shows as expired).
|
||||
|
||||
Phase 2 (editor): `job_kind='edit'` jobs derive a new per-user clip from an already-downloaded
|
||||
ready job. They still hang off a `MediaAsset` (with `source_kind='edit'`, a per-user cache key)
|
||||
so all the file-serve/ref-count/GC/quota machinery is reused; the worker branches on the
|
||||
asset's `source_kind` and runs ffmpeg instead of yt-dlp. `source_job_id`/`source_asset_id`
|
||||
point at the parent download the clip was cut from; `edit_spec` is the (trim/crop) recipe."""
|
||||
|
||||
__tablename__ = "download_jobs"
|
||||
__table_args__ = (Index("ix_download_jobs_claim", "status", "queue_pos"),)
|
||||
|
|
@ -793,6 +799,19 @@ class DownloadJob(Base, TimestampMixin, UpdatedAtMixin):
|
|||
)
|
||||
source_kind: Mapped[str] = mapped_column(String(16)) # "youtube" | "url"
|
||||
source_ref: Mapped[str] = mapped_column(String(512))
|
||||
# "download" (default) or "edit" (a per-user trim/crop derivative of another job).
|
||||
job_kind: Mapped[str] = mapped_column(
|
||||
String(16), default="download", server_default="download"
|
||||
)
|
||||
# For edit jobs: the parent download this clip is cut from (SET NULL if the parent is deleted).
|
||||
source_job_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("download_jobs.id", ondelete="SET NULL")
|
||||
)
|
||||
source_asset_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("media_assets.id", ondelete="SET NULL")
|
||||
)
|
||||
# For edit jobs: the trim/crop recipe {trim?:{start_s,end_s}, crop?:{x,y,w,h}}.
|
||||
edit_spec: Mapped[dict | None] = mapped_column(JSON)
|
||||
profile_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("download_profiles.id", ondelete="SET NULL")
|
||||
)
|
||||
|
|
@ -848,6 +867,33 @@ class DownloadQuota(Base, UpdatedAtMixin):
|
|||
)
|
||||
|
||||
|
||||
class DownloadLink(Base, TimestampMixin):
|
||||
"""A public capability link to one download — the "share by link" surface.
|
||||
|
||||
Anyone holding the unguessable `token` can watch the file on the login-free `/watch/{token}`
|
||||
player page (Google-Drive style). Optional per-link controls: `expires_at`, `allow_download`
|
||||
(stream-only vs downloadable), and a `password_hash` (argon2, like a user password). Revoke =
|
||||
delete the row. Distinct from `DownloadShare`, which grants a *registered* user access via the
|
||||
in-app "Shared with me" list."""
|
||||
|
||||
__tablename__ = "download_links"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
job_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("download_jobs.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
token: Mapped[str] = mapped_column(String(64), unique=True)
|
||||
created_by: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE")
|
||||
)
|
||||
allow_download: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, server_default="false"
|
||||
)
|
||||
password_hash: Mapped[str | None] = mapped_column(Text)
|
||||
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
view_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||
|
||||
|
||||
class MessageKey(Base, TimestampMixin):
|
||||
"""A user's end-to-end-encryption key material for direct messaging (phase 2).
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ worker is a separate process); this module never downloads — it enqueues, mana
|
|||
serves finished files (range-aware, with the user's custom display name), and shares them.
|
||||
"""
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
|
|
@ -18,15 +18,19 @@ from sqlalchemy.orm import Session
|
|||
from app.auth import admin_user, require_human
|
||||
from app.config import settings
|
||||
from app.db import get_db
|
||||
from app.downloads import edit as editmod
|
||||
from app.downloads import links as linksmod
|
||||
from app.downloads import quota, service, storage
|
||||
from app.models import (
|
||||
DownloadJob,
|
||||
DownloadLink,
|
||||
DownloadProfile,
|
||||
DownloadQuota,
|
||||
DownloadShare,
|
||||
MediaAsset,
|
||||
User,
|
||||
)
|
||||
from app.security import hash_password
|
||||
|
||||
router = APIRouter(prefix="/api/downloads", tags=["downloads"])
|
||||
admin_router = APIRouter(prefix="/api/admin/downloads", tags=["admin-downloads"])
|
||||
|
|
@ -81,6 +85,9 @@ def _serialize(job: DownloadJob, asset: MediaAsset | None) -> dict:
|
|||
"profile_id": job.profile_id,
|
||||
"spec": job.profile_snapshot,
|
||||
"display_name": job.display_name,
|
||||
"job_kind": job.job_kind,
|
||||
"source_job_id": job.source_job_id,
|
||||
"edit_spec": job.edit_spec,
|
||||
"can_download": ready and job.status == "done",
|
||||
"expired": job.asset_id is None and job.status == "done",
|
||||
"asset": None
|
||||
|
|
@ -247,6 +254,40 @@ def enqueue_download(
|
|||
return _serialize(job, asset)
|
||||
|
||||
|
||||
@router.post("/edit")
|
||||
def enqueue_edit(
|
||||
payload: dict, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
"""Queue a phase-2 editor derivative (trim/crop) of a download this user can access.
|
||||
|
||||
The source may be the user's own download OR one shared with them — either way the resulting
|
||||
clip is a NEW per-user derivative in the editor's own library (counts against their quota); the
|
||||
source file is only read, never modified, so editing a shared video is safe."""
|
||||
source_job = _accessible_job(db, user, int(payload.get("source_job_id") or 0))
|
||||
if source_job.status != "done" or source_job.asset_id is None:
|
||||
raise HTTPException(status_code=409, detail="You can only edit a finished download.")
|
||||
spec = payload.get("edit_spec")
|
||||
if not isinstance(spec, dict):
|
||||
raise HTTPException(status_code=400, detail="edit_spec must be an object")
|
||||
display_name = (payload.get("display_name") or "").strip() or None
|
||||
try:
|
||||
job = service.enqueue_edit(db, user.id, source_job, spec, display_name)
|
||||
except quota.QuotaExceeded as e:
|
||||
raise HTTPException(status_code=422, detail=_quota_message(e))
|
||||
except service.EditError as 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, asset)
|
||||
|
||||
|
||||
def _edit_message(e: service.EditError) -> str:
|
||||
if e.reason == "empty_edit":
|
||||
return "Choose a trim range or crop area first."
|
||||
if e.reason == "source_not_ready":
|
||||
return "The source download isn't ready to edit."
|
||||
return "That edit couldn't be created."
|
||||
|
||||
|
||||
def _quota_message(e: quota.QuotaExceeded) -> str:
|
||||
if e.reason == "max_jobs":
|
||||
return f"You've reached your download limit ({e.limit} items). Remove some first."
|
||||
|
|
@ -470,6 +511,49 @@ def download_file(
|
|||
return FileResponse(path, filename=filename)
|
||||
|
||||
|
||||
# --- editor filmstrip (scrub track) --------------------------------------------------------
|
||||
|
||||
@router.get("/{job_id}/storyboard")
|
||||
def storyboard_meta(
|
||||
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
"""Filmstrip geometry for the editor's scrub track, generating the sprite on first use.
|
||||
|
||||
Best-effort: for a very long/high-res source the sprite may not generate in time — then
|
||||
`available` is False and the editor shows a plain timeline."""
|
||||
job = _accessible_job(db, user, job_id)
|
||||
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:
|
||||
raise HTTPException(status_code=409, detail="This download isn't ready.")
|
||||
meta = editmod.ensure_storyboard(asset, settings.download_root)
|
||||
if meta is None:
|
||||
return {"available": False}
|
||||
db.commit() # persist storyboard_path if it was just generated
|
||||
return {
|
||||
"available": True,
|
||||
"cols": meta["cols"],
|
||||
"rows": meta["rows"],
|
||||
"count": meta["count"],
|
||||
"interval_s": meta["interval_s"],
|
||||
"url": f"/api/downloads/{job_id}/storyboard.jpg",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{job_id}/storyboard.jpg")
|
||||
def storyboard_image(
|
||||
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||
):
|
||||
job = _accessible_job(db, user, job_id)
|
||||
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
|
||||
if asset is None or not asset.storyboard_path:
|
||||
raise HTTPException(status_code=404, detail="No filmstrip.")
|
||||
path = storage.abs_path(settings.download_root, asset.storyboard_path).resolve()
|
||||
root = Path(settings.download_root).resolve()
|
||||
if root not in path.parents or not path.exists():
|
||||
raise HTTPException(status_code=404, detail="No filmstrip.")
|
||||
return FileResponse(path, media_type="image/jpeg")
|
||||
|
||||
|
||||
# --- sharing -------------------------------------------------------------------------------
|
||||
|
||||
@router.post("/{job_id}/share")
|
||||
|
|
@ -539,6 +623,137 @@ def unshare_download(
|
|||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete("/shared/{job_id}")
|
||||
def remove_shared_with_me(
|
||||
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
"""Remove a download that was shared WITH this user from their 'Shared with me' list. Deletes
|
||||
only the recipient's share grant — the owner's job and the physical file are untouched (this is
|
||||
a per-user dismissal, not a delete)."""
|
||||
row = db.execute(
|
||||
select(DownloadShare).where(
|
||||
DownloadShare.job_id == job_id, DownloadShare.to_user_id == user.id
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if row is not None:
|
||||
db.delete(row)
|
||||
db.commit()
|
||||
return {"removed": job_id}
|
||||
|
||||
|
||||
# --- recipients (for the internal "share with a user" picker) ------------------------------
|
||||
|
||||
@router.get("/recipients")
|
||||
def share_recipients(
|
||||
user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||
) -> list[dict]:
|
||||
"""Registered users this user can share a download with (excludes self + the demo account)."""
|
||||
rows = (
|
||||
db.execute(
|
||||
select(User)
|
||||
.where(User.id != user.id, User.is_demo.is_(False))
|
||||
.order_by(func.lower(User.email))
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
return [{"id": u.id, "email": u.email, "display_name": u.display_name} for u in rows]
|
||||
|
||||
|
||||
# --- public watch links (share by link) ----------------------------------------------------
|
||||
|
||||
def _own_link(db: Session, user: User, link_id: int) -> DownloadLink:
|
||||
link = db.get(DownloadLink, link_id)
|
||||
if link is None:
|
||||
raise HTTPException(status_code=404, detail="Unknown link")
|
||||
job = db.get(DownloadJob, link.job_id)
|
||||
if job is None or job.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="Unknown link")
|
||||
return link
|
||||
|
||||
|
||||
def _link_expiry(payload: dict) -> datetime | None:
|
||||
days = payload.get("expires_days")
|
||||
if days in (None, "", 0, "0"):
|
||||
return None
|
||||
try:
|
||||
d = int(days)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(status_code=400, detail="expires_days must be a number")
|
||||
if d <= 0:
|
||||
return None
|
||||
return datetime.now(timezone.utc) + timedelta(days=min(d, 3650))
|
||||
|
||||
|
||||
@router.get("/{job_id}/links")
|
||||
def list_links(
|
||||
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||
) -> list[dict]:
|
||||
_own_job(db, user, job_id)
|
||||
rows = (
|
||||
db.execute(
|
||||
select(DownloadLink).where(DownloadLink.job_id == job_id).order_by(DownloadLink.id.desc())
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
return [linksmod.owner_view(l) for l in rows]
|
||||
|
||||
|
||||
@router.post("/{job_id}/links")
|
||||
def create_link(
|
||||
job_id: int,
|
||||
payload: dict,
|
||||
user: User = Depends(require_human),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
job = _own_job(db, user, job_id)
|
||||
if job.status != "done" or job.asset_id is None:
|
||||
raise HTTPException(status_code=409, detail="Only a finished download can be shared.")
|
||||
pw = (payload.get("password") or "").strip()
|
||||
link = DownloadLink(
|
||||
job_id=job_id,
|
||||
token=linksmod.new_token(),
|
||||
created_by=user.id,
|
||||
allow_download=bool(payload.get("allow_download")),
|
||||
password_hash=hash_password(pw) if pw else None,
|
||||
expires_at=_link_expiry(payload),
|
||||
)
|
||||
db.add(link)
|
||||
db.commit()
|
||||
db.refresh(link)
|
||||
return linksmod.owner_view(link)
|
||||
|
||||
|
||||
@router.patch("/links/{link_id}")
|
||||
def update_link(
|
||||
link_id: int,
|
||||
payload: dict,
|
||||
user: User = Depends(require_human),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
link = _own_link(db, user, link_id)
|
||||
if "allow_download" in payload:
|
||||
link.allow_download = bool(payload["allow_download"])
|
||||
if "expires_days" in payload:
|
||||
link.expires_at = _link_expiry(payload)
|
||||
if "password" in payload:
|
||||
pw = (payload.get("password") or "").strip()
|
||||
link.password_hash = hash_password(pw) if pw else None
|
||||
db.commit()
|
||||
return linksmod.owner_view(link)
|
||||
|
||||
|
||||
@router.delete("/links/{link_id}")
|
||||
def revoke_link(
|
||||
link_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
link = _own_link(db, user, link_id)
|
||||
db.delete(link)
|
||||
db.commit()
|
||||
return {"deleted": link_id}
|
||||
|
||||
|
||||
# --- admin ---------------------------------------------------------------------------------
|
||||
|
||||
@admin_router.get("")
|
||||
|
|
|
|||
109
backend/app/routes/public.py
Normal file
109
backend/app/routes/public.py
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
"""Public, login-free surface for shared download links (the `/watch/{token}` player page).
|
||||
|
||||
These routes are deliberately OUTSIDE the auth/`require_human` gate — the unguessable token IS the
|
||||
credential (a capability URL). They only ever expose one file's stream + minimal metadata, never
|
||||
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).
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.db import get_db
|
||||
from app.downloads import links as linksmod
|
||||
from app.downloads import storage
|
||||
from app.models import DownloadJob, DownloadLink, MediaAsset
|
||||
from app.ratelimit import RateLimiter
|
||||
from app.security import verify_password
|
||||
|
||||
router = APIRouter(prefix="/api/public", tags=["public"])
|
||||
|
||||
# Slow password guessing per link (the token itself is 192-bit, so it needs no throttle).
|
||||
_unlock_limit = RateLimiter(max_events=10, window_seconds=300)
|
||||
|
||||
|
||||
def _link_or_404(db: Session, token: str) -> DownloadLink:
|
||||
link = db.execute(
|
||||
select(DownloadLink).where(DownloadLink.token == token)
|
||||
).scalar_one_or_none()
|
||||
if link is None or linksmod.is_expired(link):
|
||||
raise HTTPException(status_code=404, detail="This link is no longer available.")
|
||||
return link
|
||||
|
||||
|
||||
def _ready_asset(db: Session, link: DownloadLink) -> MediaAsset:
|
||||
job = db.get(DownloadJob, link.job_id)
|
||||
asset = db.get(MediaAsset, job.asset_id) if job and job.asset_id else None
|
||||
if asset is None or asset.status != "ready" or not asset.rel_path:
|
||||
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
|
||||
# loads a player for a missing file — meta and file agree.
|
||||
path = storage.abs_path(settings.download_root, asset.rel_path).resolve()
|
||||
root = Path(settings.download_root).resolve()
|
||||
if root not in path.parents or not path.exists():
|
||||
raise HTTPException(status_code=404, detail="This video is no longer available.")
|
||||
return asset
|
||||
|
||||
|
||||
def _file_url(token: str, grant: str | None = None) -> str:
|
||||
base = f"/api/public/watch/{token}/file"
|
||||
return f"{base}?g={grant}" if grant else base
|
||||
|
||||
|
||||
@router.get("/watch/{token}")
|
||||
def watch_meta(token: str, db: Session = Depends(get_db)) -> dict:
|
||||
"""Public metadata for the watch page. For a password link, returns only `needs_password`
|
||||
until the viewer unlocks it (no title/thumbnail leak)."""
|
||||
link = _link_or_404(db, token)
|
||||
if link.password_hash:
|
||||
return {"needs_password": True}
|
||||
asset = _ready_asset(db, link)
|
||||
link.view_count += 1
|
||||
db.commit()
|
||||
return {"needs_password": False, **linksmod.public_meta(link, asset, _file_url(token))}
|
||||
|
||||
|
||||
@router.post("/watch/{token}/unlock")
|
||||
def watch_unlock(token: str, payload: dict, db: Session = Depends(get_db)) -> dict:
|
||||
"""Exchange the link password for a signed grant carried by the media URL."""
|
||||
if not _unlock_limit.allow(token):
|
||||
raise HTTPException(status_code=429, detail="Too many attempts. Try again later.")
|
||||
link = _link_or_404(db, token)
|
||||
if not link.password_hash:
|
||||
# Not a password link — nothing to unlock; just hand back the plain meta.
|
||||
asset = _ready_asset(db, link)
|
||||
return {"needs_password": False, **linksmod.public_meta(link, asset, _file_url(token))}
|
||||
if not verify_password((payload.get("password") or ""), link.password_hash):
|
||||
raise HTTPException(status_code=403, detail="Wrong password.")
|
||||
asset = _ready_asset(db, link)
|
||||
link.view_count += 1
|
||||
db.commit()
|
||||
grant = linksmod.make_grant(token)
|
||||
return {"needs_password": False, **linksmod.public_meta(link, asset, _file_url(token, grant))}
|
||||
|
||||
|
||||
@router.get("/watch/{token}/file")
|
||||
def watch_file(token: str, g: str | None = None, db: Session = Depends(get_db)):
|
||||
"""Range-aware stream of the shared file. Inline by default (plays in the page); an
|
||||
`allow_download` link serves it as an attachment. Password links require a valid grant."""
|
||||
link = _link_or_404(db, token)
|
||||
if link.password_hash and not linksmod.check_grant(token, g):
|
||||
raise HTTPException(status_code=403, detail="This link needs a password.")
|
||||
asset = _ready_asset(db, link)
|
||||
path = storage.abs_path(settings.download_root, asset.rel_path).resolve()
|
||||
root = Path(settings.download_root).resolve()
|
||||
if root not in path.parents or not path.exists():
|
||||
raise HTTPException(status_code=404, detail="This video is no longer available.")
|
||||
|
||||
ext = asset.container or path.suffix.lstrip(".")
|
||||
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"
|
||||
# Starlette's FileResponse honours the Range header (206) for seeking/scrubbing.
|
||||
return FileResponse(path, filename=filename, content_disposition_type=disposition)
|
||||
|
|
@ -28,6 +28,7 @@ from sqlalchemy import text
|
|||
|
||||
from app.config import settings
|
||||
from app.db import SessionLocal
|
||||
from app.downloads import edit as editmod
|
||||
from app.downloads import formats, quota, service, storage
|
||||
from app.models import DownloadJob, MediaAsset
|
||||
from app.titles import normalize_title
|
||||
|
|
@ -141,6 +142,7 @@ def _process_job(job_id: int) -> None:
|
|||
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
|
||||
spec = dict(job.profile_snapshot or {})
|
||||
source_kind, source_ref = job.source_kind, job.source_ref
|
||||
job_kind = job.job_kind
|
||||
asset_id = asset.id if asset else None
|
||||
asset_status = asset.status if asset else None
|
||||
|
||||
|
|
@ -160,13 +162,16 @@ def _process_job(job_id: int) -> None:
|
|||
time.sleep(2)
|
||||
return
|
||||
|
||||
# asset_status == "pending": try to win the download.
|
||||
# asset_status == "pending": try to win the download / edit.
|
||||
if not _claim_asset(asset_id):
|
||||
_set_job(job_id, status="queued", phase="waiting")
|
||||
time.sleep(2)
|
||||
return
|
||||
|
||||
_download(job_id, asset_id, source_kind, source_ref, spec)
|
||||
if job_kind == "edit":
|
||||
_process_edit(job_id, asset_id)
|
||||
else:
|
||||
_download(job_id, asset_id, source_kind, source_ref, spec)
|
||||
|
||||
|
||||
def _finish_from_cache(job_id: int, asset_id: int) -> None:
|
||||
|
|
@ -431,6 +436,114 @@ def _reset_asset_pending(asset_id: int) -> None:
|
|||
db.commit()
|
||||
|
||||
|
||||
# --- editor derivatives (phase 2: trim/crop via ffmpeg) -------------------------------------
|
||||
|
||||
def _edit_staging_dir(asset_id: int) -> Path:
|
||||
return Path(settings.download_root) / ".staging" / f"edit-{asset_id}"
|
||||
|
||||
|
||||
def _fail_edit(job_id: int, asset_id: int, msg: str) -> None:
|
||||
with SessionLocal() as db:
|
||||
asset = db.get(MediaAsset, asset_id)
|
||||
if asset:
|
||||
asset.status = "error"
|
||||
asset.error = msg
|
||||
job = db.get(DownloadJob, job_id)
|
||||
if job:
|
||||
job.status = "error"
|
||||
job.error = msg
|
||||
job.phase = None
|
||||
db.commit()
|
||||
log.warning("edit job %s failed: %s", job_id, msg)
|
||||
|
||||
|
||||
def _process_edit(job_id: int, asset_id: int) -> None:
|
||||
"""Produce a per-user trim/crop clip from the source asset's downloaded file via ffmpeg."""
|
||||
with SessionLocal() as db:
|
||||
job = db.get(DownloadJob, job_id)
|
||||
src = db.get(MediaAsset, job.source_asset_id) if job and job.source_asset_id else None
|
||||
edit_spec = dict(job.edit_spec or {}) if job else {}
|
||||
user_id = job.user_id if job else None
|
||||
src_ready = src is not None and src.status == "ready" and bool(src.rel_path)
|
||||
src_rel = src.rel_path if src else None
|
||||
src_w, src_h, src_dur = (src.width, src.height, src.duration_s) if src else (None, None, None)
|
||||
|
||||
if not src_ready or not src_rel:
|
||||
_fail_edit(job_id, asset_id, "The source download is no longer available.")
|
||||
return
|
||||
src_path = storage.abs_path(settings.download_root, src_rel)
|
||||
if not src_path.exists():
|
||||
_fail_edit(job_id, asset_id, "The source file is missing.")
|
||||
return
|
||||
|
||||
src_ext = src_path.suffix.lstrip(".").lower() or "mp4"
|
||||
crop = editmod.has_crop(edit_spec)
|
||||
out_ext = "mp4" if editmod.needs_reencode(edit_spec) else src_ext
|
||||
total_s = editmod.clip_duration(edit_spec, src_dur) or src_dur or 0
|
||||
staging = _edit_staging_dir(asset_id)
|
||||
|
||||
try:
|
||||
if staging.exists():
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
staging.mkdir(parents=True, exist_ok=True)
|
||||
dest_staging = staging / f"out.{out_ext}"
|
||||
|
||||
_set_job(job_id, phase="editing", progress=0, speed_bps=None, eta_s=None)
|
||||
if edit_spec.get("segments"):
|
||||
cmd, prep = editmod.build_concat_plan(src_path, dest_staging, edit_spec, out_ext, staging)
|
||||
for p, content in prep.items():
|
||||
p.write_text(content, encoding="utf-8")
|
||||
else:
|
||||
cmd = editmod.build_edit_cmd(src_path, dest_staging, edit_spec, out_ext)
|
||||
editmod.run_ffmpeg(
|
||||
cmd,
|
||||
total_s,
|
||||
on_progress=lambda pct: _set_job(job_id, progress=int(pct), phase="editing"),
|
||||
should_cancel=lambda: _job_status(job_id) not in ("running", None),
|
||||
)
|
||||
if not dest_staging.exists():
|
||||
raise RuntimeError("ffmpeg produced no output file")
|
||||
|
||||
rel = storage.edit_rel_path(user_id, asset_id, out_ext)
|
||||
storage.place_file(dest_staging, settings.download_root, rel)
|
||||
final = storage.abs_path(settings.download_root, rel)
|
||||
size = final.stat().st_size if final.exists() else None
|
||||
expires = datetime.now(timezone.utc) + timedelta(days=settings.download_retention_days)
|
||||
|
||||
with SessionLocal() as db:
|
||||
asset = db.get(MediaAsset, asset_id)
|
||||
if asset:
|
||||
asset.status = "ready"
|
||||
asset.rel_path = rel
|
||||
asset.size_bytes = size
|
||||
asset.container = out_ext
|
||||
asset.duration_s = total_s or asset.duration_s
|
||||
if crop:
|
||||
asset.width = edit_spec["crop"]["w"]
|
||||
asset.height = edit_spec["crop"]["h"]
|
||||
else:
|
||||
asset.width, asset.height = src_w, src_h
|
||||
asset.error = None
|
||||
asset.last_access_at = datetime.now(timezone.utc)
|
||||
asset.expires_at = expires
|
||||
job = db.get(DownloadJob, job_id)
|
||||
if job:
|
||||
job.status = "done"
|
||||
job.progress = 100
|
||||
job.phase = None
|
||||
db.commit()
|
||||
log.info("edit job %s done: %s", job_id, rel)
|
||||
|
||||
except editmod.EditAborted:
|
||||
# Paused/canceled mid-encode: free the asset for a later retry (ref_count is the route's job).
|
||||
_reset_asset_pending(asset_id)
|
||||
log.info("edit job %s aborted (pause/cancel)", job_id)
|
||||
except Exception as exc: # noqa: BLE001 — surface the failure, keep the worker alive
|
||||
_fail_edit(job_id, asset_id, str(exc)[:500])
|
||||
finally:
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
|
||||
|
||||
# --- loop -----------------------------------------------------------------------------------
|
||||
|
||||
def _worker_loop(idx: int) -> None:
|
||||
|
|
|
|||
|
|
@ -37,6 +37,10 @@ services:
|
|||
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-siftlode}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-siftlode}
|
||||
# This instance owns the background scheduler (single writer).
|
||||
SCHEDULER_ENABLED: "true"
|
||||
# Download center: the API serves finished files + builds filmstrips; the worker (below) runs
|
||||
# the yt-dlp job loop. Read-only root is fine — it only writes to the /downloads mount + tmpfs.
|
||||
DOWNLOAD_ROOT: /downloads
|
||||
WORKER_ENABLED: "false"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
|
@ -45,6 +49,10 @@ services:
|
|||
# Reachable on the host's LAN at http://<host>:8080. Put a reverse proxy (Caddy/Nginx) in
|
||||
# front for HTTPS / public exposure — and set OAUTH_REDIRECT_URL to the https URL.
|
||||
- "${HTTP_PORT:-8080}:8000"
|
||||
volumes:
|
||||
# Downloaded media. Defaults to a Docker-managed named volume; set DOWNLOAD_HOST_PATH in .env
|
||||
# to a host directory (e.g. one your Plex server reads) to keep the Plex-style tree there.
|
||||
- ${DOWNLOAD_HOST_PATH:-siftlode_downloads}:/downloads
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
|
|
@ -60,8 +68,48 @@ services:
|
|||
start_period: 30s
|
||||
restart: unless-stopped
|
||||
|
||||
# Download worker: same image, runs the yt-dlp / ffmpeg job loop instead of the API. Shares the
|
||||
# DB (job queue) and the downloads mount with the API. Not read-only (yt-dlp/deno/ffmpeg write to
|
||||
# $HOME caches + the staging dir).
|
||||
worker:
|
||||
image: forge.b1fr0st.eu/peter/siftlode:${IMAGE_TAG:-latest}
|
||||
command: ["python", "-m", "app.worker"]
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-siftlode}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-siftlode}
|
||||
SCHEDULER_ENABLED: "false"
|
||||
DOWNLOAD_ROOT: /downloads
|
||||
WORKER_ENABLED: "true"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
bgutil-pot:
|
||||
condition: service_started
|
||||
networks: [internal]
|
||||
volumes:
|
||||
- ${DOWNLOAD_HOST_PATH:-siftlode_downloads}:/downloads
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
tmpfs:
|
||||
- /tmp
|
||||
restart: unless-stopped
|
||||
|
||||
# PO-token provider sidecar: mints YouTube Proof-of-Origin tokens so the worker beats bot-detection
|
||||
# and unlocks high-quality formats (reached at http://bgutil-pot:4416, the config default).
|
||||
bgutil-pot:
|
||||
image: brainicism/bgutil-ytdlp-pot-provider:1.3.1
|
||||
init: true
|
||||
networks: [internal]
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
siftlode_pgdata:
|
||||
siftlode_downloads:
|
||||
|
||||
networks:
|
||||
internal:
|
||||
|
|
|
|||
|
|
@ -25,11 +25,19 @@ services:
|
|||
env_file: .env
|
||||
environment:
|
||||
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-siftlode}:${POSTGRES_PASSWORD:-siftlode}@db:5432/${POSTGRES_DB:-siftlode}
|
||||
# Download center: the API serves finished files + builds filmstrips; the worker (below) runs
|
||||
# the yt-dlp job loop.
|
||||
DOWNLOAD_ROOT: /downloads
|
||||
WORKER_ENABLED: "false"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "${APP_PORT:-8080}:8000"
|
||||
volumes:
|
||||
# Downloaded media. Defaults to a Docker-managed named volume; set DOWNLOAD_HOST_PATH in .env
|
||||
# to a host directory (e.g. one your Plex server reads) to keep the Plex-style tree there.
|
||||
- ${DOWNLOAD_HOST_PATH:-downloads}:/downloads
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; exit(0 if urllib.request.urlopen('http://localhost:8000/healthz').status == 200 else 1)"]
|
||||
interval: 15s
|
||||
|
|
@ -38,5 +46,39 @@ services:
|
|||
start_period: 25s
|
||||
restart: unless-stopped
|
||||
|
||||
# Download worker: same image, runs the yt-dlp / ffmpeg job loop instead of the API. Shares the
|
||||
# DB (job queue) + the downloads mount with the API.
|
||||
worker:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
APP_VERSION: ${APP_VERSION:-dev}
|
||||
GIT_SHA: ${GIT_SHA:-unknown}
|
||||
BUILD_DATE: ${BUILD_DATE:-}
|
||||
command: ["python", "-m", "app.worker"]
|
||||
env_file: .env
|
||||
environment:
|
||||
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-siftlode}:${POSTGRES_PASSWORD:-siftlode}@db:5432/${POSTGRES_DB:-siftlode}
|
||||
SCHEDULER_ENABLED: "false"
|
||||
DOWNLOAD_ROOT: /downloads
|
||||
WORKER_ENABLED: "true"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
bgutil-pot:
|
||||
condition: service_started
|
||||
volumes:
|
||||
- ${DOWNLOAD_HOST_PATH:-downloads}:/downloads
|
||||
restart: unless-stopped
|
||||
|
||||
# PO-token provider sidecar: mints YouTube Proof-of-Origin tokens so the worker beats bot-detection
|
||||
# and unlocks high-quality formats (reached at http://bgutil-pot:4416, the config default).
|
||||
bgutil-pot:
|
||||
image: brainicism/bgutil-ytdlp-pot-provider:1.3.1
|
||||
init: true
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
downloads:
|
||||
|
|
|
|||
|
|
@ -8,7 +8,9 @@ configured in a web wizard on first start. There's no editing of config files by
|
|||
|
||||
- A machine with **Docker** and the **Docker Compose plugin** (Docker Desktop on Windows/macOS,
|
||||
or Docker Engine on Linux).
|
||||
- A few hundred MB of disk and ~1 GB RAM free.
|
||||
- A few hundred MB of disk and ~1 GB RAM free for the app itself. The optional Download Center
|
||||
stores media too — budget disk for whatever you download (it's bounded by per-user quotas you
|
||||
set as admin).
|
||||
- Optional: a domain name + reverse proxy if you want HTTPS / public access (see below).
|
||||
|
||||
## 1. Get the files
|
||||
|
|
@ -77,6 +79,24 @@ public access, put a reverse proxy (Caddy, Nginx, Traefik…) in front to termin
|
|||
secure). If you've already run the installer, edit `OAUTH_REDIRECT_URL` in `.env` to the https
|
||||
callback URL and `docker compose -f docker-compose.selfhost.yml up -d`.
|
||||
|
||||
## Download Center (media storage)
|
||||
|
||||
The stack includes a **Download Center**: an admin-enabled feature that saves videos to the server
|
||||
with yt-dlp (Plex-friendly folders + `.nfo`/poster), lets users trim/crop/join clips, and shares
|
||||
them. It runs two extra containers that come up automatically — a `worker` (the download/edit job
|
||||
loop) and a small `bgutil-pot` sidecar (mints YouTube tokens so downloads aren't bot-blocked). No
|
||||
configuration is required; per-user storage quotas are set on the admin **Downloads → System** page.
|
||||
|
||||
By default the media lives in a Docker-managed volume (`siftlode_downloads`). To keep it somewhere
|
||||
you can reach from other apps — e.g. a folder your **Plex** server indexes — point it at a host
|
||||
directory by adding this to `.env` and re-running `up -d`:
|
||||
|
||||
```bash
|
||||
DOWNLOAD_HOST_PATH=/mnt/media/youtube
|
||||
```
|
||||
|
||||
The directory must be writable by the container user (uid `1000`): `sudo chown -R 1000:1000 <dir>`.
|
||||
|
||||
## Day-to-day
|
||||
|
||||
```bash
|
||||
|
|
@ -84,8 +104,9 @@ callback URL and `docker compose -f docker-compose.selfhost.yml up -d`.
|
|||
docker compose -f docker-compose.selfhost.yml pull
|
||||
docker compose -f docker-compose.selfhost.yml up -d
|
||||
|
||||
# Logs / status
|
||||
# Logs / status (api = web, worker = downloads/edits)
|
||||
docker compose -f docker-compose.selfhost.yml logs -f api
|
||||
docker compose -f docker-compose.selfhost.yml logs -f worker
|
||||
docker compose -f docker-compose.selfhost.yml ps
|
||||
|
||||
# Stop
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>Siftlode</title>
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
11
frontend/public/favicon.svg
Normal file
11
frontend/public/favicon.svg
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<defs>
|
||||
<linearGradient id="s" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#7c86ff"/>
|
||||
<stop offset="1" stop-color="#a855f7"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="64" height="64" rx="16" fill="url(#s)"/>
|
||||
<!-- Bold "S" as a path so it renders identically without depending on an installed font. -->
|
||||
<path fill="#ffffff" d="M41.8 22.4c-2.2-2.5-5.6-4-9.9-4-6.3 0-10.6 3.3-10.6 8.4 0 4.9 3.6 7 9.4 8.2 4.4.9 5.9 1.7 5.9 3.5 0 1.8-1.8 2.9-4.8 2.9-3 0-5.3-1.2-7-3.3l-4.8 4c2.4 3.1 6.4 5 11.6 5 6.8 0 11.3-3.4 11.3-8.9 0-5.2-3.7-7.2-9.7-8.4-4.2-.8-5.6-1.5-5.6-3.2 0-1.6 1.6-2.6 4.2-2.6 2.6 0 4.6 1 6.1 2.8z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 716 B |
|
|
@ -18,6 +18,7 @@ import {
|
|||
type ThemePrefs,
|
||||
} from "./lib/theme";
|
||||
import { hasFilterParams, isPage, paramsToFilters, readPage, stripUrlParams, type Page } from "./lib/urlState";
|
||||
import { pageTitleKey } from "./lib/pageMeta";
|
||||
import {
|
||||
loadLayout,
|
||||
normalizeLayout,
|
||||
|
|
@ -284,6 +285,18 @@ export default function App() {
|
|||
// Expose the current setPage to decoupled callers (download toast "View", etc.).
|
||||
useEffect(() => setNavigator(setPage));
|
||||
|
||||
// Reflect the current module in the browser tab title so open tabs are distinguishable and the
|
||||
// history reads sensibly. Feed = brand only; an open channel page shows the channel name.
|
||||
useEffect(() => {
|
||||
const brand = "Siftlode";
|
||||
const label = channelView
|
||||
? channelView.name || t("header.channelManager")
|
||||
: page === "feed"
|
||||
? null
|
||||
: t(pageTitleKey(page));
|
||||
document.title = label ? `${label} · ${brand}` : brand;
|
||||
}, [page, channelView, i18n.language, t]);
|
||||
|
||||
function setSidebarLayout(next: SidebarLayout) {
|
||||
setSidebarLayoutState(next);
|
||||
saveLayoutLocal(next);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
|
|
@ -7,6 +7,7 @@ import {
|
|||
Pause,
|
||||
Play,
|
||||
Plus,
|
||||
Scissors,
|
||||
Settings2,
|
||||
Share2,
|
||||
Pencil,
|
||||
|
|
@ -17,6 +18,8 @@ import Tabs, { usePersistedTab } from "./Tabs";
|
|||
import Modal from "./Modal";
|
||||
import DownloadDialog from "./DownloadDialog";
|
||||
import ProfileEditor from "./ProfileEditor";
|
||||
import VideoEditor from "./VideoEditor";
|
||||
import ShareDialog from "./ShareDialog";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||
import { api, type DownloadJob, type Me } from "../lib/api";
|
||||
|
|
@ -37,9 +40,10 @@ const STATUS_CLS: Record<string, string> = {
|
|||
};
|
||||
|
||||
// Byte-progress phases (show a % bar) vs ffmpeg post-steps (no %, indeterminate pulse).
|
||||
const DOWNLOAD_PHASES = ["video", "audio"];
|
||||
// "editing" (the phase-2 editor) reports a real % from ffmpeg's out_time, so it gets a bar.
|
||||
const DOWNLOAD_PHASES = ["video", "audio", "editing"];
|
||||
const PHASE_KEYS = [
|
||||
"video", "audio", "merging", "audio_extract", "thumbnail", "sponsorblock", "metadata", "processing",
|
||||
"video", "audio", "editing", "merging", "audio_extract", "thumbnail", "sponsorblock", "metadata", "processing",
|
||||
];
|
||||
|
||||
function StatusPill({ job }: { job: DownloadJob }) {
|
||||
|
|
@ -108,7 +112,14 @@ function DownloadRow({
|
|||
<div className="flex gap-3 p-2.5 rounded-xl bg-card/40 hover:bg-card transition">
|
||||
<Thumb job={job} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium leading-snug line-clamp-1">{jobTitle(job)}</div>
|
||||
<div className="font-medium leading-snug line-clamp-1 flex items-center gap-1.5">
|
||||
{job.job_kind === "edit" && (
|
||||
<span className="inline-flex items-center gap-1 shrink-0 px-1.5 py-0.5 rounded text-[10px] font-semibold uppercase tracking-wide bg-accent/15 text-accent">
|
||||
<Scissors className="w-3 h-3" /> {t("downloads.clipBadge")}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate">{jobTitle(job)}</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted truncate mt-0.5">
|
||||
{job.asset?.uploader || job.source_ref}
|
||||
</div>
|
||||
|
|
@ -176,44 +187,14 @@ function RenameModal({ job, onClose }: { job: DownloadJob; onClose: () => void }
|
|||
);
|
||||
}
|
||||
|
||||
function ShareModal({ job, onClose }: { job: DownloadJob; onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [email, setEmail] = useState("");
|
||||
const share = useMutation({
|
||||
mutationFn: () => api.shareDownload(job.id, email.trim()),
|
||||
onSuccess: (r) => {
|
||||
notify({ level: "success", message: t("downloads.share.done", { email: r.shared_with }) });
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
return (
|
||||
<Modal title={t("downloads.share.title")} onClose={onClose}>
|
||||
<label className="block text-sm font-medium mb-1">{t("downloads.share.label")}</label>
|
||||
<input
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder={t("downloads.share.placeholder")}
|
||||
className={inputCls}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex justify-end mt-4">
|
||||
<button
|
||||
onClick={() => share.mutate()}
|
||||
disabled={!email.trim() || share.isPending}
|
||||
className="px-3 py-1.5 rounded-lg text-sm font-medium bg-accent text-accent-fg hover:opacity-90 disabled:opacity-50 transition"
|
||||
>
|
||||
{t("downloads.share.submit")}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Usage bar ------------------------------------------------------------------------------
|
||||
|
||||
function UsageBar() {
|
||||
const { t } = useTranslation();
|
||||
const usage = useQuery({ queryKey: ["download-usage"], queryFn: api.downloadUsage });
|
||||
// Poll live (like the library list) so the footprint updates the moment the worker finishes an
|
||||
// edit/download — enqueue only creates a queued job (0 bytes), the size lands asynchronously, so
|
||||
// a one-shot fetch at enqueue time would show a stale 0 B until a manual reload.
|
||||
const usage = useLiveQuery(["download-usage"], api.downloadUsage, { intervalMs: 2000 });
|
||||
const u = usage.data;
|
||||
if (!u) return null;
|
||||
const pct = u.unlimited || !u.max_bytes ? 0 : Math.min(100, (u.footprint_bytes / u.max_bytes) * 100);
|
||||
|
|
@ -297,6 +278,56 @@ function QuotaModal({ userId, email, onClose }: { userId: number; email: string;
|
|||
);
|
||||
}
|
||||
|
||||
// Pick ANY user to set a download quota for — even one who hasn't downloaded anything yet (the
|
||||
// footprint list below only shows users who already have files).
|
||||
function QuotaUserPicker({ onPick }: { onPick: (u: { id: number; email: string }) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [q, setQ] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const users = useQuery({ queryKey: ["admin-users"], queryFn: api.adminUsers });
|
||||
const list = (users.data ?? []).filter((u) => !u.is_demo);
|
||||
const filtered = useMemo(() => {
|
||||
const s = q.trim().toLowerCase();
|
||||
const base = s
|
||||
? list.filter((u) => u.email.toLowerCase().includes(s) || (u.display_name ?? "").toLowerCase().includes(s))
|
||||
: list;
|
||||
return base.slice(0, 8);
|
||||
}, [q, list]);
|
||||
return (
|
||||
<div className="relative w-full sm:w-72">
|
||||
<input
|
||||
value={q}
|
||||
onChange={(e) => {
|
||||
setQ(e.target.value);
|
||||
setOpen(true);
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => setTimeout(() => setOpen(false), 150)}
|
||||
placeholder={t("downloads.admin.quotaPickPlaceholder")}
|
||||
className={inputCls}
|
||||
/>
|
||||
{open && filtered.length > 0 && (
|
||||
<div className="absolute z-10 mt-1 w-full rounded-lg border border-border bg-card shadow-xl overflow-hidden">
|
||||
{filtered.map((u) => (
|
||||
<button
|
||||
key={u.id}
|
||||
onClick={() => {
|
||||
onPick({ id: u.id, email: u.email });
|
||||
setQ("");
|
||||
setOpen(false);
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 text-sm hover:bg-surface transition flex flex-col"
|
||||
>
|
||||
<span className="truncate">{u.display_name || u.email}</span>
|
||||
{u.display_name && <span className="text-xs text-muted truncate">{u.email}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminSystem() {
|
||||
const { t } = useTranslation();
|
||||
const storage = useLiveQuery(["admin-storage"], api.adminDownloadStorage, { intervalMs: 5000 });
|
||||
|
|
@ -320,9 +351,13 @@ function AdminSystem() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{s && s.per_user.length > 0 && (
|
||||
{s && (
|
||||
<div className="mb-6">
|
||||
<div className="text-sm font-medium mb-2">{t("downloads.admin.perUser")}</div>
|
||||
<div className="flex items-center justify-between gap-3 mb-2 flex-wrap">
|
||||
<div className="text-sm font-medium">{t("downloads.admin.perUser")}</div>
|
||||
{/* Set a quota for any user, regardless of whether they've downloaded anything. */}
|
||||
<QuotaUserPicker onPick={setQuotaFor} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{s.per_user.map((u) => (
|
||||
<div key={u.user_id} className="flex items-center gap-2 text-sm px-3 py-1.5 rounded-lg bg-card/40">
|
||||
|
|
@ -337,6 +372,9 @@ function AdminSystem() {
|
|||
</button>
|
||||
</div>
|
||||
))}
|
||||
{s.per_user.length === 0 && (
|
||||
<div className="text-xs text-muted px-3 py-2">{t("downloads.admin.noFootprint")}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -371,6 +409,7 @@ export default function DownloadCenter({ me }: { me: Me }) {
|
|||
const [manage, setManage] = useState(false);
|
||||
const [renameJob, setRenameJob] = useState<DownloadJob | null>(null);
|
||||
const [shareJob, setShareJob] = useState<DownloadJob | null>(null);
|
||||
const [editJob, setEditJob] = useState<DownloadJob | null>(null);
|
||||
|
||||
const jobsQ = useLiveQuery(["downloads"], api.downloads, { intervalMs: 2000 });
|
||||
const sharedQ = useQuery({ queryKey: ["downloads-shared"], queryFn: api.downloadsShared, enabled: tab === "shared" });
|
||||
|
|
@ -392,6 +431,12 @@ export default function DownloadCenter({ me }: { me: Me }) {
|
|||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
// Remove a shared-with-me item from your list (only your grant; the owner's file is untouched).
|
||||
const removeShared = useMutation({
|
||||
mutationFn: (id: number) => api.removeSharedDownload(id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["downloads-shared"] }),
|
||||
});
|
||||
|
||||
const confirmThen = async (title: string, body: string, fn: () => void) => {
|
||||
if (await confirm({ title, message: body, confirmLabel: title, danger: true })) fn();
|
||||
};
|
||||
|
|
@ -479,6 +524,11 @@ export default function DownloadCenter({ me }: { me: Me }) {
|
|||
{library.map((job) => (
|
||||
<DownloadRow key={job.id} job={job}>
|
||||
{saveBtn(job)}
|
||||
{job.can_download && (job.asset?.duration_s ?? 0) > 0 && (
|
||||
<IconBtn onClick={() => setEditJob(job)} title={t("downloads.actions.edit")}>
|
||||
<Scissors className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
)}
|
||||
<IconBtn onClick={() => setRenameJob(job)} title={t("downloads.actions.rename")}>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
|
|
@ -504,6 +554,24 @@ export default function DownloadCenter({ me }: { me: Me }) {
|
|||
{(sharedQ.data ?? []).map((job) => (
|
||||
<DownloadRow key={job.id} job={job}>
|
||||
{job.can_download && saveBtn(job)}
|
||||
{job.can_download && (job.asset?.duration_s ?? 0) > 0 && (
|
||||
<IconBtn onClick={() => setEditJob(job)} title={t("downloads.actions.edit")}>
|
||||
<Scissors className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
)}
|
||||
<IconBtn
|
||||
onClick={() =>
|
||||
confirmThen(
|
||||
t("downloads.confirm.removeSharedTitle"),
|
||||
t("downloads.confirm.removeSharedBody"),
|
||||
() => removeShared.mutate(job.id)
|
||||
)
|
||||
}
|
||||
title={t("downloads.actions.removeShared")}
|
||||
danger
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
</DownloadRow>
|
||||
))}
|
||||
{sharedQ.data && sharedQ.data.length === 0 && (
|
||||
|
|
@ -525,7 +593,8 @@ export default function DownloadCenter({ me }: { me: Me }) {
|
|||
)}
|
||||
{manage && <ProfileEditor onClose={() => setManage(false)} />}
|
||||
{renameJob && <RenameModal job={renameJob} onClose={() => setRenameJob(null)} />}
|
||||
{shareJob && <ShareModal job={shareJob} onClose={() => setShareJob(null)} />}
|
||||
{shareJob && <ShareDialog job={shareJob} onClose={() => setShareJob(null)} />}
|
||||
{editJob && <VideoEditor job={editJob} onClose={() => setEditJob(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import { useTranslation } from "react-i18next";
|
|||
import { Search, X, Youtube } from "lucide-react";
|
||||
import type { FeedFilters, Me } from "../lib/api";
|
||||
import type { Page } from "../lib/urlState";
|
||||
import { pageTitleKey } from "../lib/pageMeta";
|
||||
import PageTitle from "./PageTitle";
|
||||
|
||||
// Contextual top bar over the content column. Primary navigation, account and the per-user sync
|
||||
// status live in the left NavSidebar; the feed's scope toggle now lives in the filter sidebar.
|
||||
|
|
@ -72,26 +74,8 @@ export default function Header({
|
|||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 text-center text-sm font-semibold">
|
||||
{page === "stats"
|
||||
? t("header.usageStats")
|
||||
: page === "playlists"
|
||||
? t("header.account.playlists")
|
||||
: page === "settings"
|
||||
? t("settings.title")
|
||||
: page === "scheduler"
|
||||
? t("header.scheduler")
|
||||
: page === "config"
|
||||
? t("header.configuration")
|
||||
: page === "users"
|
||||
? t("header.users")
|
||||
: page === "notifications"
|
||||
? t("inbox.navLabel")
|
||||
: page === "messages"
|
||||
? t("messages.navLabel")
|
||||
: page === "downloads"
|
||||
? t("downloads.navLabel")
|
||||
: t("header.channelManager")}
|
||||
<div className="flex-1 flex justify-center">
|
||||
<PageTitle label={t(pageTitleKey(page))} />
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
|
|
|||
|
|
@ -256,8 +256,9 @@ export default function NavSidebar({
|
|||
{!collapsed && (
|
||||
<button
|
||||
onClick={() => setPage("feed")}
|
||||
className="text-lg font-bold tracking-tight select-none"
|
||||
className="text-lg font-bold tracking-tight select-none cursor-pointer rounded-md -mx-1 px-1 hover:bg-card hover:opacity-90 transition"
|
||||
title={t("header.feed")}
|
||||
aria-label={t("header.feed")}
|
||||
>
|
||||
Sift<span className="text-accent">lode</span>
|
||||
</button>
|
||||
|
|
|
|||
11
frontend/src/components/PageTitle.tsx
Normal file
11
frontend/src/components/PageTitle.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
// The centered module title in the top bar. Every non-feed module renders through this one
|
||||
// component, so its styling lives in a single place. Design element (user-picked): a tracked
|
||||
// small-caps "eyebrow" with a leading accent dot.
|
||||
export default function PageTitle({ label }: { label: string }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.16em] text-fg">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-accent shrink-0" />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -142,7 +142,7 @@ function JobRow({
|
|||
<StatusDot k={statusKey(job)} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium flex items-center gap-1.5 flex-wrap">
|
||||
<Tooltip hint={t(`scheduler.jobDesc.${job.id}`)}>
|
||||
<Tooltip hint={t(`scheduler.jobDesc.${job.id}`, "")}>
|
||||
<span className="underline decoration-dotted decoration-muted/40 underline-offset-4 cursor-help">
|
||||
{t(`scheduler.jobs.${job.id}`, job.id)}
|
||||
</span>
|
||||
|
|
|
|||
255
frontend/src/components/ShareDialog.tsx
Normal file
255
frontend/src/components/ShareDialog.tsx
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, Copy, Link2, Lock, Trash2, UserPlus, Eye } from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
import Modal from "./Modal";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { api, type DownloadJob, type ShareLink } from "../lib/api";
|
||||
|
||||
const inputCls =
|
||||
"w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent/40";
|
||||
const btn = "px-3 py-1.5 rounded-lg text-sm font-medium transition disabled:opacity-50";
|
||||
const EXPIRY_DAYS = [null, 1, 7, 30] as const;
|
||||
|
||||
// --- share with a registered user (ACL) -----------------------------------------------------
|
||||
|
||||
function UserShare({ job }: { job: DownloadJob }) {
|
||||
const { t } = useTranslation();
|
||||
const [q, setQ] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const recipients = useQuery({ queryKey: ["share-recipients"], queryFn: api.shareRecipients });
|
||||
const list = recipients.data ?? [];
|
||||
const filtered = useMemo(() => {
|
||||
const s = q.trim().toLowerCase();
|
||||
if (!s) return list.slice(0, 8);
|
||||
return list
|
||||
.filter((u) => u.email.toLowerCase().includes(s) || (u.display_name ?? "").toLowerCase().includes(s))
|
||||
.slice(0, 8);
|
||||
}, [q, list]);
|
||||
|
||||
const share = useMutation({
|
||||
mutationFn: (email: string) => api.shareDownload(job.id, email),
|
||||
onSuccess: (r) => {
|
||||
notify({ level: "success", message: t("downloads.share.done", { email: r.shared_with }) });
|
||||
setQ("");
|
||||
setOpen(false);
|
||||
},
|
||||
onError: () => notify({ level: "error", message: t("downloads.share.failed") }),
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-medium mb-1">
|
||||
<UserPlus className="w-4 h-4 text-muted" /> {t("downloads.share.userSection")}
|
||||
</div>
|
||||
<p className="text-xs text-muted mb-2">{t("downloads.share.userHint")}</p>
|
||||
<div className="relative">
|
||||
<input
|
||||
value={q}
|
||||
onChange={(e) => {
|
||||
setQ(e.target.value);
|
||||
setOpen(true);
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
// Close on blur, but after a tick so a click on a dropdown row still registers.
|
||||
onBlur={() => setTimeout(() => setOpen(false), 150)}
|
||||
placeholder={t("downloads.share.pickPlaceholder")}
|
||||
className={inputCls}
|
||||
/>
|
||||
{open && filtered.length > 0 && (
|
||||
<div className="absolute z-10 mt-1 w-full rounded-lg border border-border bg-card shadow-xl overflow-hidden">
|
||||
{filtered.map((u) => (
|
||||
<button
|
||||
key={u.id}
|
||||
onClick={() => share.mutate(u.email)}
|
||||
disabled={share.isPending}
|
||||
className="w-full text-left px-3 py-2 text-sm hover:bg-surface transition flex flex-col"
|
||||
>
|
||||
<span className="truncate">{u.display_name || u.email}</span>
|
||||
{u.display_name && <span className="text-xs text-muted truncate">{u.email}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{recipients.data && list.length === 0 && (
|
||||
<p className="text-xs text-muted mt-1">{t("downloads.share.noUsers")}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- share by public link -------------------------------------------------------------------
|
||||
|
||||
function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const fullUrl = window.location.origin + link.url;
|
||||
|
||||
const copy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(fullUrl);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
notify({ level: "error", message: t("downloads.share.copyFailed") });
|
||||
}
|
||||
};
|
||||
const toggle = useMutation({
|
||||
mutationFn: () => api.updateDownloadLink(link.id, { allow_download: !link.allow_download }),
|
||||
onSuccess: onChanged,
|
||||
});
|
||||
const revoke = useMutation({
|
||||
mutationFn: () => api.revokeDownloadLink(link.id),
|
||||
onSuccess: onChanged,
|
||||
});
|
||||
|
||||
const badges: string[] = [];
|
||||
if (link.has_password) badges.push(t("downloads.share.hasPassword"));
|
||||
if (link.allow_download) badges.push(t("downloads.share.downloadable"));
|
||||
if (link.expires_at) badges.push(t("downloads.share.expiresOn", { date: new Date(link.expires_at).toLocaleDateString() }));
|
||||
|
||||
return (
|
||||
<div className="rounded-lg bg-card/40 p-2.5 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link2 className="w-4 h-4 text-muted shrink-0" />
|
||||
<input readOnly value={fullUrl} className="flex-1 min-w-0 bg-transparent text-xs text-muted truncate outline-none" />
|
||||
<button onClick={copy} title={t("downloads.share.copy")} className="p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg transition">
|
||||
{copied ? <Check className="w-4 h-4 text-emerald-400" /> : <Copy className="w-4 h-4" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (await confirm({ title: t("downloads.share.revoke"), message: t("downloads.share.revokeConfirm"), confirmLabel: t("downloads.share.revoke"), danger: true }))
|
||||
revoke.mutate();
|
||||
}}
|
||||
title={t("downloads.share.revoke")}
|
||||
className="p-1.5 rounded-md hover:bg-surface text-muted hover:text-red-400 transition"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted flex-wrap">
|
||||
<span className="inline-flex items-center gap-1"><Eye className="w-3 h-3" /> {t("downloads.share.views", { n: link.view_count })}</span>
|
||||
{badges.map((b) => (
|
||||
<span key={b} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-surface">
|
||||
{b === t("downloads.share.hasPassword") && <Lock className="w-3 h-3" />}
|
||||
{b}
|
||||
</span>
|
||||
))}
|
||||
<div className="flex-1" />
|
||||
<label className="inline-flex items-center gap-1.5 cursor-pointer">
|
||||
<input type="checkbox" checked={link.allow_download} onChange={() => toggle.mutate()} />
|
||||
{t("downloads.share.allowDownload")}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkShare({ job }: { job: DownloadJob }) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const links = useQuery({ queryKey: ["download-links", job.id], queryFn: () => api.downloadLinks(job.id) });
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [allowDownload, setAllowDownload] = useState(false);
|
||||
const [expiryDays, setExpiryDays] = useState<number | null>(null);
|
||||
const [password, setPassword] = useState("");
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: ["download-links", job.id] });
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: () =>
|
||||
api.createDownloadLink(job.id, {
|
||||
allow_download: allowDownload,
|
||||
expires_days: expiryDays,
|
||||
password: password.trim() || undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
setCreating(false);
|
||||
setAllowDownload(false);
|
||||
setExpiryDays(null);
|
||||
setPassword("");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-medium mb-1">
|
||||
<Link2 className="w-4 h-4 text-muted" /> {t("downloads.share.linkSection")}
|
||||
</div>
|
||||
<p className="text-xs text-muted mb-2">{t("downloads.share.linkHint")}</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
{(links.data ?? []).map((l) => (
|
||||
<LinkRow key={l.id} link={l} onChanged={invalidate} />
|
||||
))}
|
||||
{links.data && links.data.length === 0 && !creating && (
|
||||
<p className="text-xs text-muted">{t("downloads.share.noLinks")}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{creating ? (
|
||||
<div className="mt-2 rounded-lg border border-border p-3 space-y-3">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" checked={allowDownload} onChange={(e) => setAllowDownload(e.target.checked)} />
|
||||
{t("downloads.share.allowDownload")}
|
||||
</label>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-muted">{t("downloads.share.expiry")}:</span>
|
||||
{EXPIRY_DAYS.map((d) => (
|
||||
<button
|
||||
key={String(d)}
|
||||
onClick={() => setExpiryDays(d)}
|
||||
className={clsx(
|
||||
"px-2 py-1 rounded-md border text-xs transition",
|
||||
expiryDays === d ? "border-accent bg-accent/10 text-accent" : "border-border text-muted hover:text-fg"
|
||||
)}
|
||||
>
|
||||
{d === null ? t("downloads.share.expiryNever") : t("downloads.share.days", { n: d })}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={t("downloads.share.password")}
|
||||
className={inputCls}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button onClick={() => setCreating(false)} className={clsx(btn, "text-muted hover:text-fg hover:bg-surface")}>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button onClick={() => create.mutate()} disabled={create.isPending} className={clsx(btn, "bg-accent text-accent-fg hover:opacity-90")}>
|
||||
{t("downloads.share.createLink")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setCreating(true)}
|
||||
className={clsx(btn, "mt-2 border border-border text-muted hover:text-fg hover:border-muted inline-flex items-center gap-1.5")}
|
||||
>
|
||||
<Link2 className="w-4 h-4" /> {t("downloads.share.newLink")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ShareDialog({ job, onClose }: { job: DownloadJob; onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Modal title={t("downloads.share.title")} onClose={onClose}>
|
||||
<div className="space-y-5">
|
||||
<UserShare job={job} />
|
||||
<div className="border-t border-border" />
|
||||
<LinkShare job={job} />
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -128,19 +128,29 @@ export default function SyncStatus({
|
|||
</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
{showMain && <div className="flex items-center gap-1.5">{stateNode}</div>}
|
||||
{notFull > 0 && (
|
||||
<Tooltip hint={t("header.sync.fullHistoryTooltip")}>
|
||||
<button
|
||||
onClick={onGoToFullHistory}
|
||||
className="flex items-center gap-1 hover:text-fg underline decoration-dotted decoration-muted/40 underline-offset-4 transition cursor-pointer"
|
||||
>
|
||||
<History className="w-3.5 h-3.5" />
|
||||
{t("header.sync.withoutFullHistory", { count: notFull })}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{/* Pause sits inline at the right end of the primary status row (the sync state, or —
|
||||
when idle with only deep-history pending — the "N without full history" row), never on
|
||||
an orphaned line of its own. */}
|
||||
{showMain && (
|
||||
<div className="flex items-center justify-between gap-1.5">
|
||||
<span className="flex items-center gap-1.5 min-w-0">{stateNode}</span>
|
||||
{pauseBtn}
|
||||
</div>
|
||||
)}
|
||||
{notFull > 0 && (
|
||||
<div className="flex items-center justify-between gap-1.5">
|
||||
<Tooltip hint={t("header.sync.fullHistoryTooltip")}>
|
||||
<button
|
||||
onClick={onGoToFullHistory}
|
||||
className="flex items-center gap-1 hover:text-fg underline decoration-dotted decoration-muted/40 underline-offset-4 transition cursor-pointer"
|
||||
>
|
||||
<History className="w-3.5 h-3.5" />
|
||||
{t("header.sync.withoutFullHistory", { count: notFull })}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{!showMain && pauseBtn}
|
||||
</div>
|
||||
)}
|
||||
{pauseBtn && <div className="pt-0.5">{pauseBtn}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import { useRef, useState, useSyncExternalStore } from "react";
|
||||
import { useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { hintsEnabled, subscribeHints } from "../lib/hints";
|
||||
|
||||
type Side = "top" | "bottom";
|
||||
type Coords = { left: number; top: number; placement: Side };
|
||||
|
||||
const MARGIN = 8;
|
||||
const MAX_HALF = 120; // half of the tooltip's max-w-[240px] — the widest it can get
|
||||
|
||||
/** Wrap any element to show a short glass hint caption on hover — but only while the
|
||||
* app-wide hints toggle (Settings → Appearance) is on. Rendered in a portal with
|
||||
* fixed positioning so it is never clipped by an overflow/stacking ancestor. */
|
||||
|
|
@ -19,6 +22,7 @@ export default function Tooltip({
|
|||
}) {
|
||||
const enabled = useSyncExternalStore(subscribeHints, hintsEnabled, hintsEnabled);
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const tipRef = useRef<HTMLDivElement>(null);
|
||||
const [coords, setCoords] = useState<Coords | null>(null);
|
||||
|
||||
function show() {
|
||||
|
|
@ -27,8 +31,12 @@ export default function Tooltip({
|
|||
const r = el.getBoundingClientRect();
|
||||
// Prefer the requested side; flip to bottom if there's no room above.
|
||||
const placement: Side = side === "bottom" || r.top < 90 ? "bottom" : "top";
|
||||
const center = r.left + r.width / 2;
|
||||
// Clamp using the MAX half-width so the caption can never overflow the viewport, even on the
|
||||
// first paint (before we know its real width). A left-edge anchor near x=0 would otherwise push
|
||||
// the centered box off-screen. The layout effect below refines this to the actual width.
|
||||
setCoords({
|
||||
left: Math.min(Math.max(r.left + r.width / 2, 92), window.innerWidth - 92),
|
||||
left: Math.min(Math.max(center, MAX_HALF + MARGIN), window.innerWidth - MAX_HALF - MARGIN),
|
||||
top: placement === "top" ? r.top - 8 : r.bottom + 8,
|
||||
placement,
|
||||
});
|
||||
|
|
@ -37,6 +45,21 @@ export default function Tooltip({
|
|||
setCoords(null);
|
||||
}
|
||||
|
||||
// Once rendered, re-center on the anchor using the caption's ACTUAL width (a short hint doesn't
|
||||
// need the full 240px reservation), still clamped inside the viewport.
|
||||
useLayoutEffect(() => {
|
||||
const tip = tipRef.current;
|
||||
const el = ref.current;
|
||||
if (!coords || !tip || !el) return;
|
||||
const r = el.getBoundingClientRect();
|
||||
const half = tip.offsetWidth / 2;
|
||||
const left = Math.min(
|
||||
Math.max(r.left + r.width / 2, half + MARGIN),
|
||||
window.innerWidth - half - MARGIN
|
||||
);
|
||||
if (Math.abs(left - coords.left) > 0.5) setCoords((c) => (c ? { ...c, left } : c));
|
||||
}, [coords]);
|
||||
|
||||
if (!enabled || !hint) return <>{children}</>;
|
||||
|
||||
return (
|
||||
|
|
@ -52,6 +75,7 @@ export default function Tooltip({
|
|||
{coords &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={tipRef}
|
||||
role="tooltip"
|
||||
style={{
|
||||
position: "fixed",
|
||||
|
|
|
|||
571
frontend/src/components/VideoEditor.tsx
Normal file
571
frontend/src/components/VideoEditor.tsx
Normal file
|
|
@ -0,0 +1,571 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Crop, Eye, EyeOff, Pause, Play, Scissors, SplitSquareHorizontal, Loader2, X } from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
import Modal from "./Modal";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { api, type DownloadJob, type EditSpec } from "../lib/api";
|
||||
|
||||
// mm:ss.d timecode <-> seconds.
|
||||
function tc(s: number): string {
|
||||
if (!isFinite(s) || s < 0) s = 0;
|
||||
const m = Math.floor(s / 60);
|
||||
return `${m}:${(s - m * 60).toFixed(1).padStart(4, "0")}`;
|
||||
}
|
||||
function parseTc(v: string): number | null {
|
||||
v = v.trim();
|
||||
if (!v) return null;
|
||||
if (v.includes(":")) {
|
||||
const [m, s] = v.split(":");
|
||||
const mm = Number(m), ss = Number(s);
|
||||
if (!isFinite(mm) || !isFinite(ss)) return null;
|
||||
return mm * 60 + ss;
|
||||
}
|
||||
const n = Number(v);
|
||||
return isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
type Seg = { id: number; start: number; end: number; keep: boolean };
|
||||
type Frac = { x: number; y: number; w: number; h: number };
|
||||
type Drag = { type: "seek" } | { type: "boundary"; bi: number };
|
||||
|
||||
const btn = "px-3 py-1.5 rounded-lg text-sm font-medium transition disabled:opacity-50";
|
||||
const inputCls =
|
||||
"w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent/40";
|
||||
const numCls =
|
||||
"w-24 rounded-md bg-surface border border-border px-2 py-1 text-sm tabular-nums focus:outline-none focus:ring-2 focus:ring-accent/40";
|
||||
const MIN_SEG = 0.1;
|
||||
|
||||
export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
const sid = useRef(1);
|
||||
const nid = () => sid.current++;
|
||||
|
||||
const srcDur = job.asset?.duration_s ?? 0;
|
||||
const [duration, setDuration] = useState(srcDur);
|
||||
const [current, setCurrent] = useState(0);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [trackW, setTrackW] = useState(0);
|
||||
|
||||
// The cut-list: contiguous segments covering [0, duration]. Starts as the whole video, kept.
|
||||
const [segments, setSegments] = useState<Seg[]>([{ id: 0, start: 0, end: srcDur, keep: true }]);
|
||||
const [selId, setSelId] = useState(0);
|
||||
const [output, setOutput] = useState<"separate" | "join">("separate");
|
||||
const [cropOn, setCropOn] = useState(false);
|
||||
const [crop, setCrop] = useState<Frac>({ x: 0.1, y: 0.1, w: 0.8, h: 0.8 });
|
||||
const [accurate, setAccurate] = useState(true);
|
||||
const [name, setName] = useState("");
|
||||
|
||||
const fileUrl = useMemo(() => api.downloadFileUrl(job.id), [job.id]);
|
||||
const aspect =
|
||||
(job.asset?.width && job.asset?.height ? job.asset.width / job.asset.height : 0) || 16 / 9;
|
||||
const sb = useQuery({
|
||||
queryKey: ["storyboard", job.id],
|
||||
queryFn: () => api.downloadStoryboard(job.id),
|
||||
staleTime: Infinity,
|
||||
retry: false,
|
||||
});
|
||||
const strip = sb.data?.available ? sb.data : null;
|
||||
const defaultName = job.display_name || job.asset?.title || "";
|
||||
|
||||
// Measure the track for aspect-correct filmstrip tiling.
|
||||
useEffect(() => {
|
||||
const el = trackRef.current;
|
||||
if (!el || typeof ResizeObserver === "undefined") return;
|
||||
const ro = new ResizeObserver(() => setTrackW(el.clientWidth));
|
||||
ro.observe(el);
|
||||
setTrackW(el.clientWidth);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
const onLoaded = () => {
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
const d = v.duration && isFinite(v.duration) ? v.duration : srcDur;
|
||||
setDuration(d);
|
||||
setSegments((s) => (s.length === 1 && s[0].end <= 0 ? [{ id: 0, start: 0, end: d, keep: true }] : s));
|
||||
};
|
||||
const onTimeUpdate = () => {
|
||||
const v = videoRef.current;
|
||||
if (v) setCurrent(v.currentTime);
|
||||
};
|
||||
const seek = (s: number) => {
|
||||
const v = videoRef.current;
|
||||
if (v) v.currentTime = Math.max(0, Math.min(duration, s));
|
||||
};
|
||||
const playPause = () => {
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
v.paused ? v.play() : v.pause();
|
||||
};
|
||||
|
||||
// --- cut-list mutations ---
|
||||
const boundaries = useMemo(() => segments.slice(0, -1).map((s) => s.end), [segments]);
|
||||
const splitAtPlayhead = () => {
|
||||
const time = current;
|
||||
setSegments((segs) => {
|
||||
const i = segs.findIndex((s) => time > s.start + MIN_SEG && time < s.end - MIN_SEG);
|
||||
if (i < 0) return segs;
|
||||
const s = segs[i];
|
||||
const b: Seg = { id: nid(), start: time, end: s.end, keep: s.keep };
|
||||
const next = [...segs.slice(0, i), { ...s, end: time }, b, ...segs.slice(i + 1)];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const moveBoundary = (bi: number, time: number) =>
|
||||
setSegments((segs) => {
|
||||
if (bi < 0 || bi >= segs.length - 1) return segs;
|
||||
const lo = segs[bi].start + MIN_SEG;
|
||||
const hi = segs[bi + 1].end - MIN_SEG;
|
||||
const tt = Math.max(lo, Math.min(hi, time));
|
||||
const copy = [...segs];
|
||||
copy[bi] = { ...copy[bi], end: tt };
|
||||
copy[bi + 1] = { ...copy[bi + 1], start: tt };
|
||||
return copy;
|
||||
});
|
||||
const deleteBoundary = (bi: number) =>
|
||||
setSegments((segs) => {
|
||||
if (segs.length < 2) return segs;
|
||||
const merged = { ...segs[bi], end: segs[bi + 1].end };
|
||||
return [...segs.slice(0, bi), merged, ...segs.slice(bi + 2)];
|
||||
});
|
||||
const toggleKeep = (id: number) =>
|
||||
setSegments((segs) => segs.map((s) => (s.id === id ? { ...s, keep: !s.keep } : s)));
|
||||
|
||||
// --- timeline drag (seek / boundary) + hover preview ---
|
||||
const [drag, setDrag] = useState<Drag | null>(null);
|
||||
const [hoverX, setHoverX] = useState<number | null>(null);
|
||||
const timeAt = useCallback(
|
||||
(clientX: number) => {
|
||||
const el = trackRef.current;
|
||||
if (!el) return 0;
|
||||
const r = el.getBoundingClientRect();
|
||||
return Math.max(0, Math.min(1, (clientX - r.left) / r.width)) * duration;
|
||||
},
|
||||
[duration]
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!drag) return;
|
||||
const move = (e: PointerEvent) => {
|
||||
const tt = timeAt(e.clientX);
|
||||
if (drag.type === "seek") {
|
||||
setCurrent(tt);
|
||||
seek(tt);
|
||||
} else moveBoundary(drag.bi, tt);
|
||||
};
|
||||
const up = () => setDrag(null);
|
||||
window.addEventListener("pointermove", move);
|
||||
window.addEventListener("pointerup", up);
|
||||
return () => {
|
||||
window.removeEventListener("pointermove", move);
|
||||
window.removeEventListener("pointerup", up);
|
||||
};
|
||||
}, [drag, timeAt]);
|
||||
|
||||
// --- crop drag ---
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
const cropDrag = useRef<null | { mode: "move" | "resize"; px: number; py: number; orig: Frac }>(null);
|
||||
const onCropDown = (e: React.PointerEvent, mode: "move" | "resize") => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
(e.target as Element).setPointerCapture?.(e.pointerId);
|
||||
cropDrag.current = { mode, px: e.clientX, py: e.clientY, orig: { ...crop } };
|
||||
};
|
||||
useEffect(() => {
|
||||
const move = (e: PointerEvent) => {
|
||||
const d = cropDrag.current;
|
||||
const el = overlayRef.current;
|
||||
if (!d || !el) return;
|
||||
const r = el.getBoundingClientRect();
|
||||
const dx = (e.clientX - d.px) / r.width;
|
||||
const dy = (e.clientY - d.py) / r.height;
|
||||
if (d.mode === "move")
|
||||
setCrop({
|
||||
...d.orig,
|
||||
x: Math.max(0, Math.min(1 - d.orig.w, d.orig.x + dx)),
|
||||
y: Math.max(0, Math.min(1 - d.orig.h, d.orig.y + dy)),
|
||||
});
|
||||
else
|
||||
setCrop({
|
||||
...d.orig,
|
||||
w: Math.max(0.05, Math.min(1 - d.orig.x, d.orig.w + dx)),
|
||||
h: Math.max(0.05, Math.min(1 - d.orig.y, d.orig.h + dy)),
|
||||
});
|
||||
};
|
||||
const up = () => (cropDrag.current = null);
|
||||
window.addEventListener("pointermove", move);
|
||||
window.addEventListener("pointerup", up);
|
||||
return () => {
|
||||
window.removeEventListener("pointermove", move);
|
||||
window.removeEventListener("pointerup", up);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// --- filmstrip tile helpers (percentage sprite positioning; aspect-correct cells) ---
|
||||
const pct = (v: number) => `${(duration ? (v / duration) * 100 : 0).toFixed(3)}%`;
|
||||
const tileStyle = (idx: number): React.CSSProperties => {
|
||||
const cols = strip?.cols ?? 12;
|
||||
const rows = strip?.rows ?? 1;
|
||||
const i = Math.max(0, Math.min((strip?.count ?? 1) - 1, idx));
|
||||
const col = i % cols;
|
||||
const row = Math.floor(i / cols);
|
||||
return {
|
||||
backgroundImage: `url(${api.storyboardImageUrl(job.id)})`,
|
||||
backgroundSize: `${cols * 100}% ${rows * 100}%`,
|
||||
backgroundPosition: `${cols > 1 ? (col / (cols - 1)) * 100 : 0}% ${rows > 1 ? (row / (rows - 1)) * 100 : 0}%`,
|
||||
};
|
||||
};
|
||||
const stripCells = strip && trackW ? Math.max(1, Math.floor(trackW / (44 * aspect))) : 0;
|
||||
const hoverTime = hoverX != null ? timeAt(hoverX) : null;
|
||||
// Clamp the hover-scrub popover inside the track so it never overflows the modal (which would
|
||||
// add a horizontal scrollbar): center it on the cursor, but keep both edges within [0, trackW].
|
||||
const HOVER_W = 148;
|
||||
const hoverLeft =
|
||||
hoverX != null
|
||||
? Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
Math.max(0, (trackW || HOVER_W) - HOVER_W),
|
||||
hoverX - (trackRef.current?.getBoundingClientRect().left ?? 0) - HOVER_W / 2
|
||||
)
|
||||
)
|
||||
: 0;
|
||||
|
||||
// --- create ---
|
||||
const kept = segments.filter((s) => s.keep);
|
||||
const keptDur = kept.reduce((a, s) => a + (s.end - s.start), 0);
|
||||
const isFullSingle =
|
||||
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 willJoin = output === "join" && kept.length > 1;
|
||||
const reencode = cropOn || (willJoin ? accurate : accurate);
|
||||
|
||||
const cropPx = (): { x: number; y: number; w: number; h: number } | undefined => {
|
||||
const v = videoRef.current;
|
||||
const vw = v?.videoWidth || job.asset?.width || 0;
|
||||
const vh = v?.videoHeight || job.asset?.height || 0;
|
||||
if (!cropOn || !vw || !vh) return undefined;
|
||||
const even = (n: number) => Math.max(2, Math.round(n / 2) * 2);
|
||||
return { x: even(crop.x * vw), y: even(crop.y * vh), w: even(crop.w * vw), h: even(crop.h * vh) };
|
||||
};
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: async () => {
|
||||
const cp = cropPx();
|
||||
const base = name.trim() || defaultName;
|
||||
if (willJoin) {
|
||||
const spec: EditSpec = {
|
||||
segments: kept.map((s) => ({ start_s: +s.start.toFixed(3), end_s: +s.end.toFixed(3) })),
|
||||
accurate,
|
||||
};
|
||||
if (cp) spec.crop = cp;
|
||||
await api.enqueueEdit({ source_job_id: job.id, edit_spec: spec, display_name: name.trim() || undefined });
|
||||
return { files: 1 };
|
||||
}
|
||||
const N = kept.length;
|
||||
for (let i = 0; i < N; i++) {
|
||||
const s = kept[i];
|
||||
const spec: EditSpec = { trim: { start_s: +s.start.toFixed(3), end_s: +s.end.toFixed(3) } };
|
||||
if (cp) spec.crop = cp;
|
||||
else spec.accurate = accurate;
|
||||
const dn = N > 1 ? `${base} (${i + 1}/${N})` : name.trim() || undefined;
|
||||
await api.enqueueEdit({ source_job_id: job.id, edit_spec: spec, display_name: dn });
|
||||
}
|
||||
return { files: N };
|
||||
},
|
||||
onSuccess: ({ files }) => {
|
||||
qc.invalidateQueries({ queryKey: ["downloads"] });
|
||||
qc.invalidateQueries({ queryKey: ["download-usage"] });
|
||||
notify({ level: "success", message: t("editor.created", { count: files }) });
|
||||
onClose();
|
||||
},
|
||||
onError: () => notify({ level: "error", message: t("editor.failed") }),
|
||||
});
|
||||
|
||||
const sel = segments.find((s) => s.id === selId) ?? segments[0];
|
||||
const selIdx = segments.findIndex((s) => s.id === sel?.id);
|
||||
|
||||
return (
|
||||
<Modal title={t("editor.title", { name: defaultName })} onClose={onClose} maxWidth="max-w-3xl">
|
||||
<div className="space-y-3">
|
||||
{/* video + crop overlay */}
|
||||
<div className="relative bg-black rounded-lg overflow-hidden select-none">
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={fileUrl}
|
||||
onLoadedMetadata={onLoaded}
|
||||
onTimeUpdate={onTimeUpdate}
|
||||
onPlay={() => setPlaying(true)}
|
||||
onPause={() => setPlaying(false)}
|
||||
className="w-full max-h-[42vh] mx-auto block"
|
||||
playsInline
|
||||
/>
|
||||
{cropOn && (
|
||||
<div ref={overlayRef} className="absolute inset-0">
|
||||
<div
|
||||
className="absolute border-2 border-accent cursor-move"
|
||||
style={{
|
||||
left: `${crop.x * 100}%`,
|
||||
top: `${crop.y * 100}%`,
|
||||
width: `${crop.w * 100}%`,
|
||||
height: `${crop.h * 100}%`,
|
||||
boxShadow: "0 0 0 9999px rgba(0,0,0,0.5)",
|
||||
}}
|
||||
onPointerDown={(e) => onCropDown(e, "move")}
|
||||
>
|
||||
<div
|
||||
className="absolute -right-1.5 -bottom-1.5 w-4 h-4 rounded-sm bg-accent cursor-nwse-resize"
|
||||
onPointerDown={(e) => onCropDown(e, "resize")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* transport */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={playPause} className="p-2 rounded-lg bg-surface hover:bg-card transition" title={t("editor.playPause")}>
|
||||
{playing ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
|
||||
</button>
|
||||
<div className="text-xs text-muted tabular-nums">{tc(current)} / {tc(duration)}</div>
|
||||
<div className="flex-1" />
|
||||
<button onClick={splitAtPlayhead} className={clsx(btn, "bg-surface hover:bg-card inline-flex items-center gap-1.5")}>
|
||||
<SplitSquareHorizontal className="w-4 h-4" /> {t("editor.splitHere")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* timeline: filmstrip + segments (keep/drop tint) + markers + playhead + hover preview */}
|
||||
<div className="relative">
|
||||
<div
|
||||
ref={trackRef}
|
||||
className="relative h-14 rounded-lg overflow-hidden bg-surface border border-border cursor-pointer"
|
||||
onPointerDown={(e) => {
|
||||
setDrag({ type: "seek" });
|
||||
seek(timeAt(e.clientX));
|
||||
}}
|
||||
onPointerMove={(e) => setHoverX(e.clientX)}
|
||||
onPointerLeave={() => setHoverX(null)}
|
||||
>
|
||||
{/* aspect-correct filmstrip */}
|
||||
{stripCells > 0 && (
|
||||
<div className="absolute inset-0 flex pointer-events-none">
|
||||
{Array.from({ length: stripCells }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-full flex-1"
|
||||
style={tileStyle(Math.round(((i + 0.5) / stripCells) * ((strip!.count ?? 1) - 1)))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* segment tint: dropped = dimmed + hatched */}
|
||||
{segments.map((s, i) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className={clsx(
|
||||
"absolute inset-y-0 border-r border-black/40 last:border-r-0",
|
||||
s.keep ? "bg-accent/10" : "bg-black/60",
|
||||
s.id === selId && "ring-2 ring-inset ring-white/70"
|
||||
)}
|
||||
style={{
|
||||
left: pct(s.start),
|
||||
width: pct(s.end - s.start),
|
||||
...(s.keep
|
||||
? {}
|
||||
: {
|
||||
backgroundImage:
|
||||
"repeating-linear-gradient(45deg, rgba(0,0,0,0.55) 0 6px, rgba(0,0,0,0.25) 6px 12px)",
|
||||
}),
|
||||
}}
|
||||
onPointerDown={() => setSelId(s.id)}
|
||||
title={s.keep ? t("editor.keptSeg") : t("editor.droppedSeg")}
|
||||
>
|
||||
{/* keep/drop toggle */}
|
||||
<button
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleKeep(s.id);
|
||||
}}
|
||||
className={clsx(
|
||||
"absolute top-1 left-1 p-0.5 rounded bg-black/50 backdrop-blur-sm",
|
||||
s.keep ? "text-accent" : "text-muted"
|
||||
)}
|
||||
title={s.keep ? t("editor.drop") : t("editor.keep")}
|
||||
>
|
||||
{s.keep ? <Eye className="w-3.5 h-3.5" /> : <EyeOff className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{/* draggable boundary markers */}
|
||||
{boundaries.map((b, bi) => (
|
||||
<div key={bi} className="absolute inset-y-0 -ml-1.5 w-3 flex items-center justify-center z-10" style={{ left: pct(b) }}>
|
||||
<div
|
||||
className="w-1 h-full bg-white/90 cursor-ew-resize rounded"
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation();
|
||||
setDrag({ type: "boundary", bi });
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteBoundary(bi);
|
||||
}}
|
||||
className="absolute -top-0 -right-2 p-0.5 rounded-full bg-black/70 text-muted hover:text-red-400"
|
||||
title={t("editor.removeCut")}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{/* playhead */}
|
||||
<div className="absolute inset-y-0 w-0.5 bg-white pointer-events-none z-20" style={{ left: pct(current) }} />
|
||||
</div>
|
||||
|
||||
{/* hover-scrub thumbnail */}
|
||||
{strip && hoverTime != null && (
|
||||
<div
|
||||
className="absolute bottom-full mb-2 pointer-events-none z-30 rounded-md overflow-hidden border border-border shadow-xl bg-black"
|
||||
style={{ left: hoverLeft, width: HOVER_W }}
|
||||
>
|
||||
<div style={{ width: HOVER_W, height: HOVER_W / aspect, ...tileStyle(Math.floor(hoverTime / (strip.interval_s || 1))) }} />
|
||||
<div className="text-[10px] text-center text-muted py-0.5 tabular-nums">{tc(hoverTime)}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* selected-segment editor */}
|
||||
{sel && (
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm rounded-lg bg-card/40 px-3 py-2">
|
||||
<span className="font-medium">{t("editor.segment", { n: selIdx + 1 })}</span>
|
||||
<button
|
||||
onClick={() => toggleKeep(sel.id)}
|
||||
className={clsx(
|
||||
"inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium",
|
||||
sel.keep ? "bg-accent/15 text-accent" : "bg-surface text-muted"
|
||||
)}
|
||||
>
|
||||
{sel.keep ? <Eye className="w-3.5 h-3.5" /> : <EyeOff className="w-3.5 h-3.5" />}
|
||||
{sel.keep ? t("editor.kept") : t("editor.dropped")}
|
||||
</button>
|
||||
<div className="flex-1" />
|
||||
<label className="flex items-center gap-1 text-muted">
|
||||
{t("editor.in")}
|
||||
<input
|
||||
defaultValue={tc(sel.start)}
|
||||
key={`s-${sel.id}-${sel.start}`}
|
||||
disabled={selIdx === 0}
|
||||
onBlur={(e) => {
|
||||
const v = parseTc(e.target.value);
|
||||
if (v != null && selIdx > 0) moveBoundary(selIdx - 1, v);
|
||||
}}
|
||||
className={numCls}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-1 text-muted">
|
||||
{t("editor.out")}
|
||||
<input
|
||||
defaultValue={tc(sel.end)}
|
||||
key={`e-${sel.id}-${sel.end}`}
|
||||
disabled={selIdx === segments.length - 1}
|
||||
onBlur={(e) => {
|
||||
const v = parseTc(e.target.value);
|
||||
if (v != null && selIdx < segments.length - 1) moveBoundary(selIdx, v);
|
||||
}}
|
||||
className={numCls}
|
||||
/>
|
||||
</label>
|
||||
<span className="text-xs text-muted tabular-nums w-16 text-right">{tc(sel.end - sel.start)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* output mode + summary */}
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="text-muted">{t("editor.output.label")}:</span>
|
||||
{(["separate", "join"] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => setOutput(m)}
|
||||
disabled={m === "join" && kept.length < 2}
|
||||
className={clsx(
|
||||
"px-3 py-1.5 rounded-lg border transition disabled:opacity-40",
|
||||
output === m ? "border-accent bg-accent/10 text-accent" : "border-border text-muted hover:text-fg"
|
||||
)}
|
||||
>
|
||||
{t(`editor.output.${m}`)}
|
||||
</button>
|
||||
))}
|
||||
<div className="flex-1" />
|
||||
<span className="text-xs text-muted">
|
||||
{t("editor.keptSummary", { n: kept.length, dur: tc(keptDur) })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* crop + precision */}
|
||||
<div className="grid sm:grid-cols-2 gap-3">
|
||||
<button
|
||||
onClick={() => setCropOn((v) => !v)}
|
||||
className={clsx(
|
||||
"flex items-center gap-2 px-3 py-2 rounded-lg border text-sm transition",
|
||||
cropOn ? "border-accent text-accent bg-accent/10" : "border-border text-muted hover:text-fg"
|
||||
)}
|
||||
>
|
||||
<Crop className="w-4 h-4" /> {cropOn ? t("editor.cropOn") : t("editor.cropOff")}
|
||||
</button>
|
||||
{!cropOn ? (
|
||||
<div className="flex gap-2">
|
||||
{(["accurate", "fast"] as const).map((m) => {
|
||||
const on = (m === "accurate") === accurate;
|
||||
return (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => setAccurate(m === "accurate")}
|
||||
className={clsx(
|
||||
"flex-1 px-3 py-2 rounded-lg border text-left transition",
|
||||
on ? "border-accent bg-accent/10" : "border-border hover:border-muted"
|
||||
)}
|
||||
title={t(`editor.${m}.hint`)}
|
||||
>
|
||||
<div className={clsx("text-sm font-medium", on ? "text-accent" : "text-fg")}>{t(`editor.${m}.label`)}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center px-3 text-xs text-muted">{t("editor.cropReencode")}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">{t("editor.nameLabel")}</label>
|
||||
<input value={name} onChange={(e) => setName(e.target.value)} placeholder={defaultName} className={inputCls} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<span className="text-xs text-muted">{reencode ? t("editor.willReencode") : t("editor.willCopy")}</span>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={onClose} className={clsx(btn, "text-muted hover:text-fg hover:bg-surface")}>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => create.mutate()}
|
||||
disabled={!valid || create.isPending}
|
||||
className={clsx(btn, "bg-accent text-accent-fg hover:opacity-90 inline-flex items-center gap-1.5")}
|
||||
>
|
||||
{create.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Scissors className="w-4 h-4" />}
|
||||
{willJoin
|
||||
? t("editor.createJoin")
|
||||
: kept.length > 1
|
||||
? t("editor.createN", { count: kept.length })
|
||||
: t("editor.create")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
178
frontend/src/components/WatchPage.tsx
Normal file
178
frontend/src/components/WatchPage.tsx
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { Download, Lock, PlayCircle } from "lucide-react";
|
||||
|
||||
// Standalone, login-free video page for a shared /watch/<token> link. Rendered OUTSIDE the app
|
||||
// tree (no auth, no providers) — like the /privacy and /terms pages. Uses plain fetch and a tiny
|
||||
// self-contained i18n dict (the public viewer has no app language preference).
|
||||
|
||||
type Meta = {
|
||||
needs_password: boolean;
|
||||
title?: string | null;
|
||||
uploader?: string | null;
|
||||
duration_s?: number | null;
|
||||
allow_download?: boolean;
|
||||
file_url?: string;
|
||||
};
|
||||
|
||||
const STR = {
|
||||
en: {
|
||||
loading: "Loading…",
|
||||
gone: "This link is no longer available.",
|
||||
goneHint: "It may have expired or been revoked by the owner.",
|
||||
passwordTitle: "This video is password-protected",
|
||||
passwordPlaceholder: "Enter password",
|
||||
watch: "Watch",
|
||||
wrong: "Wrong password.",
|
||||
download: "Download",
|
||||
brand: "Shared via Siftlode",
|
||||
},
|
||||
hu: {
|
||||
loading: "Betöltés…",
|
||||
gone: "Ez a link már nem elérhető.",
|
||||
goneHint: "Lehet, hogy lejárt, vagy a tulajdonos visszavonta.",
|
||||
passwordTitle: "Ez a videó jelszóval védett",
|
||||
passwordPlaceholder: "Add meg a jelszót",
|
||||
watch: "Megnézem",
|
||||
wrong: "Hibás jelszó.",
|
||||
download: "Letöltés",
|
||||
brand: "Megosztva a Siftlode-dal",
|
||||
},
|
||||
de: {
|
||||
loading: "Wird geladen…",
|
||||
gone: "Dieser Link ist nicht mehr verfügbar.",
|
||||
goneHint: "Er ist möglicherweise abgelaufen oder wurde widerrufen.",
|
||||
passwordTitle: "Dieses Video ist passwortgeschützt",
|
||||
passwordPlaceholder: "Passwort eingeben",
|
||||
watch: "Ansehen",
|
||||
wrong: "Falsches Passwort.",
|
||||
download: "Herunterladen",
|
||||
brand: "Geteilt über Siftlode",
|
||||
},
|
||||
} as const;
|
||||
|
||||
const lang = ((navigator.language || "en").slice(0, 2) as keyof typeof STR) in STR
|
||||
? ((navigator.language || "en").slice(0, 2) as keyof typeof STR)
|
||||
: "en";
|
||||
const T = STR[lang];
|
||||
|
||||
export default function WatchPage() {
|
||||
const token = window.location.pathname.split("/watch/")[1]?.split(/[/?#]/)[0] ?? "";
|
||||
const [status, setStatus] = useState<"loading" | "ready" | "password" | "gone">("loading");
|
||||
const [meta, setMeta] = useState<Meta | null>(null);
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [unlocking, setUnlocking] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setStatus("gone");
|
||||
return;
|
||||
}
|
||||
fetch(`/api/public/watch/${encodeURIComponent(token)}`)
|
||||
.then((r) => (r.ok ? r.json() : Promise.reject(r.status)))
|
||||
.then((m: Meta) => {
|
||||
if (m.needs_password) setStatus("password");
|
||||
else {
|
||||
setMeta(m);
|
||||
setStatus("ready");
|
||||
}
|
||||
})
|
||||
.catch(() => setStatus("gone"));
|
||||
}, [token]);
|
||||
|
||||
const unlock = async () => {
|
||||
setUnlocking(true);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await fetch(`/api/public/watch/${encodeURIComponent(token)}/unlock`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
if (r.status === 403) {
|
||||
setError(T.wrong);
|
||||
return;
|
||||
}
|
||||
if (!r.ok) {
|
||||
setStatus("gone");
|
||||
return;
|
||||
}
|
||||
setMeta(await r.json());
|
||||
setStatus("ready");
|
||||
} finally {
|
||||
setUnlocking(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0b0f14] text-slate-100 flex flex-col items-center">
|
||||
<div className="w-full max-w-4xl px-4 py-6 flex-1 flex flex-col">
|
||||
<div className="flex items-center gap-2 mb-5 text-sm text-slate-400">
|
||||
<PlayCircle className="w-5 h-5 text-teal-400" />
|
||||
<span className="font-semibold text-slate-200">Siftlode</span>
|
||||
</div>
|
||||
|
||||
{status === "loading" && <div className="flex-1 grid place-items-center text-slate-400">{T.loading}</div>}
|
||||
|
||||
{status === "gone" && (
|
||||
<div className="flex-1 grid place-items-center text-center">
|
||||
<div>
|
||||
<div className="text-lg font-semibold">{T.gone}</div>
|
||||
<div className="text-sm text-slate-400 mt-1">{T.goneHint}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "password" && (
|
||||
<div className="flex-1 grid place-items-center">
|
||||
<div className="w-full max-w-sm rounded-2xl bg-slate-900/60 border border-slate-700 p-6">
|
||||
<div className="flex items-center gap-2 font-medium mb-4">
|
||||
<Lock className="w-4 h-4 text-teal-400" /> {T.passwordTitle}
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
autoFocus
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && password && unlock()}
|
||||
placeholder={T.passwordPlaceholder}
|
||||
className="w-full rounded-lg bg-slate-800 border border-slate-700 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-teal-500/40"
|
||||
/>
|
||||
{error && <div className="text-sm text-red-400 mt-2">{error}</div>}
|
||||
<button
|
||||
onClick={unlock}
|
||||
disabled={!password || unlocking}
|
||||
className="mt-4 w-full rounded-lg bg-teal-600 hover:bg-teal-500 disabled:opacity-50 py-2 text-sm font-medium transition"
|
||||
>
|
||||
{T.watch}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "ready" && meta && (
|
||||
<div>
|
||||
<div className="rounded-xl overflow-hidden bg-black border border-slate-800">
|
||||
<video controls playsInline src={meta.file_url} className="w-full max-h-[75vh] mx-auto block" />
|
||||
</div>
|
||||
<div className="mt-3 flex items-start gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h1 className="text-lg font-semibold leading-snug">{meta.title || "Video"}</h1>
|
||||
{meta.uploader && <div className="text-sm text-slate-400">{meta.uploader}</div>}
|
||||
</div>
|
||||
{meta.allow_download && (
|
||||
<a
|
||||
href={meta.file_url}
|
||||
className="shrink-0 inline-flex items-center gap-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 px-3 py-2 text-sm font-medium transition"
|
||||
>
|
||||
<Download className="w-4 h-4" /> {T.download}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-slate-600 py-4">{T.brand}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -45,7 +45,8 @@
|
|||
"thumbnail": "Vorschaubild einbetten",
|
||||
"sponsorblock": "Sponsoren entfernen",
|
||||
"metadata": "Metadaten schreiben",
|
||||
"processing": "Verarbeitung"
|
||||
"processing": "Verarbeitung",
|
||||
"editing": "Bearbeitung"
|
||||
},
|
||||
"actions": {
|
||||
"pause": "Pause",
|
||||
|
|
@ -55,7 +56,9 @@
|
|||
"retry": "Erneut",
|
||||
"download": "Auf mein Gerät speichern",
|
||||
"rename": "Umbenennen",
|
||||
"share": "Teilen"
|
||||
"share": "Teilen",
|
||||
"edit": "Bearbeiten / schneiden",
|
||||
"removeShared": "Aus meiner Liste entfernen"
|
||||
},
|
||||
"empty": {
|
||||
"queue": "Die Warteschlange ist leer.",
|
||||
|
|
@ -79,13 +82,38 @@
|
|||
"label": "E-Mail des Empfängers",
|
||||
"placeholder": "user@example.com",
|
||||
"submit": "Teilen",
|
||||
"done": "Geteilt mit {{email}}"
|
||||
"done": "Geteilt mit {{email}}",
|
||||
"failed": "Teilen fehlgeschlagen.",
|
||||
"userSection": "Mit einem Nutzer teilen",
|
||||
"userHint": "Erscheint bei ihm unter „Mit mir geteilt”.",
|
||||
"pickPlaceholder": "Nutzer suchen…",
|
||||
"noUsers": "Keine weiteren Nutzer.",
|
||||
"linkSection": "Per Link teilen",
|
||||
"linkHint": "Jeder mit dem Link kann zusehen — ohne Konto.",
|
||||
"noLinks": "Noch keine Links.",
|
||||
"newLink": "Link erstellen",
|
||||
"createLink": "Erstellen",
|
||||
"allowDownload": "Download erlauben",
|
||||
"expiry": "Läuft ab",
|
||||
"expiryNever": "Nie",
|
||||
"days": "{{n}} Tage",
|
||||
"password": "Passwort (optional)",
|
||||
"copy": "Link kopieren",
|
||||
"copyFailed": "Kopieren fehlgeschlagen.",
|
||||
"revoke": "Widerrufen",
|
||||
"revokeConfirm": "Der Link funktioniert sofort nicht mehr.",
|
||||
"views": "{{n}} Aufrufe",
|
||||
"expiresOn": "läuft ab {{date}}",
|
||||
"hasPassword": "Passwort",
|
||||
"downloadable": "herunterladbar"
|
||||
},
|
||||
"confirm": {
|
||||
"cancelTitle": "Download abbrechen?",
|
||||
"cancelBody": "Dies stoppt den Download und entfernt ihn aus deiner Warteschlange.",
|
||||
"deleteTitle": "Download entfernen?",
|
||||
"deleteBody": "Dies entfernt ihn aus deiner Liste. Die Datei kann bis zum Ablauf für andere Nutzer im Cache bleiben."
|
||||
"deleteBody": "Dies entfernt ihn aus deiner Liste. Die Datei kann bis zum Ablauf für andere Nutzer im Cache bleiben.",
|
||||
"removeSharedTitle": "Aus deiner Liste entfernen?",
|
||||
"removeSharedBody": "Entfernt es nur aus deiner geteilten Liste — die Datei des Besitzers wird nicht gelöscht."
|
||||
},
|
||||
"profiles": {
|
||||
"manage": "Formate verwalten",
|
||||
|
|
@ -132,7 +160,9 @@
|
|||
"reset": "Auf Standard zurücksetzen",
|
||||
"save": "Speichern",
|
||||
"custom": "individuell",
|
||||
"default": "Standard"
|
||||
"default": "Standard",
|
||||
"quotaPickPlaceholder": "Kontingent für einen Nutzer festlegen…",
|
||||
"noFootprint": "Noch keine Downloads."
|
||||
},
|
||||
"cols": {
|
||||
"title": "Titel",
|
||||
|
|
@ -142,5 +172,6 @@
|
|||
"status": "Status",
|
||||
"added": "Hinzugefügt",
|
||||
"user": "Nutzer"
|
||||
}
|
||||
},
|
||||
"clipBadge": "Clip"
|
||||
}
|
||||
|
|
|
|||
45
frontend/src/i18n/locales/de/editor.json
Normal file
45
frontend/src/i18n/locales/de/editor.json
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
{
|
||||
"title": "Bearbeiten: „{{name}}“",
|
||||
"playPause": "Wiedergabe / Pause",
|
||||
"setIn": "Start hier",
|
||||
"setOut": "Ende hier",
|
||||
"in": "Start",
|
||||
"out": "Ende",
|
||||
"selected": "Auswahl",
|
||||
"cropOff": "Zuschneiden",
|
||||
"cropOn": "Zuschneiden aktiv",
|
||||
"cropReencode": "Zuschneiden kodiert immer neu (bildgenau).",
|
||||
"split": "Aufteilen in",
|
||||
"accurate": {
|
||||
"label": "Genauer Schnitt",
|
||||
"hint": "Bildgenau. Kodiert den Clip neu."
|
||||
},
|
||||
"fast": {
|
||||
"label": "Schneller Schnitt",
|
||||
"hint": "Sofort, schneidet aber an Keyframes (±1–2 s)."
|
||||
},
|
||||
"nameLabel": "Clip-Name (optional)",
|
||||
"willReencode": "Wird neu kodiert",
|
||||
"willCopy": "Sofort (Stream-Kopie)",
|
||||
"create": "Clip erstellen",
|
||||
"createN": "{{count}} Clips erstellen",
|
||||
"created_one": "{{count}} Clip zu deinen Downloads hinzugefügt",
|
||||
"created_other": "{{count}} Clips zu deinen Downloads hinzugefügt",
|
||||
"failed": "Clip konnte nicht erstellt werden.",
|
||||
"splitHere": "Hier teilen",
|
||||
"output": {
|
||||
"label": "Ausgabe",
|
||||
"separate": "Einzelne Dateien",
|
||||
"join": "Zu einer zusammenfügen"
|
||||
},
|
||||
"segment": "Segment {{n}}",
|
||||
"keep": "Behalten",
|
||||
"drop": "Verwerfen",
|
||||
"kept": "Behalten",
|
||||
"dropped": "Verworfen",
|
||||
"keptSeg": "Behaltenes Segment",
|
||||
"droppedSeg": "Verworfenes Segment",
|
||||
"removeCut": "Schnitt entfernen",
|
||||
"keptSummary": "{{n}} behalten · {{dur}}",
|
||||
"createJoin": "Zusammengefügten Clip erstellen"
|
||||
}
|
||||
|
|
@ -67,7 +67,8 @@
|
|||
"subscriptions": "Importiert die YouTube-Abos jedes Nutzers neu. Fällt er aus, werden auf YouTube hinzugefügte oder entfernte Abos hier nicht übernommen.",
|
||||
"playlist_sync": "Spiegelt die YouTube-Wiedergabelisten jedes Nutzers in die App. Fällt er aus, erscheinen Änderungen an deinen YouTube-Listen lokal nicht.",
|
||||
"maintenance": "Findet Videos, die nicht mehr abspielbar sind (gelöscht, auf privat gesetzt, abgebrochene Premieren), blendet sie aus dem Feed aus und entfernt sie nach einer Schonfrist; prüft die am längsten nicht geprüften Videos erneut. Fällt er aus, bleiben tote Videos im Katalog.",
|
||||
"explore_cleanup": "Entfernt Kanäle, die du erkundet, aber nie abonniert hast (und ihre Videos), nach einer Karenzzeit, damit Neugier-Stöbern den Katalog nicht überfüllt. Wenn es stoppt, bleiben verlassene erkundete Kanäle bestehen."
|
||||
"explore_cleanup": "Entfernt Kanäle, die du erkundet, aber nie abonniert hast (und ihre Videos), nach einer Karenzzeit, damit Neugier-Stöbern den Katalog nicht überfüllt. Wenn es stoppt, bleiben verlassene erkundete Kanäle bestehen.",
|
||||
"download_gc": "Löscht heruntergeladene Dateien nach Ablauf der Aufbewahrungsfrist, gibt nicht mehr belegten Speicher frei und warnt vor dem Ablauf einer geteilten Datei. Fällt er aus, häufen sich alte Downloads und Speicherplatz wird nicht freigegeben."
|
||||
},
|
||||
"jobs": {
|
||||
"rss_poll": "RSS-Abfrage (neue Uploads)",
|
||||
|
|
@ -79,7 +80,8 @@
|
|||
"playlist_sync": "YouTube-Wiedergabelisten-Sync",
|
||||
"maintenance": "Wartung + Validierung",
|
||||
"demo_reset": "Demo-Konto-Reset",
|
||||
"explore_cleanup": "Bereinigung erkundeter Kanäle"
|
||||
"explore_cleanup": "Bereinigung erkundeter Kanäle",
|
||||
"download_gc": "Download-Bereinigung"
|
||||
},
|
||||
"queue": {
|
||||
"recentPending": "Zu synchronisierende Kanäle",
|
||||
|
|
|
|||
|
|
@ -45,7 +45,8 @@
|
|||
"thumbnail": "Embedding thumbnail",
|
||||
"sponsorblock": "Removing sponsors",
|
||||
"metadata": "Writing metadata",
|
||||
"processing": "Processing"
|
||||
"processing": "Processing",
|
||||
"editing": "Editing"
|
||||
},
|
||||
"actions": {
|
||||
"pause": "Pause",
|
||||
|
|
@ -55,7 +56,9 @@
|
|||
"retry": "Retry",
|
||||
"download": "Save to my device",
|
||||
"rename": "Rename",
|
||||
"share": "Share"
|
||||
"share": "Share",
|
||||
"edit": "Edit / trim",
|
||||
"removeShared": "Remove from my list"
|
||||
},
|
||||
"empty": {
|
||||
"queue": "Nothing in the queue.",
|
||||
|
|
@ -79,13 +82,38 @@
|
|||
"label": "Recipient email",
|
||||
"placeholder": "user@example.com",
|
||||
"submit": "Share",
|
||||
"done": "Shared with {{email}}"
|
||||
"done": "Shared with {{email}}",
|
||||
"failed": "Couldn’t share.",
|
||||
"userSection": "Share with a user",
|
||||
"userHint": "They’ll find it under “Shared with me”.",
|
||||
"pickPlaceholder": "Search a user…",
|
||||
"noUsers": "No other users yet.",
|
||||
"linkSection": "Share a link",
|
||||
"linkHint": "Anyone with the link can watch — no account needed.",
|
||||
"noLinks": "No links yet.",
|
||||
"newLink": "Create a link",
|
||||
"createLink": "Create link",
|
||||
"allowDownload": "Allow download",
|
||||
"expiry": "Expires",
|
||||
"expiryNever": "Never",
|
||||
"days": "{{n}} days",
|
||||
"password": "Password (optional)",
|
||||
"copy": "Copy link",
|
||||
"copyFailed": "Couldn’t copy.",
|
||||
"revoke": "Revoke",
|
||||
"revokeConfirm": "This link will stop working immediately.",
|
||||
"views": "{{n}} views",
|
||||
"expiresOn": "expires {{date}}",
|
||||
"hasPassword": "password",
|
||||
"downloadable": "downloadable"
|
||||
},
|
||||
"confirm": {
|
||||
"cancelTitle": "Cancel download?",
|
||||
"cancelBody": "This stops the download and removes it from your queue.",
|
||||
"deleteTitle": "Remove download?",
|
||||
"deleteBody": "This removes it from your list. The file may stay cached for other users until it expires."
|
||||
"deleteBody": "This removes it from your list. The file may stay cached for other users until it expires.",
|
||||
"removeSharedTitle": "Remove from your list?",
|
||||
"removeSharedBody": "This removes it from your shared list only — it won't delete the owner's file."
|
||||
},
|
||||
"profiles": {
|
||||
"manage": "Manage formats",
|
||||
|
|
@ -132,7 +160,9 @@
|
|||
"reset": "Reset to default",
|
||||
"save": "Save",
|
||||
"custom": "custom",
|
||||
"default": "default"
|
||||
"default": "default",
|
||||
"quotaPickPlaceholder": "Set a user's quota…",
|
||||
"noFootprint": "No downloads yet."
|
||||
},
|
||||
"cols": {
|
||||
"title": "Title",
|
||||
|
|
@ -142,5 +172,6 @@
|
|||
"status": "Status",
|
||||
"added": "Added",
|
||||
"user": "User"
|
||||
}
|
||||
},
|
||||
"clipBadge": "Clip"
|
||||
}
|
||||
|
|
|
|||
45
frontend/src/i18n/locales/en/editor.json
Normal file
45
frontend/src/i18n/locales/en/editor.json
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
{
|
||||
"title": "Edit “{{name}}”",
|
||||
"playPause": "Play / pause",
|
||||
"setIn": "Set start",
|
||||
"setOut": "Set end",
|
||||
"in": "Start",
|
||||
"out": "End",
|
||||
"selected": "Selection",
|
||||
"cropOff": "Add crop",
|
||||
"cropOn": "Cropping",
|
||||
"cropReencode": "Cropping always re-encodes (frame-accurate).",
|
||||
"split": "Split into",
|
||||
"accurate": {
|
||||
"label": "Precise cut",
|
||||
"hint": "Frame-accurate. Re-encodes the clip."
|
||||
},
|
||||
"fast": {
|
||||
"label": "Fast cut",
|
||||
"hint": "Instant, but cuts snap to keyframes (±1–2s)."
|
||||
},
|
||||
"nameLabel": "Clip name (optional)",
|
||||
"willReencode": "Will re-encode",
|
||||
"willCopy": "Instant (stream copy)",
|
||||
"create": "Create clip",
|
||||
"createN": "Create {{count}} clips",
|
||||
"created_one": "{{count}} clip added to your downloads",
|
||||
"created_other": "{{count}} clips added to your downloads",
|
||||
"failed": "Couldn’t create the clip.",
|
||||
"splitHere": "Split here",
|
||||
"output": {
|
||||
"label": "Output",
|
||||
"separate": "Separate files",
|
||||
"join": "Join into one"
|
||||
},
|
||||
"segment": "Segment {{n}}",
|
||||
"keep": "Keep",
|
||||
"drop": "Drop",
|
||||
"kept": "Kept",
|
||||
"dropped": "Dropped",
|
||||
"keptSeg": "Kept segment",
|
||||
"droppedSeg": "Dropped segment",
|
||||
"removeCut": "Remove cut",
|
||||
"keptSummary": "{{n}} kept · {{dur}}",
|
||||
"createJoin": "Create joined clip"
|
||||
}
|
||||
|
|
@ -67,7 +67,8 @@
|
|||
"subscriptions": "Re-imports every user's YouTube subscriptions. If it stops, channels subscribed to or dropped on YouTube aren't reflected here.",
|
||||
"playlist_sync": "Mirrors each user's YouTube playlists into the app. If it stops, changes to your YouTube playlists don't show up locally.",
|
||||
"maintenance": "Finds videos that can no longer be played (deleted, made private, abandoned premieres), hides them from the feed and removes them after a grace period; re-checks the least-recently-checked videos. If it stops, dead videos linger in the catalogue.",
|
||||
"explore_cleanup": "Removes channels you explored but never subscribed to (and their videos) after a grace period, so curiosity browsing doesn't pile up in the catalogue. If it stops, abandoned explored channels linger."
|
||||
"explore_cleanup": "Removes channels you explored but never subscribed to (and their videos) after a grace period, so curiosity browsing doesn't pile up in the catalogue. If it stops, abandoned explored channels linger.",
|
||||
"download_gc": "Deletes downloaded files past their retention window, reclaims space no one is holding anymore, and warns owners before a shared file expires. If it stops, old downloads pile up and disk space isn't freed."
|
||||
},
|
||||
"jobs": {
|
||||
"rss_poll": "RSS poll (new uploads)",
|
||||
|
|
@ -79,7 +80,8 @@
|
|||
"playlist_sync": "YouTube playlist sync",
|
||||
"maintenance": "Maintenance + validation",
|
||||
"demo_reset": "Demo account reset",
|
||||
"explore_cleanup": "Explored-channel cleanup"
|
||||
"explore_cleanup": "Explored-channel cleanup",
|
||||
"download_gc": "Download cleanup"
|
||||
},
|
||||
"queue": {
|
||||
"recentPending": "Channels to sync",
|
||||
|
|
|
|||
|
|
@ -45,7 +45,8 @@
|
|||
"thumbnail": "Bélyegkép beágyazása",
|
||||
"sponsorblock": "Szponzorok eltávolítása",
|
||||
"metadata": "Metaadat írása",
|
||||
"processing": "Feldolgozás"
|
||||
"processing": "Feldolgozás",
|
||||
"editing": "Szerkesztés"
|
||||
},
|
||||
"actions": {
|
||||
"pause": "Szünet",
|
||||
|
|
@ -55,7 +56,9 @@
|
|||
"retry": "Újra",
|
||||
"download": "Mentés a gépemre",
|
||||
"rename": "Átnevezés",
|
||||
"share": "Megosztás"
|
||||
"share": "Megosztás",
|
||||
"edit": "Szerkesztés / vágás",
|
||||
"removeShared": "Eltávolítás a listámból"
|
||||
},
|
||||
"empty": {
|
||||
"queue": "A sor üres.",
|
||||
|
|
@ -79,13 +82,38 @@
|
|||
"label": "Címzett e-mail címe",
|
||||
"placeholder": "user@example.com",
|
||||
"submit": "Megosztás",
|
||||
"done": "Megosztva vele: {{email}}"
|
||||
"done": "Megosztva vele: {{email}}",
|
||||
"failed": "A megosztás nem sikerült.",
|
||||
"userSection": "Megosztás felhasználóval",
|
||||
"userHint": "A „Velem megosztott” fülön találja meg.",
|
||||
"pickPlaceholder": "Keress egy felhasználót…",
|
||||
"noUsers": "Nincs más felhasználó.",
|
||||
"linkSection": "Megosztás linkkel",
|
||||
"linkHint": "A link birtokában bárki megnézheti — fiók nélkül is.",
|
||||
"noLinks": "Még nincs link.",
|
||||
"newLink": "Link létrehozása",
|
||||
"createLink": "Létrehozás",
|
||||
"allowDownload": "Letöltés engedélyezése",
|
||||
"expiry": "Lejárat",
|
||||
"expiryNever": "Soha",
|
||||
"days": "{{n}} nap",
|
||||
"password": "Jelszó (opcionális)",
|
||||
"copy": "Link másolása",
|
||||
"copyFailed": "Nem sikerült a másolás.",
|
||||
"revoke": "Visszavonás",
|
||||
"revokeConfirm": "A link azonnal használhatatlanná válik.",
|
||||
"views": "{{n}} megtekintés",
|
||||
"expiresOn": "lejár: {{date}}",
|
||||
"hasPassword": "jelszó",
|
||||
"downloadable": "letölthető"
|
||||
},
|
||||
"confirm": {
|
||||
"cancelTitle": "Megszakítod a letöltést?",
|
||||
"cancelBody": "Ez leállítja a letöltést és eltávolítja a sorból.",
|
||||
"deleteTitle": "Eltávolítod a letöltést?",
|
||||
"deleteBody": "Ez eltávolítja a listádról. A fájl a lejáratáig még a gyorsítótárban maradhat mások számára."
|
||||
"deleteBody": "Ez eltávolítja a listádról. A fájl a lejáratáig még a gyorsítótárban maradhat mások számára.",
|
||||
"removeSharedTitle": "Eltávolítod a listádból?",
|
||||
"removeSharedBody": "Csak a te megosztott listádból tűnik el — a tulajdonos fájlját nem törli."
|
||||
},
|
||||
"profiles": {
|
||||
"manage": "Formátumok kezelése",
|
||||
|
|
@ -132,7 +160,9 @@
|
|||
"reset": "Visszaállítás alapértékre",
|
||||
"save": "Mentés",
|
||||
"custom": "egyedi",
|
||||
"default": "alapértelmezett"
|
||||
"default": "alapértelmezett",
|
||||
"quotaPickPlaceholder": "Kvóta beállítása egy felhasználónak…",
|
||||
"noFootprint": "Még nincs letöltés."
|
||||
},
|
||||
"cols": {
|
||||
"title": "Cím",
|
||||
|
|
@ -142,5 +172,6 @@
|
|||
"status": "Állapot",
|
||||
"added": "Hozzáadva",
|
||||
"user": "Felhasználó"
|
||||
}
|
||||
},
|
||||
"clipBadge": "Klip"
|
||||
}
|
||||
|
|
|
|||
45
frontend/src/i18n/locales/hu/editor.json
Normal file
45
frontend/src/i18n/locales/hu/editor.json
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
{
|
||||
"title": "Szerkesztés: „{{name}}”",
|
||||
"playPause": "Lejátszás / szünet",
|
||||
"setIn": "Kezdet itt",
|
||||
"setOut": "Vég itt",
|
||||
"in": "Kezdet",
|
||||
"out": "Vég",
|
||||
"selected": "Kijelölés",
|
||||
"cropOff": "Vágókeret",
|
||||
"cropOn": "Vágókeret bekapcsolva",
|
||||
"cropReencode": "A vágókeret mindig újrakódol (frame-pontos).",
|
||||
"split": "Feldarabolás",
|
||||
"accurate": {
|
||||
"label": "Pontos vágás",
|
||||
"hint": "Frame-pontos. Újrakódolja a klipet."
|
||||
},
|
||||
"fast": {
|
||||
"label": "Gyors vágás",
|
||||
"hint": "Azonnali, de kulcskockára ugrik (±1–2 mp)."
|
||||
},
|
||||
"nameLabel": "Klip neve (opcionális)",
|
||||
"willReencode": "Újrakódolás lesz",
|
||||
"willCopy": "Azonnali (másolás)",
|
||||
"create": "Klip létrehozása",
|
||||
"createN": "{{count}} klip létrehozása",
|
||||
"created_one": "{{count}} klip a letöltésekhez adva",
|
||||
"created_other": "{{count}} klip a letöltésekhez adva",
|
||||
"failed": "A klip létrehozása nem sikerült.",
|
||||
"splitHere": "Vágás itt",
|
||||
"output": {
|
||||
"label": "Kimenet",
|
||||
"separate": "Külön fájlok",
|
||||
"join": "Egy fájlba fűzve"
|
||||
},
|
||||
"segment": "{{n}}. szegmens",
|
||||
"keep": "Megtart",
|
||||
"drop": "Eldob",
|
||||
"kept": "Megtartva",
|
||||
"dropped": "Eldobva",
|
||||
"keptSeg": "Megtartott szegmens",
|
||||
"droppedSeg": "Eldobott szegmens",
|
||||
"removeCut": "Vágás törlése",
|
||||
"keptSummary": "{{n}} megtartva · {{dur}}",
|
||||
"createJoin": "Összefűzött klip"
|
||||
}
|
||||
|
|
@ -67,7 +67,8 @@
|
|||
"subscriptions": "Újraimportálja minden felhasználó YouTube-feliratkozásait. Ha leáll, a YouTube-on felvett vagy törölt feliratkozások nem tükröződnek itt.",
|
||||
"playlist_sync": "Tükrözi a felhasználók YouTube lejátszási listáit az appba. Ha leáll, a YouTube-listák változásai nem jelennek meg lokálisan.",
|
||||
"maintenance": "Megkeresi a már sehol le nem játszható videókat (törölt, priváttá tett, elhagyott premier), elrejti őket a hírfolyamból, és türelmi idő után eltávolítja; újraellenőrzi a legrégebben ellenőrzött videókat. Ha leáll, a halott videók a katalógusban maradnak.",
|
||||
"explore_cleanup": "A türelmi idő után eltávolítja a felfedezett, de nem követett csatornákat (és videóikat), hogy a kíváncsiság-böngészés ne halmozódjon a katalógusban. Ha leáll, az elhagyott felfedezett csatornák megmaradnak."
|
||||
"explore_cleanup": "A türelmi idő után eltávolítja a felfedezett, de nem követett csatornákat (és videóikat), hogy a kíváncsiság-böngészés ne halmozódjon a katalógusban. Ha leáll, az elhagyott felfedezett csatornák megmaradnak.",
|
||||
"download_gc": "Törli a megőrzési időn túli letöltéseket, felszabadítja a már senki által nem használt helyet, és a lejárat előtt figyelmezteti a tulajdonost. Ha leáll, a régi letöltések felhalmozódnak, és a tárhely nem szabadul fel."
|
||||
},
|
||||
"jobs": {
|
||||
"rss_poll": "RSS-lekérdezés (új feltöltések)",
|
||||
|
|
@ -79,7 +80,8 @@
|
|||
"playlist_sync": "YouTube lejátszási listák szinkronja",
|
||||
"maintenance": "Karbantartás + ellenőrzés",
|
||||
"demo_reset": "Demo fiók visszaállítása",
|
||||
"explore_cleanup": "Felfedezett csatornák takarítása"
|
||||
"explore_cleanup": "Felfedezett csatornák takarítása",
|
||||
"download_gc": "Letöltések takarítása"
|
||||
},
|
||||
"queue": {
|
||||
"recentPending": "Szinkronra váró csatorna",
|
||||
|
|
|
|||
|
|
@ -667,6 +667,24 @@ export type DownloadStatus =
|
|||
| "error"
|
||||
| "canceled";
|
||||
|
||||
// Phase-2 editor recipe. A single `trim` (one output file) or a `segments` cut-list joined into
|
||||
// one file. `accurate` re-encodes for a frame-accurate cut (a crop always re-encodes).
|
||||
export interface EditSpec {
|
||||
trim?: { start_s: number; end_s?: number };
|
||||
segments?: { start_s: number; end_s: number }[];
|
||||
crop?: { x: number; y: number; w: number; h: number };
|
||||
accurate?: boolean;
|
||||
}
|
||||
|
||||
export interface StoryboardMeta {
|
||||
available: boolean;
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
count?: number;
|
||||
interval_s?: number;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface DownloadJob {
|
||||
id: number;
|
||||
status: DownloadStatus;
|
||||
|
|
@ -682,6 +700,9 @@ export interface DownloadJob {
|
|||
profile_id: number | null;
|
||||
spec: DownloadSpec;
|
||||
display_name: string | null;
|
||||
job_kind?: string; // "download" (default) | "edit"
|
||||
source_job_id?: number | null; // parent download an edit was cut from
|
||||
edit_spec?: EditSpec | null;
|
||||
can_download: boolean;
|
||||
expired: boolean;
|
||||
asset: DownloadAsset | null;
|
||||
|
|
@ -689,6 +710,23 @@ export interface DownloadJob {
|
|||
user_email?: string;
|
||||
}
|
||||
|
||||
export interface ShareRecipient {
|
||||
id: number;
|
||||
email: string;
|
||||
display_name: string | null;
|
||||
}
|
||||
|
||||
export interface ShareLink {
|
||||
id: number;
|
||||
token: string;
|
||||
url: string; // path like "/watch/<token>" — prepend window.location.origin to share
|
||||
allow_download: boolean;
|
||||
has_password: boolean;
|
||||
expires_at: string | null;
|
||||
view_count: number;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface DownloadUsage {
|
||||
footprint_bytes: number;
|
||||
active_jobs: number;
|
||||
|
|
@ -1027,8 +1065,19 @@ export const api = {
|
|||
display_name?: string;
|
||||
}): Promise<DownloadJob> =>
|
||||
req("/api/downloads", { method: "POST", body: JSON.stringify(body) }),
|
||||
enqueueEdit: (body: {
|
||||
source_job_id: number;
|
||||
edit_spec: EditSpec;
|
||||
display_name?: string;
|
||||
}): Promise<DownloadJob> =>
|
||||
req("/api/downloads/edit", { method: "POST", body: JSON.stringify(body) }),
|
||||
downloadStoryboard: (id: number): Promise<StoryboardMeta> =>
|
||||
req(`/api/downloads/${id}/storyboard`),
|
||||
downloads: (): Promise<DownloadJob[]> => req("/api/downloads"),
|
||||
downloadsShared: (): Promise<DownloadJob[]> => req("/api/downloads/shared"),
|
||||
// Remove a shared-with-me item from your list (deletes only your share grant, not the file).
|
||||
removeSharedDownload: (jobId: number): Promise<{ removed: number }> =>
|
||||
req(`/api/downloads/shared/${jobId}`, { method: "DELETE" }),
|
||||
downloadUsage: (): Promise<DownloadUsage> => req("/api/downloads/usage"),
|
||||
downloadIndex: (): Promise<Record<string, DownloadStatus>> => req("/api/downloads/index"),
|
||||
pauseDownload: (id: number): Promise<DownloadJob> =>
|
||||
|
|
@ -1044,6 +1093,22 @@ export const api = {
|
|||
req(`/api/downloads/${id}/share`, { method: "POST", body: JSON.stringify({ email }) }),
|
||||
unshareDownload: (id: number, email: string) =>
|
||||
req(`/api/downloads/${id}/share/${encodeURIComponent(email)}`, { method: "DELETE" }),
|
||||
// Registered users this user can share with (for the internal picker).
|
||||
shareRecipients: (): Promise<ShareRecipient[]> => req("/api/downloads/recipients"),
|
||||
// Public watch links (share by link).
|
||||
downloadLinks: (jobId: number): Promise<ShareLink[]> => req(`/api/downloads/${jobId}/links`),
|
||||
createDownloadLink: (
|
||||
jobId: number,
|
||||
body: { allow_download?: boolean; expires_days?: number | null; password?: string }
|
||||
): Promise<ShareLink> =>
|
||||
req(`/api/downloads/${jobId}/links`, { method: "POST", body: JSON.stringify(body) }),
|
||||
updateDownloadLink: (
|
||||
linkId: number,
|
||||
patch: { allow_download?: boolean; expires_days?: number | null; password?: string }
|
||||
): Promise<ShareLink> =>
|
||||
req(`/api/downloads/links/${linkId}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
||||
revokeDownloadLink: (linkId: number): Promise<{ deleted: number }> =>
|
||||
req(`/api/downloads/links/${linkId}`, { method: "DELETE" }),
|
||||
// A plain <a> navigation can't send the X-Siftlode-Account header, so pass the active
|
||||
// account via ?account= (same wallet-gated selection the WebSocket uses) — otherwise the
|
||||
// download resolves to the session-default account and 404s for a per-tab account's file.
|
||||
|
|
@ -1051,6 +1116,13 @@ export const api = {
|
|||
const a = getActiveAccount();
|
||||
return a != null ? `/api/downloads/${id}/file?account=${a}` : `/api/downloads/${id}/file`;
|
||||
},
|
||||
// Filmstrip sprite (an <img>/background src → direct nav, so it needs the ?account= wallet gate).
|
||||
storyboardImageUrl: (id: number): string => {
|
||||
const a = getActiveAccount();
|
||||
return a != null
|
||||
? `/api/downloads/${id}/storyboard.jpg?account=${a}`
|
||||
: `/api/downloads/${id}/storyboard.jpg`;
|
||||
},
|
||||
|
||||
// admin
|
||||
adminDownloads: (): Promise<DownloadJob[]> => req("/api/admin/downloads"),
|
||||
|
|
|
|||
32
frontend/src/lib/pageMeta.ts
Normal file
32
frontend/src/lib/pageMeta.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import type { Page } from "./urlState";
|
||||
|
||||
// The i18n key for a page's human title — shared by the top-bar header and the browser tab title
|
||||
// so the two never drift. Keep in sync with the nav modules in NavSidebar / App's page switch.
|
||||
export function pageTitleKey(page: Page): string {
|
||||
switch (page) {
|
||||
case "feed":
|
||||
return "header.account.feed";
|
||||
case "channels":
|
||||
return "header.channelManager";
|
||||
case "playlists":
|
||||
return "header.account.playlists";
|
||||
case "notifications":
|
||||
return "inbox.navLabel";
|
||||
case "messages":
|
||||
return "messages.navLabel";
|
||||
case "downloads":
|
||||
return "downloads.navLabel";
|
||||
case "stats":
|
||||
return "header.usageStats";
|
||||
case "scheduler":
|
||||
return "header.scheduler";
|
||||
case "config":
|
||||
return "header.configuration";
|
||||
case "users":
|
||||
return "header.users";
|
||||
case "settings":
|
||||
return "settings.title";
|
||||
default:
|
||||
return "header.account.feed";
|
||||
}
|
||||
}
|
||||
|
|
@ -14,6 +14,20 @@ export interface ReleaseEntry {
|
|||
}
|
||||
|
||||
export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
{
|
||||
version: "0.22.0",
|
||||
date: "2026-07-04",
|
||||
summary: "The Download Center — save videos to the server, edit them into clips, and share them.",
|
||||
features: [
|
||||
"Download Center: save any video (or a search result) to the server with yt-dlp, laid out Plex-style with a poster + info file. Choose a built-in format preset or make your own, keep an eye on your per-user storage quota, and save the finished file to your device.",
|
||||
"Built-in video editor: trim, crop, and cut a download into segments — keep the parts you want as separate clips or join them into one file. Pick a precise (frame-accurate) or fast cut, with a scrub timeline and a hover preview.",
|
||||
"Sharing: share a download with another user (it appears in their “Shared with me”, where they can edit their own copy or remove it), or hand out a public watch link that plays on a clean, login-free page — with an optional password, an expiry, and a stream-only vs downloadable toggle.",
|
||||
"Tidier video titles across the feed, search and downloads — clickbait ALL-CAPS and trailing hashtag clutter are cleaned up for display (the original title is kept underneath).",
|
||||
],
|
||||
fixes: [
|
||||
"The browser tab now carries an app icon and shows the section you're on; clicking the Siftlode logo returns you to the feed.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.21.0",
|
||||
date: "2026-07-02",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import ErrorBoundary from "./components/ErrorBoundary";
|
|||
import { ConfirmProvider } from "./components/ConfirmProvider";
|
||||
import PrivacyPolicy from "./components/legal/PrivacyPolicy";
|
||||
import Terms from "./components/legal/Terms";
|
||||
import WatchPage from "./components/WatchPage";
|
||||
import "./i18n";
|
||||
import "./index.css";
|
||||
|
||||
|
|
@ -19,7 +20,8 @@ const queryClient = new QueryClient({
|
|||
const path = window.location.pathname;
|
||||
const root =
|
||||
path === "/privacy" ? <PrivacyPolicy /> :
|
||||
path === "/terms" ? <Terms /> : (
|
||||
path === "/terms" ? <Terms /> :
|
||||
path.startsWith("/watch/") ? <WatchPage /> : (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ErrorBoundary>
|
||||
<ConfirmProvider>
|
||||
|
|
|
|||
|
|
@ -28,6 +28,10 @@ POSTGRES_PASSWORD=$pgPass
|
|||
SECRET_KEY=$secretKey
|
||||
TOKEN_ENCRYPTION_KEY=$fernet
|
||||
OAUTH_REDIRECT_URL=$url/auth/callback
|
||||
|
||||
# Optional: store Download Center media in a host folder (e.g. one your Plex server reads) instead
|
||||
# of a Docker volume. Must be writable by uid 1000 (chown -R 1000:1000 <dir>).
|
||||
# DOWNLOAD_HOST_PATH=/mnt/media/youtube
|
||||
"@ | Set-Content -Path .env -Encoding ascii
|
||||
Write-Host "Wrote .env (secrets generated)."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,10 @@ POSTGRES_PASSWORD=$POSTGRES_PASSWORD
|
|||
SECRET_KEY=$SECRET_KEY
|
||||
TOKEN_ENCRYPTION_KEY=$TOKEN_ENCRYPTION_KEY
|
||||
OAUTH_REDIRECT_URL=$URL/auth/callback
|
||||
|
||||
# Optional: store Download Center media in a host folder (e.g. one your Plex server reads) instead
|
||||
# of a Docker volume. Must be writable by uid 1000 (sudo chown -R 1000:1000 <dir>).
|
||||
# DOWNLOAD_HOST_PATH=/mnt/media/youtube
|
||||
EOF
|
||||
chmod 600 .env
|
||||
echo "Wrote .env (secrets generated)."
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue