Merge: promote dev to prod
This commit is contained in:
commit
8eb49946de
72 changed files with 6942 additions and 48 deletions
|
|
@ -54,3 +54,11 @@ SMTP_FROM=
|
||||||
# more than one instance against the same database, keep SCHEDULER_ENABLED=true on exactly one
|
# 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.
|
# of them and false on the rest, to avoid double quota use and write races.
|
||||||
SCHEDULER_ENABLED=true
|
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
|
||||||
|
|
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -21,6 +21,10 @@ frontend/dist/
|
||||||
*.sql.gz
|
*.sql.gz
|
||||||
backups/
|
backups/
|
||||||
|
|
||||||
|
# Download center: locally downloaded media (the DOWNLOAD_ROOT bind mount at the repo root).
|
||||||
|
# Anchored with a leading slash so it does NOT also ignore the backend/app/downloads package.
|
||||||
|
/downloads/
|
||||||
|
|
||||||
# OS / editor
|
# OS / editor
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
|
||||||
20
Dockerfile
20
Dockerfile
|
|
@ -34,6 +34,18 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# ffmpeg: merge separate video+audio streams + postprocessing. Deno: the JavaScript runtime
|
||||||
|
# yt-dlp uses to solve YouTube's "n" signature challenge for the web player clients (required
|
||||||
|
# alongside the PO token, else only storyboards are offered). yt-dlp auto-detects deno on PATH.
|
||||||
|
ARG DENO_VERSION=v2.9.1
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ffmpeg unzip curl ca-certificates \
|
||||||
|
&& curl -fsSL "https://github.com/denoland/deno/releases/download/${DENO_VERSION}/deno-x86_64-unknown-linux-gnu.zip" -o /tmp/deno.zip \
|
||||||
|
&& unzip -o /tmp/deno.zip -d /usr/local/bin \
|
||||||
|
&& chmod +x /usr/local/bin/deno \
|
||||||
|
&& rm /tmp/deno.zip \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY backend/requirements.txt .
|
COPY backend/requirements.txt .
|
||||||
RUN pip install --upgrade pip && pip install -r requirements.txt
|
RUN pip install --upgrade pip && pip install -r requirements.txt
|
||||||
|
|
||||||
|
|
@ -42,7 +54,13 @@ COPY backend/ .
|
||||||
COPY VERSION ./VERSION
|
COPY VERSION ./VERSION
|
||||||
COPY --from=frontend /fe/dist ./app/static_spa
|
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
|
USER appuser
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,9 @@ blocker and SponsorBlock keep working.
|
||||||
tags to slice the feed by.
|
tags to slice the feed by.
|
||||||
- **Playlists with two-way YouTube sync** — build them locally, keep them in sync in both directions.
|
- **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.
|
- **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.
|
- **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,
|
- **Self-hosted & private**, with a first-run web setup wizard and the interface in **English,
|
||||||
Hungarian and German**.
|
Hungarian and German**.
|
||||||
|
|
|
||||||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.21.0
|
0.22.0
|
||||||
|
|
|
||||||
143
backend/alembic/versions/0036_download_center.py
Normal file
143
backend/alembic/versions/0036_download_center.py
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
"""download center core schema (yt-dlp)
|
||||||
|
|
||||||
|
Revision ID: 0036_download_center
|
||||||
|
Revises: 0035_saved_views
|
||||||
|
Create Date: 2026-07-02
|
||||||
|
|
||||||
|
The download center's data layer. `media_assets` is the shared file cache (one row per unique
|
||||||
|
source+format signature); `download_jobs` is the per-user request over it (worker-claimed via
|
||||||
|
FOR UPDATE SKIP LOCKED, live progress written back for polling); `download_profiles` are the
|
||||||
|
reusable format presets (builtins seeded in the next migration); `download_shares` grant a file
|
||||||
|
to another user; `download_quotas` hold admin-set per-user limits (default via sysconfig).
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0036_download_center"
|
||||||
|
down_revision: Union[str, None] = "0035_saved_views"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"media_assets",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("source_kind", sa.String(length=16), nullable=False),
|
||||||
|
sa.Column("source_ref", sa.String(length=512), nullable=False),
|
||||||
|
sa.Column("format_sig", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("status", sa.String(length=16), server_default="pending", nullable=False),
|
||||||
|
sa.Column("rel_path", sa.Text(), nullable=True),
|
||||||
|
sa.Column("storyboard_path", sa.Text(), nullable=True),
|
||||||
|
sa.Column("size_bytes", sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column("duration_s", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("width", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("height", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("container", sa.String(length=16), nullable=True),
|
||||||
|
sa.Column("vcodec", sa.String(length=32), nullable=True),
|
||||||
|
sa.Column("acodec", sa.String(length=32), nullable=True),
|
||||||
|
sa.Column("nfo_written", sa.Boolean(), server_default="false", nullable=False),
|
||||||
|
sa.Column("title", sa.Text(), nullable=True),
|
||||||
|
sa.Column("uploader", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("upload_date", sa.Date(), nullable=True),
|
||||||
|
sa.Column("thumbnail_url", sa.Text(), nullable=True),
|
||||||
|
sa.Column("error", sa.Text(), nullable=True),
|
||||||
|
sa.Column("ref_count", sa.Integer(), server_default="0", nullable=False),
|
||||||
|
sa.Column("last_access_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("source_kind", "source_ref", "format_sig", name="uq_media_asset_cache_key"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_media_assets_status", "media_assets", ["status"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"download_profiles",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("name", sa.String(length=80), nullable=False),
|
||||||
|
sa.Column("spec", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("is_builtin", sa.Boolean(), server_default="false", nullable=False),
|
||||||
|
sa.Column("sort_order", sa.Integer(), server_default="0", nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_download_profiles_user_id", "download_profiles", ["user_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"download_jobs",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("asset_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("source_kind", sa.String(length=16), nullable=False),
|
||||||
|
sa.Column("source_ref", sa.String(length=512), nullable=False),
|
||||||
|
sa.Column("profile_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("profile_snapshot", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("display_name", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("status", sa.String(length=16), server_default="queued", nullable=False),
|
||||||
|
sa.Column("progress", sa.Integer(), server_default="0", nullable=False),
|
||||||
|
sa.Column("speed_bps", sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column("eta_s", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("phase", sa.String(length=32), nullable=True),
|
||||||
|
sa.Column("error", sa.Text(), nullable=True),
|
||||||
|
sa.Column("queue_pos", sa.Integer(), server_default="0", nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["asset_id"], ["media_assets.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["profile_id"], ["download_profiles.id"], ondelete="SET NULL"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_download_jobs_user_id", "download_jobs", ["user_id"])
|
||||||
|
op.create_index("ix_download_jobs_asset_id", "download_jobs", ["asset_id"])
|
||||||
|
op.create_index("ix_download_jobs_status", "download_jobs", ["status"])
|
||||||
|
op.create_index("ix_download_jobs_claim", "download_jobs", ["status", "queue_pos"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"download_shares",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("job_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("from_user_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("to_user_id", sa.Integer(), 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(["from_user_id"], ["users.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["to_user_id"], ["users.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("job_id", "to_user_id", name="uq_download_share"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_download_shares_job_id", "download_shares", ["job_id"])
|
||||||
|
op.create_index("ix_download_shares_from_user_id", "download_shares", ["from_user_id"])
|
||||||
|
op.create_index("ix_download_shares_to_user_id", "download_shares", ["to_user_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"download_quotas",
|
||||||
|
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("max_bytes", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("max_concurrent", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("max_jobs", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("unlimited", sa.Boolean(), server_default="false", nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("user_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("download_quotas")
|
||||||
|
op.drop_index("ix_download_shares_to_user_id", table_name="download_shares")
|
||||||
|
op.drop_index("ix_download_shares_from_user_id", table_name="download_shares")
|
||||||
|
op.drop_index("ix_download_shares_job_id", table_name="download_shares")
|
||||||
|
op.drop_table("download_shares")
|
||||||
|
op.drop_index("ix_download_jobs_claim", table_name="download_jobs")
|
||||||
|
op.drop_index("ix_download_jobs_status", table_name="download_jobs")
|
||||||
|
op.drop_index("ix_download_jobs_asset_id", table_name="download_jobs")
|
||||||
|
op.drop_index("ix_download_jobs_user_id", table_name="download_jobs")
|
||||||
|
op.drop_table("download_jobs")
|
||||||
|
op.drop_index("ix_download_profiles_user_id", table_name="download_profiles")
|
||||||
|
op.drop_table("download_profiles")
|
||||||
|
op.drop_index("ix_media_assets_status", table_name="media_assets")
|
||||||
|
op.drop_table("media_assets")
|
||||||
67
backend/alembic/versions/0037_download_builtin_profiles.py
Normal file
67
backend/alembic/versions/0037_download_builtin_profiles.py
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
"""seed builtin download profiles (presets)
|
||||||
|
|
||||||
|
Revision ID: 0037_dl_builtin_profiles
|
||||||
|
Revises: 0036_download_center
|
||||||
|
Create Date: 2026-07-02
|
||||||
|
|
||||||
|
Seeds the shipped download presets (user_id NULL, is_builtin=true). `spec` is the format
|
||||||
|
definition consumed by app.downloads.formats: mode ("av" audio+video / "v" video-only /
|
||||||
|
"a" audio-only), max_height (null = best), container, audio_format, vcodec preference, and
|
||||||
|
the embed_*/sponsorblock postprocessor flags. Names are English (the UI default); the frontend
|
||||||
|
localizes builtin labels by a stable key derived from the name.
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0037_dl_builtin_profiles"
|
||||||
|
down_revision: Union[str, None] = "0036_download_center"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _spec(mode, height=None, container=None, audio_format=None, vcodec=None):
|
||||||
|
return {
|
||||||
|
"mode": mode,
|
||||||
|
"max_height": height,
|
||||||
|
"container": container,
|
||||||
|
"audio_format": audio_format,
|
||||||
|
"vcodec": vcodec,
|
||||||
|
"embed_subs": False,
|
||||||
|
"embed_chapters": True,
|
||||||
|
"embed_thumbnail": True,
|
||||||
|
"sponsorblock": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_BUILTINS = [
|
||||||
|
("Best (MP4)", _spec("av", None, "mp4"), 10),
|
||||||
|
("1080p (MP4)", _spec("av", 1080, "mp4"), 20),
|
||||||
|
("720p (MP4)", _spec("av", 720, "mp4"), 30),
|
||||||
|
("Audio (M4A)", _spec("a", None, None, "m4a"), 40),
|
||||||
|
("Audio (MP3)", _spec("a", None, None, "mp3"), 50),
|
||||||
|
("Video only (MP4)", _spec("v", None, "mp4"), 60),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
profiles = sa.table(
|
||||||
|
"download_profiles",
|
||||||
|
sa.column("user_id", sa.Integer),
|
||||||
|
sa.column("name", sa.String),
|
||||||
|
sa.column("spec", sa.JSON),
|
||||||
|
sa.column("is_builtin", sa.Boolean),
|
||||||
|
sa.column("sort_order", sa.Integer),
|
||||||
|
)
|
||||||
|
op.bulk_insert(
|
||||||
|
profiles,
|
||||||
|
[
|
||||||
|
{"user_id": None, "name": name, "spec": spec, "is_builtin": True, "sort_order": order}
|
||||||
|
for name, spec, order in _BUILTINS
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("DELETE FROM download_profiles WHERE is_builtin = true")
|
||||||
29
backend/alembic/versions/0038_asset_gc_notified.py
Normal file
29
backend/alembic/versions/0038_asset_gc_notified.py
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
"""media_assets.gc_notified flag for one-shot pre-expiry warnings
|
||||||
|
|
||||||
|
Revision ID: 0038_asset_gc_notified
|
||||||
|
Revises: 0037_dl_builtin_profiles
|
||||||
|
Create Date: 2026-07-03
|
||||||
|
|
||||||
|
The download GC warns each holder once, a grace window before an asset's TTL expiry; this flag
|
||||||
|
records that so subsequent GC runs don't re-notify until the asset is deleted.
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0038_asset_gc_notified"
|
||||||
|
down_revision: Union[str, None] = "0037_dl_builtin_profiles"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"media_assets",
|
||||||
|
sa.Column("gc_notified", sa.Boolean(), server_default="false", nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("media_assets", "gc_notified")
|
||||||
49
backend/alembic/versions/0039_video_title_normalize.py
Normal file
49
backend/alembic/versions/0039_video_title_normalize.py
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
"""normalize video titles for display; keep the raw one in original_title
|
||||||
|
|
||||||
|
Revision ID: 0039_title_normalize
|
||||||
|
Revises: 0038_asset_gc_notified
|
||||||
|
Create Date: 2026-07-03
|
||||||
|
|
||||||
|
Adds videos.original_title (the raw YouTube title) and rewrites videos.title to a normalized,
|
||||||
|
display-friendly form (emoji stripped, ALL-CAPS de-shouted, trailing SEO hashtags removed —
|
||||||
|
see app.titles). Reversible: original_title preserves the source, and title can be re-derived.
|
||||||
|
The generated search_vector column regenerates automatically as each title is updated.
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
from app.titles import normalize_title
|
||||||
|
|
||||||
|
revision: str = "0039_title_normalize"
|
||||||
|
down_revision: Union[str, None] = "0038_asset_gc_notified"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
_BATCH = 2000
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("videos", sa.Column("original_title", sa.Text(), nullable=True))
|
||||||
|
conn = op.get_bind()
|
||||||
|
# Preserve the raw title first (fast, set-based).
|
||||||
|
conn.execute(sa.text("UPDATE videos SET original_title = title WHERE title IS NOT NULL"))
|
||||||
|
# Then rewrite title to the normalized form, batched (each update regenerates its FTS vector).
|
||||||
|
rows = conn.execute(
|
||||||
|
sa.text("SELECT id, original_title FROM videos WHERE original_title IS NOT NULL")
|
||||||
|
).fetchall()
|
||||||
|
changes = []
|
||||||
|
for vid, raw in rows:
|
||||||
|
norm = normalize_title(raw)
|
||||||
|
if norm != raw:
|
||||||
|
changes.append({"i": vid, "t": norm})
|
||||||
|
stmt = sa.text("UPDATE videos SET title = :t WHERE id = :i")
|
||||||
|
for start in range(0, len(changes), _BATCH):
|
||||||
|
conn.execute(stmt, changes[start : start + _BATCH])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
conn.execute(sa.text("UPDATE videos SET title = original_title WHERE original_title IS NOT NULL"))
|
||||||
|
op.drop_column("videos", "original_title")
|
||||||
72
backend/alembic/versions/0040_reset_builtin_profiles.py
Normal file
72
backend/alembic/versions/0040_reset_builtin_profiles.py
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
"""reset builtin download profiles: no embedding, 1080p default
|
||||||
|
|
||||||
|
Revision ID: 0040_reset_profiles
|
||||||
|
Revises: 0039_title_normalize
|
||||||
|
Create Date: 2026-07-03
|
||||||
|
|
||||||
|
Redefines the shipped presets: embedding (thumbnail/chapters/subs) is OFF (it rewrites the whole
|
||||||
|
file — expensive on large videos — and the Plex .nfo/poster sidecars already carry the metadata),
|
||||||
|
and the default is now 1080p (Best stays available but no longer first). Re-seeds the builtin
|
||||||
|
rows so both fresh installs (0037 then this) and existing DBs converge on the same set. Existing
|
||||||
|
jobs are unaffected (they snapshot their spec; profile_id just SET NULLs if it pointed at a
|
||||||
|
replaced row).
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0040_reset_profiles"
|
||||||
|
down_revision: Union[str, None] = "0039_title_normalize"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _spec(mode, height=None, container=None, audio_format=None):
|
||||||
|
return {
|
||||||
|
"mode": mode,
|
||||||
|
"max_height": height,
|
||||||
|
"container": container,
|
||||||
|
"audio_format": audio_format,
|
||||||
|
"vcodec": None,
|
||||||
|
"embed_subs": False,
|
||||||
|
"embed_chapters": False,
|
||||||
|
"embed_thumbnail": False,
|
||||||
|
"sponsorblock": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Default first (1080p), then Best, then the rest.
|
||||||
|
_BUILTINS = [
|
||||||
|
("1080p (MP4)", _spec("av", 1080, "mp4"), 10),
|
||||||
|
("Best (MP4)", _spec("av", None, "mp4"), 20),
|
||||||
|
("720p (MP4)", _spec("av", 720, "mp4"), 30),
|
||||||
|
("Audio (M4A)", _spec("a", None, None, "m4a"), 40),
|
||||||
|
("Audio (MP3)", _spec("a", None, None, "mp3"), 50),
|
||||||
|
("Video only (MP4)", _spec("v", None, "mp4"), 60),
|
||||||
|
]
|
||||||
|
|
||||||
|
_profiles = sa.table(
|
||||||
|
"download_profiles",
|
||||||
|
sa.column("user_id", sa.Integer),
|
||||||
|
sa.column("name", sa.String),
|
||||||
|
sa.column("spec", sa.JSON),
|
||||||
|
sa.column("is_builtin", sa.Boolean),
|
||||||
|
sa.column("sort_order", sa.Integer),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute("DELETE FROM download_profiles WHERE is_builtin = true")
|
||||||
|
op.bulk_insert(
|
||||||
|
_profiles,
|
||||||
|
[
|
||||||
|
{"user_id": None, "name": n, "spec": s, "is_builtin": True, "sort_order": o}
|
||||||
|
for n, s, o in _BUILTINS
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# No-op: the previous builtin set was seeded by 0037; not worth reconstructing.
|
||||||
|
pass
|
||||||
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")
|
||||||
|
|
@ -683,7 +683,10 @@ def resolved_user_id(request: Request) -> tuple[int | None, bool]:
|
||||||
"""
|
"""
|
||||||
default_id = request.session.get("user_id")
|
default_id = request.session.get("user_id")
|
||||||
wallet = request.session.get("account_ids") or []
|
wallet = request.session.get("account_ids") or []
|
||||||
hdr = request.headers.get(ACTIVE_ACCOUNT_HEADER)
|
# Header for normal XHR; ?account= for contexts that can't set headers (WebSocket, and a
|
||||||
|
# plain <a> file download). Both are wallet-gated below, so neither can impersonate an
|
||||||
|
# account that never signed into this browser.
|
||||||
|
hdr = request.headers.get(ACTIVE_ACCOUNT_HEADER) or request.query_params.get("account")
|
||||||
if hdr:
|
if hdr:
|
||||||
try:
|
try:
|
||||||
hid = int(hdr)
|
hid = int(hdr)
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,57 @@ class Settings(BaseSettings):
|
||||||
# ("was_live") are real watchable content and stay visible.
|
# ("was_live") are real watchable content and stay visible.
|
||||||
feed_default_hidden_live: str = "live,upcoming"
|
feed_default_hidden_live: str = "live,upcoming"
|
||||||
|
|
||||||
|
# --- Download center (yt-dlp) ---
|
||||||
|
# Root directory (inside the container) for downloaded media — a Docker volume/bind mount.
|
||||||
|
# Env-only (a mount path can't live in the DB). The worker lays a Plex-style tree under it.
|
||||||
|
download_root: str = "/downloads"
|
||||||
|
# Whether THIS process runs the download worker loop. On for the dedicated worker
|
||||||
|
# container, off for the API (which keeps the scheduler). Mirrors scheduler_enabled.
|
||||||
|
worker_enabled: bool = False
|
||||||
|
# Base URL of the bgutil PO-token provider sidecar. When set, the download worker asks it
|
||||||
|
# for YouTube Proof-of-Origin tokens (bypasses bot-detection + unlocks high-quality
|
||||||
|
# formats). Empty disables it (yt-dlp still works, but may hit "confirm you're not a bot").
|
||||||
|
download_pot_base_url: str = "http://bgutil-pot:4416"
|
||||||
|
# yt-dlp player clients (comma-separated). PRIMARY = android_vr: high-quality DASH, no PO
|
||||||
|
# token / JS runtime needed, reliable for normal use. On a bot/DRM/format failure (YouTube's
|
||||||
|
# per-client responses are flaky, and android_vr can get bot-flagged under heavy load) the
|
||||||
|
# worker retries with the FALLBACK set — the POT-capable web clients (web needs the token +
|
||||||
|
# Deno) plus android for audio. Both admin-tunable when YouTube shifts what works.
|
||||||
|
download_player_clients: str = "android_vr"
|
||||||
|
download_player_clients_fallback: str = "web_safari,web,android"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _clients(raw: str) -> list[str]:
|
||||||
|
return [c.strip() for c in raw.split(",") if c.strip()]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def player_client_list(self) -> list[str]:
|
||||||
|
return self._clients(self.download_player_clients)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def player_client_fallback_list(self) -> list[str]:
|
||||||
|
return self._clients(self.download_player_clients_fallback)
|
||||||
|
# How many downloads the worker runs concurrently (claimed via FOR UPDATE SKIP LOCKED).
|
||||||
|
download_worker_concurrency: int = 2
|
||||||
|
# How often (empty pending queue) the worker polls for a new job, in seconds.
|
||||||
|
download_poll_seconds: int = 5
|
||||||
|
# Total cache size cap across ALL users (bytes). When >0 and exceeded, the GC evicts the
|
||||||
|
# least-recently-accessed ready assets until under it. 0 = unlimited (disk is the limit).
|
||||||
|
download_total_max_bytes: int = 0
|
||||||
|
# Default per-user quota (admin-tunable; a per-user download_quotas row overrides).
|
||||||
|
download_default_max_bytes: int = 5_368_709_120 # 5 GiB
|
||||||
|
download_default_max_concurrent: int = 2
|
||||||
|
download_default_max_jobs: int = 50
|
||||||
|
# Retention: an asset expires this many days after its last access; the GC job then
|
||||||
|
# deletes it (once no job references it) after an additional grace window.
|
||||||
|
download_retention_days: int = 30
|
||||||
|
download_gc_grace_days: int = 2
|
||||||
|
download_gc_minutes: int = 360
|
||||||
|
# On-disk layout: "plex" (Channel/Season YYYY/… [id].ext + .nfo/poster) or "flat".
|
||||||
|
download_layout: str = "plex"
|
||||||
|
# Default SponsorBlock (skip sponsor segments) for new custom profiles.
|
||||||
|
sponsorblock_default: bool = False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def allowed_email_set(self) -> set[str]:
|
def allowed_email_set(self) -> set[str]:
|
||||||
return {e.strip().lower() for e in self.allowed_emails.split(",") if e.strip()}
|
return {e.strip().lower() for e in self.allowed_emails.split(",") if e.strip()}
|
||||||
|
|
|
||||||
0
backend/app/downloads/__init__.py
Normal file
0
backend/app/downloads/__init__.py
Normal file
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}
|
||||||
179
backend/app/downloads/formats.py
Normal file
179
backend/app/downloads/formats.py
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
"""Format profiles → yt-dlp options, and the cache signature.
|
||||||
|
|
||||||
|
A profile `spec` (stored on DownloadProfile / snapshotted on DownloadJob) is the single source
|
||||||
|
of truth for what file gets produced. `format_sig` hashes exactly the fields that change the
|
||||||
|
OUTPUT BYTES, so two users picking equivalent settings share one cached MediaAsset.
|
||||||
|
`build_ydl_opts` turns a spec into a yt-dlp options dict (format selector + postprocessors).
|
||||||
|
|
||||||
|
spec shape (all keys optional; defaults applied by `normalize`):
|
||||||
|
mode "av" (audio+video) | "v" (video-only) | "a" (audio-only)
|
||||||
|
max_height int | null (null = best)
|
||||||
|
container "mp4" | "mkv" | "webm" | null (merge/remux target for av/v)
|
||||||
|
audio_format "m4a" | "mp3" | "opus" | null (extract target for mode "a")
|
||||||
|
vcodec "h264" | "vp9" | "av1" | null (preference, best-effort)
|
||||||
|
embed_subs bool embed_chapters bool embed_thumbnail bool sponsorblock bool
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
|
||||||
|
# Fields that affect the produced file — and therefore the cache identity.
|
||||||
|
_SIG_KEYS = (
|
||||||
|
"mode",
|
||||||
|
"max_height",
|
||||||
|
"container",
|
||||||
|
"audio_format",
|
||||||
|
"vcodec",
|
||||||
|
"embed_subs",
|
||||||
|
"embed_chapters",
|
||||||
|
"embed_thumbnail",
|
||||||
|
"sponsorblock",
|
||||||
|
)
|
||||||
|
|
||||||
|
_DEFAULTS = {
|
||||||
|
"mode": "av",
|
||||||
|
"max_height": None,
|
||||||
|
"container": "mp4",
|
||||||
|
"audio_format": "m4a",
|
||||||
|
"vcodec": None,
|
||||||
|
# Embedding is OFF by default: it rewrites the whole file (doubles I/O — a 10 GB video
|
||||||
|
# gets a 10 GB temp copy), and the Plex `.nfo` + poster sidecars already carry the
|
||||||
|
# metadata/thumbnail. Users who want self-contained files opt in via a custom profile.
|
||||||
|
"embed_subs": False,
|
||||||
|
"embed_chapters": False,
|
||||||
|
"embed_thumbnail": False,
|
||||||
|
"sponsorblock": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
_SPONSORBLOCK_CATEGORIES = ["sponsor", "selfpromo", "interaction"]
|
||||||
|
_VCODEC_SORT = {"h264": "vcodec:h264", "vp9": "vcodec:vp9", "av1": "vcodec:av01"}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize(spec: dict) -> dict:
|
||||||
|
"""Fill defaults + coerce types so signature and yt-dlp opts are deterministic."""
|
||||||
|
s = dict(_DEFAULTS)
|
||||||
|
for k in _DEFAULTS:
|
||||||
|
if spec.get(k) is not None:
|
||||||
|
s[k] = spec[k]
|
||||||
|
if s["mode"] not in ("av", "v", "a"):
|
||||||
|
s["mode"] = "av"
|
||||||
|
if s["max_height"] is not None:
|
||||||
|
s["max_height"] = int(s["max_height"])
|
||||||
|
for b in ("embed_subs", "embed_chapters", "embed_thumbnail", "sponsorblock"):
|
||||||
|
s[b] = bool(s[b])
|
||||||
|
# Audio-only files can't embed subtitles/thumbnails as video; keep the flags meaningful.
|
||||||
|
if s["mode"] == "a":
|
||||||
|
s["embed_subs"] = False
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def format_sig(spec: dict) -> str:
|
||||||
|
"""Stable short hash of the output-affecting spec — the MediaAsset cache key component."""
|
||||||
|
s = normalize(spec)
|
||||||
|
payload = json.dumps({k: s[k] for k in _SIG_KEYS}, sort_keys=True, separators=(",", ":"))
|
||||||
|
return hashlib.sha256(payload.encode()).hexdigest()[:24]
|
||||||
|
|
||||||
|
|
||||||
|
def target_ext(spec: dict) -> str:
|
||||||
|
"""Best-effort final extension for the produced file (also used to name the staging output)."""
|
||||||
|
s = normalize(spec)
|
||||||
|
if s["mode"] == "a":
|
||||||
|
return s["audio_format"] or "m4a"
|
||||||
|
return s["container"] or "mp4"
|
||||||
|
|
||||||
|
|
||||||
|
def build_ydl_opts(
|
||||||
|
spec: dict,
|
||||||
|
outtmpl: str,
|
||||||
|
progress_hook,
|
||||||
|
pot_base_url: str | None = None,
|
||||||
|
player_clients: list[str] | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Assemble yt-dlp options for a single download to `outtmpl` (a full path template).
|
||||||
|
|
||||||
|
`pot_base_url` points the bgutil PO-token provider plugin at the sidecar server;
|
||||||
|
`player_clients` picks which YouTube clients to use (POT-capable ones like web use the token)."""
|
||||||
|
s = normalize(spec)
|
||||||
|
h = s["max_height"]
|
||||||
|
opts: dict = {
|
||||||
|
"outtmpl": outtmpl,
|
||||||
|
"noplaylist": True,
|
||||||
|
"quiet": True,
|
||||||
|
"no_warnings": True,
|
||||||
|
"noprogress": True,
|
||||||
|
"writethumbnail": True, # kept for the poster.jpg sidecar (and embed, if requested)
|
||||||
|
"progress_hooks": [progress_hook],
|
||||||
|
"postprocessors": [],
|
||||||
|
# Force IPv4: containers often have no working IPv6 route, but googlevideo CDN hosts
|
||||||
|
# advertise AAAA records, so ffmpeg/the downloader would try IPv6 and fail with
|
||||||
|
# "[Errno -5] No address associated with hostname". Binding to the IPv4 any-address
|
||||||
|
# makes every connection IPv4-only. (yt-dlp's --force-ipv4 does the same thing.)
|
||||||
|
"source_address": "0.0.0.0",
|
||||||
|
# Self-heal transient network blips instead of failing the whole job.
|
||||||
|
"retries": 10,
|
||||||
|
"fragment_retries": 10,
|
||||||
|
"extractor_retries": 3,
|
||||||
|
"retry_sleep": {"http": 5, "fragment": 5, "extractor": 5},
|
||||||
|
"socket_timeout": 30,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Extractor args: which YouTube clients to use + where the bgutil PO-token sidecar lives
|
||||||
|
# (== --extractor-args "youtube:player_client=…;youtubepot-bgutilhttp:base_url=…"). Values
|
||||||
|
# are lists in the Python API.
|
||||||
|
ea: dict = {}
|
||||||
|
if player_clients:
|
||||||
|
ea["youtube"] = {"player_client": player_clients}
|
||||||
|
if pot_base_url:
|
||||||
|
ea["youtubepot-bgutilhttp"] = {"base_url": [pot_base_url]}
|
||||||
|
if ea:
|
||||||
|
opts["extractor_args"] = ea
|
||||||
|
|
||||||
|
if s["mode"] == "a":
|
||||||
|
opts["format"] = "bestaudio/best"
|
||||||
|
opts["postprocessors"].append(
|
||||||
|
{
|
||||||
|
"key": "FFmpegExtractAudio",
|
||||||
|
"preferredcodec": s["audio_format"] or "m4a",
|
||||||
|
"preferredquality": "0",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
hcap = f"[height<=?{h}]" if h else ""
|
||||||
|
if s["mode"] == "v":
|
||||||
|
opts["format"] = f"bestvideo{hcap}/best{hcap}"
|
||||||
|
else: # av
|
||||||
|
opts["format"] = f"bestvideo{hcap}+bestaudio/best{hcap}"
|
||||||
|
if s["container"]:
|
||||||
|
opts["merge_output_format"] = s["container"]
|
||||||
|
sort = []
|
||||||
|
if h:
|
||||||
|
sort.append(f"res:{h}")
|
||||||
|
if s["vcodec"] in _VCODEC_SORT:
|
||||||
|
sort.append(_VCODEC_SORT[s["vcodec"]])
|
||||||
|
if sort:
|
||||||
|
opts["format_sort"] = sort
|
||||||
|
|
||||||
|
# SponsorBlock first (marks segments), then strip them from the media + chapters.
|
||||||
|
if s["sponsorblock"]:
|
||||||
|
opts["postprocessors"].append(
|
||||||
|
{"key": "SponsorBlock", "categories": _SPONSORBLOCK_CATEGORIES, "when": "after_filter"}
|
||||||
|
)
|
||||||
|
opts["postprocessors"].append(
|
||||||
|
{"key": "ModifyChapters", "remove_sponsor_segments": _SPONSORBLOCK_CATEGORIES}
|
||||||
|
)
|
||||||
|
if s["embed_subs"] and s["mode"] != "a":
|
||||||
|
opts["writesubtitles"] = True
|
||||||
|
opts["subtitleslangs"] = ["en.*", "hu.*", "de.*"]
|
||||||
|
opts["postprocessors"].append({"key": "FFmpegEmbedSubtitle"})
|
||||||
|
if s["embed_chapters"]:
|
||||||
|
opts["postprocessors"].append(
|
||||||
|
{"key": "FFmpegMetadata", "add_chapters": True, "add_metadata": True}
|
||||||
|
)
|
||||||
|
# Normalize the written thumbnail to jpg so we always have a `<name>.jpg` poster sidecar.
|
||||||
|
# `already_have_thumbnail` keeps that file on disk after embedding (default deletes it).
|
||||||
|
opts["postprocessors"].append({"key": "FFmpegThumbnailsConvertor", "format": "jpg"})
|
||||||
|
if s["embed_thumbnail"]:
|
||||||
|
opts["postprocessors"].append(
|
||||||
|
{"key": "EmbedThumbnail", "already_have_thumbnail": True}
|
||||||
|
)
|
||||||
|
|
||||||
|
return opts
|
||||||
119
backend/app/downloads/gc.py
Normal file
119
backend/app/downloads/gc.py
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
"""Download retention garbage collector (a scheduler job).
|
||||||
|
|
||||||
|
Three passes each run:
|
||||||
|
1. Pre-expiry warning — for ready assets expiring within the grace window, notify each holder
|
||||||
|
once (gc_notified flag) so a download isn't silently deleted.
|
||||||
|
2. Expiry deletion — delete assets past their TTL (files + row). The FK SET NULL nulls the
|
||||||
|
holders' jobs' asset_id, so those jobs surface as "expired" (re-downloadable) in the UI.
|
||||||
|
3. LRU eviction — if a total cache cap is set and exceeded, delete least-recently-accessed
|
||||||
|
ready assets until back under it.
|
||||||
|
|
||||||
|
Runs in the API process (which mounts DOWNLOAD_ROOT). Pure disk/DB work — no YouTube quota.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app import sysconfig
|
||||||
|
from app.config import settings
|
||||||
|
from app.downloads import storage
|
||||||
|
from app.models import DownloadJob, MediaAsset
|
||||||
|
from app.notifications import create_notification
|
||||||
|
|
||||||
|
log = logging.getLogger("siftlode.downloads.gc")
|
||||||
|
|
||||||
|
|
||||||
|
def _holders(db: Session, asset_id: int) -> list[int]:
|
||||||
|
return list(
|
||||||
|
db.execute(
|
||||||
|
select(DownloadJob.user_id)
|
||||||
|
.where(DownloadJob.asset_id == asset_id)
|
||||||
|
.distinct()
|
||||||
|
).scalars()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _notify_holders(db: Session, asset: MediaAsset, ntype: str) -> None:
|
||||||
|
title = asset.title or "download"
|
||||||
|
for uid in _holders(db, asset.id):
|
||||||
|
create_notification(
|
||||||
|
db,
|
||||||
|
user_id=uid,
|
||||||
|
type=ntype,
|
||||||
|
title=title, # English fallback; the client renders a translated message from data
|
||||||
|
data={"kind": ntype, "title": asset.title, "video_id": asset.source_ref},
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _delete_asset(db: Session, asset: MediaAsset, ntype: str) -> None:
|
||||||
|
if asset.rel_path:
|
||||||
|
storage.delete_asset_files(settings.download_root, asset.rel_path)
|
||||||
|
_notify_holders(db, asset, ntype)
|
||||||
|
db.delete(asset) # jobs.asset_id -> NULL via FK ON DELETE SET NULL
|
||||||
|
|
||||||
|
|
||||||
|
def run_download_gc(db: Session) -> dict:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
grace_days = sysconfig.get_int(db, "download_gc_grace_days")
|
||||||
|
warned = expired = evicted = 0
|
||||||
|
|
||||||
|
# 1. Pre-expiry warnings.
|
||||||
|
warn_before = now + timedelta(days=grace_days)
|
||||||
|
soon = db.execute(
|
||||||
|
select(MediaAsset).where(
|
||||||
|
MediaAsset.status == "ready",
|
||||||
|
MediaAsset.gc_notified.is_(False),
|
||||||
|
MediaAsset.expires_at.is_not(None),
|
||||||
|
MediaAsset.expires_at > now,
|
||||||
|
MediaAsset.expires_at <= warn_before,
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
for asset in soon:
|
||||||
|
_notify_holders(db, asset, "download_expiring")
|
||||||
|
asset.gc_notified = True
|
||||||
|
warned += 1
|
||||||
|
if soon:
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# 2. Expiry deletion (ready assets past TTL, and any errored leftovers with a TTL).
|
||||||
|
dead = db.execute(
|
||||||
|
select(MediaAsset).where(
|
||||||
|
MediaAsset.status.in_(("ready", "error")),
|
||||||
|
MediaAsset.expires_at.is_not(None),
|
||||||
|
MediaAsset.expires_at <= now,
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
for asset in dead:
|
||||||
|
_delete_asset(db, asset, "download_expired")
|
||||||
|
expired += 1
|
||||||
|
if dead:
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# 3. LRU eviction over the total cache cap.
|
||||||
|
cap = sysconfig.get_int(db, "download_total_max_bytes")
|
||||||
|
if cap > 0:
|
||||||
|
total = db.execute(
|
||||||
|
select(func.coalesce(func.sum(MediaAsset.size_bytes), 0)).where(
|
||||||
|
MediaAsset.status == "ready"
|
||||||
|
)
|
||||||
|
).scalar() or 0
|
||||||
|
if total > cap:
|
||||||
|
candidates = db.execute(
|
||||||
|
select(MediaAsset)
|
||||||
|
.where(MediaAsset.status == "ready")
|
||||||
|
.order_by(MediaAsset.last_access_at.asc().nulls_first())
|
||||||
|
).scalars().all()
|
||||||
|
for asset in candidates:
|
||||||
|
if total <= cap:
|
||||||
|
break
|
||||||
|
total -= asset.size_bytes or 0
|
||||||
|
_delete_asset(db, asset, "download_evicted")
|
||||||
|
evicted += 1
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
result = {"warned": warned, "expired": expired, "evicted": evicted}
|
||||||
|
log.info("download_gc %s", result)
|
||||||
|
return result
|
||||||
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,
|
||||||
|
}
|
||||||
129
backend/app/downloads/quota.py
Normal file
129
backend/app/downloads/quota.py
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
"""Per-user download limits + footprint accounting.
|
||||||
|
|
||||||
|
A user's *footprint* is the total size of the ready assets their library references (cache hits
|
||||||
|
count — it's "how much disk you're responsible for", not "how much you personally downloaded").
|
||||||
|
Limits come from a per-user DownloadQuota row if the admin set one, else the sysconfig defaults.
|
||||||
|
`unlimited` (e.g. the admin's own account) bypasses the byte cap.
|
||||||
|
|
||||||
|
Enforcement split: max_jobs + max_bytes are checked at enqueue (they bound holdings); per-user
|
||||||
|
max_concurrent is enforced by the worker at claim time (it bounds simultaneous downloads).
|
||||||
|
"""
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app import sysconfig
|
||||||
|
from app.models import DownloadJob, DownloadQuota, MediaAsset
|
||||||
|
|
||||||
|
# Jobs a user is considered to be "holding" (occupy a job slot / footprint). Terminal failures
|
||||||
|
# (error, canceled) don't count.
|
||||||
|
_HOLDING = ("queued", "running", "paused", "done")
|
||||||
|
_RUNNING = ("queued", "running")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class QuotaLimits:
|
||||||
|
max_bytes: int
|
||||||
|
max_concurrent: int
|
||||||
|
max_jobs: int
|
||||||
|
unlimited: bool
|
||||||
|
|
||||||
|
|
||||||
|
class QuotaExceeded(Exception):
|
||||||
|
"""Raised by check_enqueue; `reason` is one of max_jobs|max_bytes (i18n key on the client)."""
|
||||||
|
|
||||||
|
def __init__(self, reason: str, limit: int, current: int):
|
||||||
|
self.reason = reason
|
||||||
|
self.limit = limit
|
||||||
|
self.current = current
|
||||||
|
super().__init__(f"download quota exceeded: {reason} ({current}/{limit})")
|
||||||
|
|
||||||
|
|
||||||
|
def resolve(db: Session, user_id: int) -> QuotaLimits:
|
||||||
|
row = db.get(DownloadQuota, user_id)
|
||||||
|
if row is not None:
|
||||||
|
return QuotaLimits(row.max_bytes, row.max_concurrent, row.max_jobs, row.unlimited)
|
||||||
|
return QuotaLimits(
|
||||||
|
max_bytes=sysconfig.get_int(db, "download_default_max_bytes"),
|
||||||
|
max_concurrent=sysconfig.get_int(db, "download_default_max_concurrent"),
|
||||||
|
max_jobs=sysconfig.get_int(db, "download_default_max_jobs"),
|
||||||
|
unlimited=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def footprint(db: Session, user_id: int) -> int:
|
||||||
|
"""Total bytes of the distinct ready assets this user's active jobs reference."""
|
||||||
|
held_assets = (
|
||||||
|
select(DownloadJob.asset_id)
|
||||||
|
.where(
|
||||||
|
DownloadJob.user_id == user_id,
|
||||||
|
DownloadJob.status.in_(_HOLDING),
|
||||||
|
DownloadJob.asset_id.is_not(None),
|
||||||
|
)
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
|
return int(
|
||||||
|
db.execute(
|
||||||
|
select(func.coalesce(func.sum(MediaAsset.size_bytes), 0)).where(
|
||||||
|
MediaAsset.id.in_(held_assets), MediaAsset.status == "ready"
|
||||||
|
)
|
||||||
|
).scalar()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _count(db: Session, user_id: int, states) -> int:
|
||||||
|
return (
|
||||||
|
db.execute(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(DownloadJob)
|
||||||
|
.where(DownloadJob.user_id == user_id, DownloadJob.status.in_(states))
|
||||||
|
).scalar()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def active_jobs(db: Session, user_id: int) -> int:
|
||||||
|
return _count(db, user_id, _HOLDING)
|
||||||
|
|
||||||
|
|
||||||
|
def running_jobs(db: Session, user_id: int) -> int:
|
||||||
|
return _count(db, user_id, _RUNNING)
|
||||||
|
|
||||||
|
|
||||||
|
def at_concurrency_limit(db: Session, user_id: int) -> bool:
|
||||||
|
"""Worker-side gate: is this user already downloading their max? Unlimited users never are."""
|
||||||
|
lim = resolve(db, user_id)
|
||||||
|
if lim.unlimited:
|
||||||
|
return False
|
||||||
|
running = _count(db, user_id, ("running",))
|
||||||
|
return running >= lim.max_concurrent
|
||||||
|
|
||||||
|
|
||||||
|
def check_enqueue(db: Session, user_id: int) -> None:
|
||||||
|
"""Raise QuotaExceeded if the user can't take another job (holdings caps). Concurrency is
|
||||||
|
enforced later by the worker, so a big queue is allowed; it just drains max_concurrent-wide."""
|
||||||
|
lim = resolve(db, user_id)
|
||||||
|
if lim.unlimited:
|
||||||
|
return
|
||||||
|
n = active_jobs(db, user_id)
|
||||||
|
if n >= lim.max_jobs:
|
||||||
|
raise QuotaExceeded("max_jobs", lim.max_jobs, n)
|
||||||
|
used = footprint(db, user_id)
|
||||||
|
if used >= lim.max_bytes:
|
||||||
|
raise QuotaExceeded("max_bytes", lim.max_bytes, used)
|
||||||
|
|
||||||
|
|
||||||
|
def usage(db: Session, user_id: int) -> dict:
|
||||||
|
"""Compact usage snapshot for the UI (footprint + counts vs limits)."""
|
||||||
|
lim = resolve(db, user_id)
|
||||||
|
return {
|
||||||
|
"footprint_bytes": footprint(db, user_id),
|
||||||
|
"active_jobs": active_jobs(db, user_id),
|
||||||
|
"running_jobs": running_jobs(db, user_id),
|
||||||
|
"max_bytes": lim.max_bytes,
|
||||||
|
"max_concurrent": lim.max_concurrent,
|
||||||
|
"max_jobs": lim.max_jobs,
|
||||||
|
"unlimited": lim.unlimited,
|
||||||
|
}
|
||||||
200
backend/app/downloads/service.py
Normal file
200
backend/app/downloads/service.py
Normal file
|
|
@ -0,0 +1,200 @@
|
||||||
|
"""Enqueue + cache resolution for the download center (the per-user layer over MediaAsset).
|
||||||
|
|
||||||
|
`enqueue` is the single entry point used by the API: it resolves (or creates) the shared
|
||||||
|
MediaAsset for the requested source+format, links a per-user DownloadJob to it, and — on a
|
||||||
|
cache hit (asset already ready) — marks the job done instantly with no re-download. The worker
|
||||||
|
handles everything else. State transitions used by the routes (pause/resume/cancel/delete) and
|
||||||
|
the worker live in `worker.py` / the routes; this module owns enqueue + asset bookkeeping.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
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":
|
||||||
|
return f"https://www.youtube.com/watch?v={source_ref}"
|
||||||
|
return source_ref
|
||||||
|
|
||||||
|
|
||||||
|
def get_or_create_asset(
|
||||||
|
db: Session, source_kind: str, source_ref: str, format_sig: str
|
||||||
|
) -> MediaAsset:
|
||||||
|
"""Fetch the cache row for this (source, format) or create a fresh `pending` one.
|
||||||
|
|
||||||
|
Races on the unique cache key are resolved by re-selecting after an IntegrityError, so two
|
||||||
|
simultaneous enqueues of the same video+format converge on one shared asset."""
|
||||||
|
stmt = select(MediaAsset).where(
|
||||||
|
MediaAsset.source_kind == source_kind,
|
||||||
|
MediaAsset.source_ref == source_ref,
|
||||||
|
MediaAsset.format_sig == format_sig,
|
||||||
|
)
|
||||||
|
asset = db.execute(stmt).scalar_one_or_none()
|
||||||
|
if asset is not None:
|
||||||
|
return asset
|
||||||
|
asset = MediaAsset(
|
||||||
|
source_kind=source_kind,
|
||||||
|
source_ref=source_ref,
|
||||||
|
format_sig=format_sig,
|
||||||
|
status="pending",
|
||||||
|
ref_count=0,
|
||||||
|
)
|
||||||
|
db.add(asset)
|
||||||
|
try:
|
||||||
|
db.flush()
|
||||||
|
except IntegrityError:
|
||||||
|
db.rollback()
|
||||||
|
asset = db.execute(stmt).scalar_one()
|
||||||
|
return asset
|
||||||
|
|
||||||
|
|
||||||
|
def populate_from_catalog(db: Session, asset: MediaAsset) -> bool:
|
||||||
|
"""Fill an asset's display metadata (title/uploader/thumbnail/date/duration) from our own
|
||||||
|
catalog when the source is a known YouTube video — so a queued/downloading row shows the
|
||||||
|
real title + thumbnail immediately instead of the bare id. 0 network cost. Returns True if
|
||||||
|
it found a catalog match."""
|
||||||
|
if asset.source_kind != "youtube" or asset.title:
|
||||||
|
return False
|
||||||
|
video = db.get(Video, asset.source_ref)
|
||||||
|
if video is None:
|
||||||
|
return False
|
||||||
|
asset.title = video.title
|
||||||
|
asset.thumbnail_url = video.thumbnail_url
|
||||||
|
asset.duration_s = video.duration_seconds
|
||||||
|
if video.published_at:
|
||||||
|
asset.upload_date = video.published_at.date()
|
||||||
|
channel = db.get(Channel, video.channel_id)
|
||||||
|
if channel is not None:
|
||||||
|
asset.uploader = channel.title
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _next_queue_pos(db: Session) -> int:
|
||||||
|
return (db.execute(select(func.coalesce(func.max(DownloadJob.queue_pos), 0))).scalar() or 0) + 1
|
||||||
|
|
||||||
|
|
||||||
|
def enqueue(
|
||||||
|
db: Session,
|
||||||
|
user_id: int,
|
||||||
|
source_kind: str,
|
||||||
|
source_ref: str,
|
||||||
|
spec: dict,
|
||||||
|
profile_id: int | None = None,
|
||||||
|
display_name: str | None = None,
|
||||||
|
) -> DownloadJob:
|
||||||
|
"""Queue a download for `user_id`. Returns the DownloadJob (already `done` on a cache hit).
|
||||||
|
|
||||||
|
Raises quota.QuotaExceeded if the user is at their job-count or footprint cap."""
|
||||||
|
quota.check_enqueue(db, user_id)
|
||||||
|
spec = formats.normalize(spec)
|
||||||
|
sig = formats.format_sig(spec)
|
||||||
|
asset = get_or_create_asset(db, source_kind, source_ref, sig)
|
||||||
|
# Show a real title + thumbnail the moment it's queued (feed downloads are catalog videos).
|
||||||
|
populate_from_catalog(db, asset)
|
||||||
|
|
||||||
|
job = DownloadJob(
|
||||||
|
user_id=user_id,
|
||||||
|
asset_id=asset.id,
|
||||||
|
source_kind=source_kind,
|
||||||
|
source_ref=source_ref,
|
||||||
|
profile_id=profile_id,
|
||||||
|
profile_snapshot=spec,
|
||||||
|
display_name=display_name or (asset.title if asset.status == "ready" else None),
|
||||||
|
status="queued",
|
||||||
|
queue_pos=_next_queue_pos(db),
|
||||||
|
)
|
||||||
|
db.add(job)
|
||||||
|
asset.ref_count = (asset.ref_count or 0) + 1
|
||||||
|
|
||||||
|
# Cache hit: the file already exists — no worker round-trip needed.
|
||||||
|
if asset.status == "ready":
|
||||||
|
job.status = "done"
|
||||||
|
job.progress = 100
|
||||||
|
asset.last_access_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
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
|
||||||
163
backend/app/downloads/storage.py
Normal file
163
backend/app/downloads/storage.py
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
"""On-disk layout for downloaded media — Plex-friendly tree + .nfo/poster sidecars.
|
||||||
|
|
||||||
|
The physical filename is system-decided and bound to the source id (never renamed; the user's
|
||||||
|
custom name is display-only, applied at local-download time). Layout is admin-configurable:
|
||||||
|
|
||||||
|
plex: {Channel}/Season {Year}/{Channel} - {YYYY-MM-DD} - {title} [{id}].{ext}
|
||||||
|
flat: {title} [{id}].{ext}
|
||||||
|
|
||||||
|
Plus a Kodi/Plex-readable `<basename>.nfo` (episodedetails) and `<basename>.jpg` thumbnail, so
|
||||||
|
a Plex library using a metadata agent picks up rich info. Channel = "show", video = episode,
|
||||||
|
date-based — the community-standard mapping for YouTube-in-Plex.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import unicodedata
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
from xml.sax.saxutils import escape
|
||||||
|
|
||||||
|
# Reserve room for the " [id].ext" suffix + parent dirs within the 255-byte component limit.
|
||||||
|
_MAX_TITLE = 120
|
||||||
|
# Filesystem-illegal on Windows/Plex + control chars.
|
||||||
|
_ILLEGAL = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
|
||||||
|
# Unicode categories we drop entirely: emoji/pictographs (So), modifier symbols/skin-tones
|
||||||
|
# (Sk), and every control/format/private/surrogate/unassigned char (C*). Letters (incl.
|
||||||
|
# accented á/ö/ü/ß), digits, and ordinary punctuation are KEPT — Siftlode is trilingual, so
|
||||||
|
# ASCII-folding would mangle Hungarian/German titles.
|
||||||
|
_DROP_CATEGORIES = {"So", "Sk", "Cc", "Cf", "Cs", "Co", "Cn"}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MediaMeta:
|
||||||
|
video_id: str
|
||||||
|
title: str
|
||||||
|
uploader: str
|
||||||
|
upload_date: date | None
|
||||||
|
duration_s: int | None = None
|
||||||
|
description: str | None = None
|
||||||
|
thumbnail_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize(name: str, limit: int = _MAX_TITLE) -> str:
|
||||||
|
"""Turn an arbitrary (emoji-laden, clickbait) title into a clean, space-free path token.
|
||||||
|
|
||||||
|
Steps: NFKC-normalize (fold fancy width/compat forms) → drop emoji/symbols/control chars →
|
||||||
|
strip filesystem-illegal chars → collapse whitespace and runs of the same punctuation →
|
||||||
|
join words with underscores → trim junk punctuation from the ends → length-cap. Accented
|
||||||
|
letters and digits survive; the result never contains spaces or path separators."""
|
||||||
|
text = unicodedata.normalize("NFKC", name or "")
|
||||||
|
text = "".join(
|
||||||
|
ch for ch in text if unicodedata.category(ch) not in _DROP_CATEGORIES
|
||||||
|
)
|
||||||
|
text = _ILLEGAL.sub("", text)
|
||||||
|
# Collapse repeated punctuation ("!!!", "???", "-----") to a single mark.
|
||||||
|
text = re.sub(r"([^\w\s])\1{1,}", r"\1", text)
|
||||||
|
# Whitespace → single underscore (no spaces in generated names).
|
||||||
|
text = re.sub(r"\s+", "_", text.strip())
|
||||||
|
# Tidy separator pile-ups and trim junk from the ends.
|
||||||
|
text = re.sub(r"_{2,}", "_", text).strip("_.-·—–|,;:!?ّ ")
|
||||||
|
if len(text) > limit:
|
||||||
|
text = text[:limit].rstrip("_.-")
|
||||||
|
return text or "untitled"
|
||||||
|
|
||||||
|
|
||||||
|
def display_filename(name: str) -> str:
|
||||||
|
"""User-facing download filename (Content-Disposition): strip emoji/symbols/control +
|
||||||
|
filesystem-illegal chars, but KEEP spaces, accents, and ordinary punctuation — unlike the
|
||||||
|
underscore-joined on-disk name, this is the readable name the user saves to their device."""
|
||||||
|
text = unicodedata.normalize("NFKC", name or "")
|
||||||
|
text = "".join(ch for ch in text if unicodedata.category(ch) not in _DROP_CATEGORIES)
|
||||||
|
text = _ILLEGAL.sub("", text)
|
||||||
|
text = re.sub(r"\s+", " ", text).strip(" .")
|
||||||
|
return text or "download"
|
||||||
|
|
||||||
|
|
||||||
|
def rel_path(meta: MediaMeta, ext: str, layout: str = "plex") -> str:
|
||||||
|
"""Path of the media file relative to DOWNLOAD_ROOT (forward slashes)."""
|
||||||
|
title = sanitize(meta.title)
|
||||||
|
vid = meta.video_id
|
||||||
|
if layout == "flat":
|
||||||
|
return f"{title}_[{vid}].{ext}"
|
||||||
|
channel = sanitize(meta.uploader or "Unknown_Channel", 80)
|
||||||
|
year = meta.upload_date.year if meta.upload_date else 0
|
||||||
|
d = meta.upload_date.isoformat() if meta.upload_date else "0000-00-00"
|
||||||
|
epname = f"{channel}_-_{d}_-_{title}_[{vid}].{ext}"
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def place_file(staging_file: Path, download_root: str, rel: str) -> None:
|
||||||
|
"""Move a completed staging file into its final location under DOWNLOAD_ROOT."""
|
||||||
|
dest = abs_path(download_root, rel)
|
||||||
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.move(str(staging_file), str(dest))
|
||||||
|
|
||||||
|
|
||||||
|
def _nfo_xml(meta: MediaMeta) -> str:
|
||||||
|
aired = meta.upload_date.isoformat() if meta.upload_date else ""
|
||||||
|
year = meta.upload_date.year if meta.upload_date else ""
|
||||||
|
parts = [
|
||||||
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>',
|
||||||
|
"<episodedetails>",
|
||||||
|
f" <title>{escape(meta.title or '')}</title>",
|
||||||
|
f" <showtitle>{escape(meta.uploader or '')}</showtitle>",
|
||||||
|
f" <studio>{escape(meta.uploader or '')}</studio>",
|
||||||
|
f" <aired>{aired}</aired>",
|
||||||
|
f" <premiered>{aired}</premiered>",
|
||||||
|
f" <year>{year}</year>",
|
||||||
|
f" <plot>{escape(meta.description or '')}</plot>",
|
||||||
|
f" <uniqueid type=\"youtube\" default=\"true\">{escape(meta.video_id)}</uniqueid>",
|
||||||
|
]
|
||||||
|
if meta.duration_s:
|
||||||
|
parts.append(f" <runtime>{round(meta.duration_s / 60)}</runtime>")
|
||||||
|
parts.append("</episodedetails>")
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def write_sidecars(
|
||||||
|
download_root: str, rel: str, meta: MediaMeta, thumb_src: Path | None
|
||||||
|
) -> bool:
|
||||||
|
"""Write `<basename>.nfo` + `<basename>.jpg` next to the media file. Returns True if written."""
|
||||||
|
media = abs_path(download_root, rel)
|
||||||
|
base = media.with_suffix("")
|
||||||
|
try:
|
||||||
|
base.with_suffix(".nfo").write_text(_nfo_xml(meta), encoding="utf-8")
|
||||||
|
if thumb_src and thumb_src.exists():
|
||||||
|
shutil.copyfile(str(thumb_src), str(base.with_suffix(".jpg")))
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def delete_asset_files(download_root: str, rel: str) -> None:
|
||||||
|
"""Remove the media file and its sidecars; prune now-empty parent dirs (best effort)."""
|
||||||
|
media = abs_path(download_root, rel)
|
||||||
|
base = media.with_suffix("")
|
||||||
|
for p in (media, base.with_suffix(".nfo"), base.with_suffix(".jpg")):
|
||||||
|
try:
|
||||||
|
p.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
# Prune empty Season/Channel dirs up to (but not including) the root.
|
||||||
|
root = Path(download_root).resolve()
|
||||||
|
parent = media.parent
|
||||||
|
while parent != root and root in parent.parents:
|
||||||
|
try:
|
||||||
|
parent.rmdir() # only succeeds if empty
|
||||||
|
except OSError:
|
||||||
|
break
|
||||||
|
parent = parent.parent
|
||||||
|
|
@ -31,12 +31,14 @@ from app.routes import (
|
||||||
admin,
|
admin,
|
||||||
channels,
|
channels,
|
||||||
config as config_routes,
|
config as config_routes,
|
||||||
|
downloads,
|
||||||
feed,
|
feed,
|
||||||
health,
|
health,
|
||||||
me,
|
me,
|
||||||
messages,
|
messages,
|
||||||
notifications,
|
notifications,
|
||||||
playlists,
|
playlists,
|
||||||
|
public as public_routes,
|
||||||
quota,
|
quota,
|
||||||
saved_views,
|
saved_views,
|
||||||
scheduler as scheduler_routes,
|
scheduler as scheduler_routes,
|
||||||
|
|
@ -122,6 +124,9 @@ app.include_router(messages.router)
|
||||||
app.include_router(channels.router)
|
app.include_router(channels.router)
|
||||||
app.include_router(playlists.router)
|
app.include_router(playlists.router)
|
||||||
app.include_router(saved_views.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(admin.router)
|
||||||
app.include_router(config_routes.router)
|
app.include_router(config_routes.router)
|
||||||
app.include_router(scheduler_routes.router)
|
app.include_router(scheduler_routes.router)
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ from sqlalchemy import (
|
||||||
DateTime,
|
DateTime,
|
||||||
Float,
|
Float,
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
|
Index,
|
||||||
Integer,
|
Integer,
|
||||||
JSON,
|
JSON,
|
||||||
String,
|
String,
|
||||||
|
|
@ -240,7 +241,8 @@ class Video(Base, TimestampMixin):
|
||||||
channel_id: Mapped[str] = mapped_column(
|
channel_id: Mapped[str] = mapped_column(
|
||||||
ForeignKey("channels.id", ondelete="CASCADE"), index=True
|
ForeignKey("channels.id", ondelete="CASCADE"), index=True
|
||||||
)
|
)
|
||||||
title: Mapped[str | None] = mapped_column(Text)
|
title: Mapped[str | None] = mapped_column(Text) # normalized for display (see app.titles)
|
||||||
|
original_title: Mapped[str | None] = mapped_column(Text) # raw YouTube title, preserved
|
||||||
description: Mapped[str | None] = mapped_column(Text)
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
published_at: Mapped[datetime | None] = mapped_column(
|
published_at: Mapped[datetime | None] = mapped_column(
|
||||||
DateTime(timezone=True), index=True
|
DateTime(timezone=True), index=True
|
||||||
|
|
@ -688,6 +690,210 @@ class Message(Base):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MediaAsset(Base, TimestampMixin):
|
||||||
|
"""A physical downloaded media file — the SHARED cache layer of the download center.
|
||||||
|
|
||||||
|
Mirrors the app's "shared catalog, private per-user state" model: one row per unique
|
||||||
|
``(source_kind, source_ref, format_sig)`` is the global cache. When a second user requests
|
||||||
|
the same video with a format that hashes to the same `format_sig`, their `download_job`
|
||||||
|
links to this ready asset instantly — no re-download, no cost. Only the SOURCE download is
|
||||||
|
shared; per-user edited derivatives (phase 2) are never cached here.
|
||||||
|
|
||||||
|
`rel_path` is relative to `settings.download_root` (the mount), so the physical filename is
|
||||||
|
system-decided and bound to the source id — never renamed (the user's custom name is display
|
||||||
|
metadata on `download_job`, applied only when downloading to their own machine). The naming
|
||||||
|
fields (`title`/`uploader`/`upload_date`) are denormalized so the Plex-tree path can be built
|
||||||
|
uniformly for both catalog videos and ad-hoc URLs. `ref_count` (active jobs pointing here)
|
||||||
|
plus `last_access_at`/`expires_at` drive retention + LRU eviction in the GC job."""
|
||||||
|
|
||||||
|
__tablename__ = "media_assets"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"source_kind", "source_ref", "format_sig", name="uq_media_asset_cache_key"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
source_kind: Mapped[str] = mapped_column(String(16)) # "youtube" | "url"
|
||||||
|
source_ref: Mapped[str] = mapped_column(String(512)) # video id, or full URL for ad-hoc
|
||||||
|
format_sig: Mapped[str] = mapped_column(String(64)) # hash of the output-affecting spec
|
||||||
|
status: Mapped[str] = mapped_column(
|
||||||
|
String(16), default="pending", server_default="pending", index=True
|
||||||
|
) # "pending" | "ready" | "error"
|
||||||
|
rel_path: Mapped[str | None] = mapped_column(Text) # relative to download_root
|
||||||
|
storyboard_path: Mapped[str | None] = mapped_column(Text) # phase-2 filmstrip mosaic
|
||||||
|
size_bytes: Mapped[int | None] = mapped_column(BigInteger)
|
||||||
|
duration_s: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
width: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
height: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
container: Mapped[str | None] = mapped_column(String(16))
|
||||||
|
vcodec: Mapped[str | None] = mapped_column(String(32))
|
||||||
|
acodec: Mapped[str | None] = mapped_column(String(32))
|
||||||
|
nfo_written: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, default=False, server_default="false"
|
||||||
|
)
|
||||||
|
# Set once the GC has warned holders of the upcoming expiry, so it doesn't warn every run.
|
||||||
|
gc_notified: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, default=False, server_default="false"
|
||||||
|
)
|
||||||
|
# Denormalized naming/metadata for the Plex tree + ad-hoc display.
|
||||||
|
title: Mapped[str | None] = mapped_column(Text)
|
||||||
|
uploader: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
upload_date: Mapped[date | None] = mapped_column(Date)
|
||||||
|
thumbnail_url: Mapped[str | None] = mapped_column(Text)
|
||||||
|
error: Mapped[str | None] = mapped_column(Text)
|
||||||
|
ref_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||||
|
last_access_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadProfile(Base, TimestampMixin):
|
||||||
|
"""A named download preset — a reusable format profile.
|
||||||
|
|
||||||
|
`user_id` NULL + `is_builtin` marks the shipped presets (Best MP4, 1080p, audio-only, …);
|
||||||
|
a user's saved custom profiles carry their `user_id`. `spec` is the format definition
|
||||||
|
(mode av/v/a, max_height, container, audio_format, codec prefs, embed subs/chapters/
|
||||||
|
thumbnail, sponsorblock) that `downloads.formats` turns into a yt-dlp selector + a cache
|
||||||
|
`format_sig`."""
|
||||||
|
|
||||||
|
__tablename__ = "download_profiles"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
user_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
||||||
|
) # NULL for a builtin preset
|
||||||
|
name: Mapped[str] = mapped_column(String(80))
|
||||||
|
spec: Mapped[dict] = mapped_column(JSON)
|
||||||
|
is_builtin: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, default=False, server_default="false"
|
||||||
|
)
|
||||||
|
sort_order: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadJob(Base, TimestampMixin, UpdatedAtMixin):
|
||||||
|
"""A user's request to download one source — the per-user layer over `MediaAsset`.
|
||||||
|
|
||||||
|
The worker claims `queued` jobs with ``FOR UPDATE SKIP LOCKED`` and writes live
|
||||||
|
`progress`/`speed_bps`/`eta_s`/`phase` here (the frontend polls via useLiveQuery — the
|
||||||
|
worker is a separate process and can't reach the in-process WS registry). `profile_snapshot`
|
||||||
|
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).
|
||||||
|
|
||||||
|
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"),)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
asset_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("media_assets.id", ondelete="SET NULL"), index=True
|
||||||
|
)
|
||||||
|
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")
|
||||||
|
)
|
||||||
|
profile_snapshot: Mapped[dict] = mapped_column(JSON)
|
||||||
|
display_name: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
status: Mapped[str] = mapped_column(
|
||||||
|
String(16), default="queued", server_default="queued", index=True
|
||||||
|
) # queued | running | paused | done | error | canceled
|
||||||
|
progress: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||||
|
speed_bps: Mapped[int | None] = mapped_column(BigInteger)
|
||||||
|
eta_s: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
phase: Mapped[str | None] = mapped_column(String(32))
|
||||||
|
error: Mapped[str | None] = mapped_column(Text)
|
||||||
|
queue_pos: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadShare(Base, TimestampMixin):
|
||||||
|
"""A user→user grant: `to_user` may access `from_user`'s downloaded file (private by
|
||||||
|
default). Since assets are already deduped/shared on disk, a share is just an ACL row."""
|
||||||
|
|
||||||
|
__tablename__ = "download_shares"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("job_id", "to_user_id", name="uq_download_share"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
job_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("download_jobs.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
from_user_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
to_user_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadQuota(Base, UpdatedAtMixin):
|
||||||
|
"""Per-user download limits (admin-managed). A row exists only when an admin customizes a
|
||||||
|
user; otherwise the sysconfig `download_default_*` values apply. `unlimited` bypasses the
|
||||||
|
byte cap entirely (e.g. for the admin's own account)."""
|
||||||
|
|
||||||
|
__tablename__ = "download_quotas"
|
||||||
|
|
||||||
|
user_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
|
||||||
|
)
|
||||||
|
max_bytes: Mapped[int] = mapped_column(BigInteger)
|
||||||
|
max_concurrent: Mapped[int] = mapped_column(Integer)
|
||||||
|
max_jobs: Mapped[int] = mapped_column(Integer)
|
||||||
|
unlimited: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, default=False, server_default="false"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
class MessageKey(Base, TimestampMixin):
|
||||||
"""A user's end-to-end-encryption key material for direct messaging (phase 2).
|
"""A user's end-to-end-encryption key material for direct messaging (phase 2).
|
||||||
|
|
||||||
|
|
|
||||||
874
backend/app/routes/downloads.py
Normal file
874
backend/app/routes/downloads.py
Normal file
|
|
@ -0,0 +1,874 @@
|
||||||
|
"""Download center HTTP API — the per-user surface over the worker/cache/quota layers.
|
||||||
|
|
||||||
|
All routes are behind `require_human` (the shared demo account can't spend server disk / needs a
|
||||||
|
real identity). Admin routes add `admin_user`. Live job state is polled by the frontend (the
|
||||||
|
worker is a separate process); this module never downloads — it enqueues, manages job state,
|
||||||
|
serves finished files (range-aware, with the user's custom display name), and shares them.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
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"])
|
||||||
|
|
||||||
|
_VIDEO_ID = re.compile(r"^[A-Za-z0-9_-]{11}$")
|
||||||
|
|
||||||
|
|
||||||
|
# --- source + serialization ----------------------------------------------------------------
|
||||||
|
|
||||||
|
def resolve_source(raw: str) -> tuple[str, str]:
|
||||||
|
"""Map a user-supplied id/URL to (source_kind, source_ref). YouTube is normalized to its
|
||||||
|
11-char id (so the cache dedups across watch/youtu.be/shorts URLs); anything else is kept
|
||||||
|
as a raw URL for yt-dlp's generic extractor."""
|
||||||
|
raw = (raw or "").strip()
|
||||||
|
if not raw:
|
||||||
|
raise HTTPException(status_code=400, detail="A video link or id is required.")
|
||||||
|
if _VIDEO_ID.match(raw):
|
||||||
|
return "youtube", raw
|
||||||
|
if raw.startswith(("http://", "https://")):
|
||||||
|
u = urlparse(raw)
|
||||||
|
host = u.netloc.lower()
|
||||||
|
if "youtube.com" in host:
|
||||||
|
if u.path.startswith(("/shorts/", "/embed/", "/live/")):
|
||||||
|
vid = u.path.split("/")[2] if len(u.path.split("/")) > 2 else ""
|
||||||
|
if _VIDEO_ID.match(vid):
|
||||||
|
return "youtube", vid
|
||||||
|
qs = parse_qs(u.query).get("v", [])
|
||||||
|
if qs and _VIDEO_ID.match(qs[0]):
|
||||||
|
return "youtube", qs[0]
|
||||||
|
if "youtu.be" in host:
|
||||||
|
vid = u.path.lstrip("/").split("/")[0]
|
||||||
|
if _VIDEO_ID.match(vid):
|
||||||
|
return "youtube", vid
|
||||||
|
return "url", raw
|
||||||
|
raise HTTPException(status_code=400, detail="That doesn't look like a valid video link.")
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize(job: DownloadJob, asset: MediaAsset | None) -> dict:
|
||||||
|
ready = asset is not None and asset.status == "ready" and bool(asset.rel_path)
|
||||||
|
return {
|
||||||
|
"id": job.id,
|
||||||
|
"status": job.status,
|
||||||
|
"progress": job.progress,
|
||||||
|
"speed_bps": job.speed_bps,
|
||||||
|
"eta_s": job.eta_s,
|
||||||
|
"phase": job.phase,
|
||||||
|
"error": job.error,
|
||||||
|
"queue_pos": job.queue_pos,
|
||||||
|
"created_at": job.created_at.isoformat() if job.created_at else None,
|
||||||
|
"source_kind": job.source_kind,
|
||||||
|
"source_ref": job.source_ref,
|
||||||
|
"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
|
||||||
|
if asset is None
|
||||||
|
else {
|
||||||
|
"id": asset.id,
|
||||||
|
"status": asset.status,
|
||||||
|
"title": asset.title,
|
||||||
|
"uploader": asset.uploader,
|
||||||
|
"upload_date": asset.upload_date.isoformat() if asset.upload_date else None,
|
||||||
|
"duration_s": asset.duration_s,
|
||||||
|
"size_bytes": asset.size_bytes,
|
||||||
|
"width": asset.width,
|
||||||
|
"height": asset.height,
|
||||||
|
"container": asset.container,
|
||||||
|
"thumbnail_url": asset.thumbnail_url,
|
||||||
|
"expires_at": asset.expires_at.isoformat() if asset.expires_at else None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _assets_for(db: Session, jobs: list[DownloadJob]) -> dict[int, MediaAsset]:
|
||||||
|
ids = {j.asset_id for j in jobs if j.asset_id}
|
||||||
|
if not ids:
|
||||||
|
return {}
|
||||||
|
rows = db.execute(select(MediaAsset).where(MediaAsset.id.in_(ids))).scalars()
|
||||||
|
return {a.id: a for a in rows}
|
||||||
|
|
||||||
|
|
||||||
|
def _own_job(db: Session, user: User, job_id: int) -> DownloadJob:
|
||||||
|
job = db.get(DownloadJob, job_id)
|
||||||
|
if job is None or job.user_id != user.id:
|
||||||
|
raise HTTPException(status_code=404, detail="Unknown download")
|
||||||
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
# --- profiles ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _serialize_profile(p: DownloadProfile) -> dict:
|
||||||
|
return {
|
||||||
|
"id": p.id,
|
||||||
|
"name": p.name,
|
||||||
|
"spec": p.spec,
|
||||||
|
"is_builtin": p.is_builtin,
|
||||||
|
"sort_order": p.sort_order,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/profiles")
|
||||||
|
def list_profiles(
|
||||||
|
user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||||
|
) -> list[dict]:
|
||||||
|
rows = (
|
||||||
|
db.execute(
|
||||||
|
select(DownloadProfile)
|
||||||
|
.where(
|
||||||
|
(DownloadProfile.is_builtin.is_(True))
|
||||||
|
| (DownloadProfile.user_id == user.id)
|
||||||
|
)
|
||||||
|
.order_by(DownloadProfile.is_builtin.desc(), DownloadProfile.sort_order, DownloadProfile.id)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [_serialize_profile(p) for p in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/profiles")
|
||||||
|
def create_profile(
|
||||||
|
payload: dict, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||||
|
) -> dict:
|
||||||
|
name = (payload.get("name") or "").strip()
|
||||||
|
spec = payload.get("spec")
|
||||||
|
if not name:
|
||||||
|
raise HTTPException(status_code=400, detail="A profile name is required.")
|
||||||
|
if not isinstance(spec, dict):
|
||||||
|
raise HTTPException(status_code=400, detail="spec must be an object")
|
||||||
|
maxpos = db.execute(
|
||||||
|
select(func.coalesce(func.max(DownloadProfile.sort_order), 100)).where(
|
||||||
|
DownloadProfile.user_id == user.id
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
p = DownloadProfile(
|
||||||
|
user_id=user.id, name=name[:80], spec=spec, is_builtin=False, sort_order=maxpos + 1
|
||||||
|
)
|
||||||
|
db.add(p)
|
||||||
|
db.commit()
|
||||||
|
return _serialize_profile(p)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/profiles/{profile_id}")
|
||||||
|
def update_profile(
|
||||||
|
profile_id: int,
|
||||||
|
payload: dict,
|
||||||
|
user: User = Depends(require_human),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
p = db.get(DownloadProfile, profile_id)
|
||||||
|
if p is None or p.is_builtin or p.user_id != user.id:
|
||||||
|
raise HTTPException(status_code=404, detail="Unknown profile")
|
||||||
|
if "name" in payload:
|
||||||
|
name = (payload.get("name") or "").strip()
|
||||||
|
if not name:
|
||||||
|
raise HTTPException(status_code=400, detail="A profile name is required.")
|
||||||
|
p.name = name[:80]
|
||||||
|
if "spec" in payload:
|
||||||
|
if not isinstance(payload["spec"], dict):
|
||||||
|
raise HTTPException(status_code=400, detail="spec must be an object")
|
||||||
|
p.spec = payload["spec"]
|
||||||
|
db.commit()
|
||||||
|
return _serialize_profile(p)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/profiles/{profile_id}")
|
||||||
|
def delete_profile(
|
||||||
|
profile_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||||
|
) -> dict:
|
||||||
|
p = db.get(DownloadProfile, profile_id)
|
||||||
|
if p is None or p.is_builtin or p.user_id != user.id:
|
||||||
|
raise HTTPException(status_code=404, detail="Unknown profile")
|
||||||
|
db.delete(p)
|
||||||
|
db.commit()
|
||||||
|
return {"deleted": profile_id}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_spec(db: Session, user: User, payload: dict) -> tuple[dict, int | None]:
|
||||||
|
"""Pick the format spec for an enqueue: an explicit profile_id (builtin or the user's own),
|
||||||
|
an inline spec, or the first builtin as a fallback."""
|
||||||
|
pid = payload.get("profile_id")
|
||||||
|
if pid is not None:
|
||||||
|
p = db.get(DownloadProfile, pid)
|
||||||
|
if p is None or (not p.is_builtin and p.user_id != user.id):
|
||||||
|
raise HTTPException(status_code=404, detail="Unknown profile")
|
||||||
|
return p.spec, p.id
|
||||||
|
if isinstance(payload.get("spec"), dict):
|
||||||
|
return payload["spec"], None
|
||||||
|
fallback = db.execute(
|
||||||
|
select(DownloadProfile)
|
||||||
|
.where(DownloadProfile.is_builtin.is_(True))
|
||||||
|
.order_by(DownloadProfile.sort_order)
|
||||||
|
.limit(1)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if fallback is None:
|
||||||
|
raise HTTPException(status_code=400, detail="No download profile available.")
|
||||||
|
return fallback.spec, fallback.id
|
||||||
|
|
||||||
|
|
||||||
|
# --- enqueue + list + manage ---------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.post("")
|
||||||
|
def enqueue_download(
|
||||||
|
payload: dict, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||||
|
) -> dict:
|
||||||
|
source_kind, source_ref = resolve_source(payload.get("source") or "")
|
||||||
|
spec, profile_id = _resolve_spec(db, user, payload)
|
||||||
|
display_name = (payload.get("display_name") or "").strip() or None
|
||||||
|
try:
|
||||||
|
job = service.enqueue(
|
||||||
|
db, user.id, source_kind, source_ref, spec, profile_id, display_name
|
||||||
|
)
|
||||||
|
except quota.QuotaExceeded as e:
|
||||||
|
raise HTTPException(status_code=422, detail=_quota_message(e))
|
||||||
|
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
|
||||||
|
return _serialize(job, 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."
|
||||||
|
if e.reason == "max_bytes":
|
||||||
|
gb = e.limit / 1_073_741_824
|
||||||
|
return f"You've used your download storage quota ({gb:.1f} GB). Free up space first."
|
||||||
|
return "Download quota exceeded."
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def list_downloads(
|
||||||
|
user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||||
|
) -> list[dict]:
|
||||||
|
jobs = (
|
||||||
|
db.execute(
|
||||||
|
select(DownloadJob)
|
||||||
|
.where(DownloadJob.user_id == user.id)
|
||||||
|
.order_by(DownloadJob.created_at.desc(), DownloadJob.id.desc())
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
assets = _assets_for(db, jobs)
|
||||||
|
return [_serialize(j, assets.get(j.asset_id)) for j in jobs]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/usage")
|
||||||
|
def my_usage(user: User = Depends(require_human), db: Session = Depends(get_db)) -> dict:
|
||||||
|
return quota.usage(db, user.id)
|
||||||
|
|
||||||
|
|
||||||
|
# Priority when a source has several jobs (e.g. a done one plus a fresh queued one).
|
||||||
|
_INDEX_RANK = {"done": 4, "running": 3, "paused": 2, "queued": 1}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/index")
|
||||||
|
def my_download_index(
|
||||||
|
user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||||
|
) -> dict:
|
||||||
|
"""source_ref -> best active status, for the feed's per-card "downloaded / queued" badge.
|
||||||
|
Small + cheap so the feed can poll it without loading the full job list."""
|
||||||
|
rows = db.execute(
|
||||||
|
select(DownloadJob.source_ref, DownloadJob.status).where(
|
||||||
|
DownloadJob.user_id == user.id,
|
||||||
|
DownloadJob.status.in_(("queued", "running", "paused", "done")),
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
out: dict[str, str] = {}
|
||||||
|
for ref, status in rows:
|
||||||
|
if _INDEX_RANK.get(status, 0) > _INDEX_RANK.get(out.get(ref, ""), 0):
|
||||||
|
out[ref] = status
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/shared")
|
||||||
|
def shared_with_me(
|
||||||
|
user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||||
|
) -> list[dict]:
|
||||||
|
jobs = (
|
||||||
|
db.execute(
|
||||||
|
select(DownloadJob)
|
||||||
|
.join(DownloadShare, DownloadShare.job_id == DownloadJob.id)
|
||||||
|
.where(DownloadShare.to_user_id == user.id)
|
||||||
|
.order_by(DownloadShare.created_at.desc())
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
assets = _assets_for(db, jobs)
|
||||||
|
out = []
|
||||||
|
for j in jobs:
|
||||||
|
data = _serialize(j, assets.get(j.asset_id))
|
||||||
|
data["shared"] = True
|
||||||
|
out.append(data)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{job_id}")
|
||||||
|
def rename_download(
|
||||||
|
job_id: int,
|
||||||
|
payload: dict,
|
||||||
|
user: User = Depends(require_human),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
job = _own_job(db, user, job_id)
|
||||||
|
name = (payload.get("display_name") or "").strip()
|
||||||
|
# Display name only — the on-disk file keeps its system-decided, id-bound name.
|
||||||
|
job.display_name = name[:255] or None
|
||||||
|
db.commit()
|
||||||
|
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
|
||||||
|
return _serialize(job, asset)
|
||||||
|
|
||||||
|
|
||||||
|
def _set_status(db: Session, job: DownloadJob, status: str) -> None:
|
||||||
|
job.status = status
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{job_id}/pause")
|
||||||
|
def pause_download(
|
||||||
|
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||||
|
) -> dict:
|
||||||
|
job = _own_job(db, user, job_id)
|
||||||
|
if job.status in ("queued", "running"):
|
||||||
|
_set_status(db, job, "paused") # a running worker aborts cooperatively via the hook
|
||||||
|
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
|
||||||
|
return _serialize(job, asset)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{job_id}/resume")
|
||||||
|
def resume_download(
|
||||||
|
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||||
|
) -> dict:
|
||||||
|
job = _own_job(db, user, job_id)
|
||||||
|
if job.status in ("paused", "error"):
|
||||||
|
job.progress = 0
|
||||||
|
job.error = None
|
||||||
|
# If the shared asset itself failed, reset it to pending so the worker actually
|
||||||
|
# re-downloads — otherwise the requeued job would instantly re-inherit the asset's
|
||||||
|
# stale error (the worker short-circuits a job whose asset is already errored).
|
||||||
|
if job.asset_id:
|
||||||
|
asset = db.get(MediaAsset, job.asset_id)
|
||||||
|
if asset and asset.status == "error":
|
||||||
|
asset.status = "pending"
|
||||||
|
asset.error = None
|
||||||
|
_set_status(db, job, "queued")
|
||||||
|
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
|
||||||
|
return _serialize(job, asset)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{job_id}/cancel")
|
||||||
|
def cancel_download(
|
||||||
|
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||||
|
) -> dict:
|
||||||
|
job = _own_job(db, user, job_id)
|
||||||
|
if job.status not in ("done", "canceled"):
|
||||||
|
_release_asset(db, job) # release while the job still counts as holding
|
||||||
|
job.status = "canceled"
|
||||||
|
db.commit()
|
||||||
|
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
|
||||||
|
return _serialize(job, asset)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{job_id}")
|
||||||
|
def delete_download(
|
||||||
|
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||||
|
) -> dict:
|
||||||
|
job = _own_job(db, user, job_id)
|
||||||
|
_release_asset(db, job)
|
||||||
|
db.delete(job)
|
||||||
|
db.commit()
|
||||||
|
return {"deleted": job_id}
|
||||||
|
|
||||||
|
|
||||||
|
def _release_asset(db: Session, job: DownloadJob) -> None:
|
||||||
|
"""Drop this job's hold on its asset when it leaves a holding state. Once NO job holds the
|
||||||
|
asset anymore, delete the file + row immediately — a deleted download should free its disk,
|
||||||
|
and the shared cache only needs to span *overlapping* holders (a later re-add just downloads
|
||||||
|
again). Idempotent: a job that's already left holding (canceled/error) is a no-op, so delete
|
||||||
|
after cancel doesn't double-release."""
|
||||||
|
if not job.asset_id or job.status not in ("queued", "running", "paused", "done"):
|
||||||
|
return
|
||||||
|
asset = db.get(MediaAsset, job.asset_id)
|
||||||
|
if asset is None:
|
||||||
|
return
|
||||||
|
asset.ref_count = max(0, (asset.ref_count or 0) - 1)
|
||||||
|
if asset.ref_count == 0 and asset.status == "ready":
|
||||||
|
if asset.rel_path:
|
||||||
|
storage.delete_asset_files(settings.download_root, asset.rel_path)
|
||||||
|
db.delete(asset)
|
||||||
|
|
||||||
|
|
||||||
|
# --- file download (range-aware, custom display name) --------------------------------------
|
||||||
|
|
||||||
|
def _clean_basename(name: str) -> str:
|
||||||
|
# Emoji/symbol-free but keeps spaces + accents (a readable device filename).
|
||||||
|
return storage.display_filename(name)
|
||||||
|
|
||||||
|
|
||||||
|
def _accessible_job(db: Session, user: User, job_id: int) -> DownloadJob:
|
||||||
|
job = db.get(DownloadJob, job_id)
|
||||||
|
if job is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Unknown download")
|
||||||
|
if job.user_id == user.id:
|
||||||
|
return job
|
||||||
|
shared = db.execute(
|
||||||
|
select(DownloadShare.id).where(
|
||||||
|
DownloadShare.job_id == job_id, DownloadShare.to_user_id == user.id
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if shared is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Unknown download")
|
||||||
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{job_id}/file")
|
||||||
|
def download_file(
|
||||||
|
job_id: int,
|
||||||
|
request: Request,
|
||||||
|
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 asset.status != "ready" or not asset.rel_path:
|
||||||
|
raise HTTPException(status_code=409, detail="This download isn't ready.")
|
||||||
|
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="File is no longer available.")
|
||||||
|
|
||||||
|
ext = asset.container or path.suffix.lstrip(".")
|
||||||
|
base = _clean_basename(job.display_name or asset.title or asset.source_ref)
|
||||||
|
if base.lower().endswith(f".{ext.lower()}"):
|
||||||
|
base = base[: -(len(ext) + 1)]
|
||||||
|
filename = f"{base}.{ext}"
|
||||||
|
|
||||||
|
asset.last_access_at = datetime.now(timezone.utc)
|
||||||
|
db.commit()
|
||||||
|
# Starlette's FileResponse honours the Range header (206 partial content) for seek/resume.
|
||||||
|
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")
|
||||||
|
def share_download(
|
||||||
|
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.")
|
||||||
|
target = (payload.get("email") or "").strip().lower()
|
||||||
|
if not target:
|
||||||
|
raise HTTPException(status_code=400, detail="A recipient email is required.")
|
||||||
|
recipient = db.execute(
|
||||||
|
select(User).where(func.lower(User.email) == target)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if recipient is None:
|
||||||
|
raise HTTPException(status_code=404, detail="No user with that email.")
|
||||||
|
if recipient.id == user.id:
|
||||||
|
raise HTTPException(status_code=400, detail="That's already your download.")
|
||||||
|
exists = db.execute(
|
||||||
|
select(DownloadShare.id).where(
|
||||||
|
DownloadShare.job_id == job_id, DownloadShare.to_user_id == recipient.id
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if exists is None:
|
||||||
|
db.add(
|
||||||
|
DownloadShare(job_id=job_id, from_user_id=user.id, to_user_id=recipient.id)
|
||||||
|
)
|
||||||
|
from app.notifications import create_notification
|
||||||
|
|
||||||
|
create_notification(
|
||||||
|
db,
|
||||||
|
user_id=recipient.id,
|
||||||
|
type="download_shared",
|
||||||
|
title=(job.display_name or "A download") + " was shared with you",
|
||||||
|
data={"kind": "download_shared", "from": user.email, "job_id": job_id},
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return {"shared_with": recipient.email}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{job_id}/share/{email}")
|
||||||
|
def unshare_download(
|
||||||
|
job_id: int,
|
||||||
|
email: str,
|
||||||
|
user: User = Depends(require_human),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
job = _own_job(db, user, job_id)
|
||||||
|
recipient = db.execute(
|
||||||
|
select(User).where(func.lower(User.email) == email.strip().lower())
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if recipient is not None:
|
||||||
|
row = db.execute(
|
||||||
|
select(DownloadShare).where(
|
||||||
|
DownloadShare.job_id == job_id,
|
||||||
|
DownloadShare.to_user_id == recipient.id,
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if row is not None:
|
||||||
|
db.delete(row)
|
||||||
|
db.commit()
|
||||||
|
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("")
|
||||||
|
def admin_list_downloads(
|
||||||
|
admin: User = Depends(admin_user), db: Session = Depends(get_db)
|
||||||
|
) -> list[dict]:
|
||||||
|
jobs = (
|
||||||
|
db.execute(
|
||||||
|
select(DownloadJob).order_by(DownloadJob.created_at.desc(), DownloadJob.id.desc()).limit(500)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
assets = _assets_for(db, jobs)
|
||||||
|
emails = {
|
||||||
|
u.id: u.email
|
||||||
|
for u in db.execute(
|
||||||
|
select(User).where(User.id.in_({j.user_id for j in jobs}))
|
||||||
|
).scalars()
|
||||||
|
}
|
||||||
|
out = []
|
||||||
|
for j in jobs:
|
||||||
|
data = _serialize(j, assets.get(j.asset_id))
|
||||||
|
data["user_email"] = emails.get(j.user_id)
|
||||||
|
out.append(data)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@admin_router.get("/storage")
|
||||||
|
def admin_storage(
|
||||||
|
admin: User = Depends(admin_user), db: Session = Depends(get_db)
|
||||||
|
) -> dict:
|
||||||
|
ready = db.execute(
|
||||||
|
select(func.count(), func.coalesce(func.sum(MediaAsset.size_bytes), 0)).where(
|
||||||
|
MediaAsset.status == "ready"
|
||||||
|
)
|
||||||
|
).one()
|
||||||
|
total_assets = db.execute(select(func.count()).select_from(MediaAsset)).scalar()
|
||||||
|
per_user = db.execute(
|
||||||
|
select(DownloadJob.user_id, func.count(func.distinct(DownloadJob.asset_id)))
|
||||||
|
.where(DownloadJob.asset_id.is_not(None))
|
||||||
|
.group_by(DownloadJob.user_id)
|
||||||
|
).all()
|
||||||
|
emails = {
|
||||||
|
u.id: u.email
|
||||||
|
for u in db.execute(
|
||||||
|
select(User).where(User.id.in_([uid for uid, _ in per_user]))
|
||||||
|
).scalars()
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"ready_files": ready[0],
|
||||||
|
"total_bytes": int(ready[1]),
|
||||||
|
"total_assets": total_assets,
|
||||||
|
"total_cap_bytes": settings.download_total_max_bytes,
|
||||||
|
"per_user": [
|
||||||
|
{"user_id": uid, "email": emails.get(uid), "footprint_bytes": quota.footprint(db, uid)}
|
||||||
|
for uid, _ in per_user
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@admin_router.get("/quota/{user_id}")
|
||||||
|
def admin_get_quota(
|
||||||
|
user_id: int, admin: User = Depends(admin_user), db: Session = Depends(get_db)
|
||||||
|
) -> dict:
|
||||||
|
lim = quota.resolve(db, user_id)
|
||||||
|
row = db.get(DownloadQuota, user_id)
|
||||||
|
return {
|
||||||
|
"user_id": user_id,
|
||||||
|
"custom": row is not None,
|
||||||
|
"max_bytes": lim.max_bytes,
|
||||||
|
"max_concurrent": lim.max_concurrent,
|
||||||
|
"max_jobs": lim.max_jobs,
|
||||||
|
"unlimited": lim.unlimited,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@admin_router.put("/quota/{user_id}")
|
||||||
|
def admin_set_quota(
|
||||||
|
user_id: int,
|
||||||
|
payload: dict,
|
||||||
|
admin: User = Depends(admin_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
if db.get(User, user_id) is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Unknown user")
|
||||||
|
cur = quota.resolve(db, user_id)
|
||||||
|
row = db.get(DownloadQuota, user_id)
|
||||||
|
if row is None:
|
||||||
|
row = DownloadQuota(
|
||||||
|
user_id=user_id,
|
||||||
|
max_bytes=cur.max_bytes,
|
||||||
|
max_concurrent=cur.max_concurrent,
|
||||||
|
max_jobs=cur.max_jobs,
|
||||||
|
unlimited=cur.unlimited,
|
||||||
|
)
|
||||||
|
db.add(row)
|
||||||
|
if "max_bytes" in payload:
|
||||||
|
row.max_bytes = max(0, int(payload["max_bytes"]))
|
||||||
|
if "max_concurrent" in payload:
|
||||||
|
row.max_concurrent = max(1, int(payload["max_concurrent"]))
|
||||||
|
if "max_jobs" in payload:
|
||||||
|
row.max_jobs = max(1, int(payload["max_jobs"]))
|
||||||
|
if "unlimited" in payload:
|
||||||
|
row.unlimited = bool(payload["unlimited"])
|
||||||
|
db.commit()
|
||||||
|
return admin_get_quota(user_id, admin, db)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_router.delete("/quota/{user_id}")
|
||||||
|
def admin_reset_quota(
|
||||||
|
user_id: int, admin: User = Depends(admin_user), db: Session = Depends(get_db)
|
||||||
|
) -> dict:
|
||||||
|
row = db.get(DownloadQuota, user_id)
|
||||||
|
if row is not None:
|
||||||
|
db.delete(row)
|
||||||
|
db.commit()
|
||||||
|
return admin_get_quota(user_id, admin, db)
|
||||||
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)
|
||||||
|
|
@ -12,6 +12,7 @@ from sqlalchemy import select
|
||||||
from app import progress, quota
|
from app import progress, quota
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db import SessionLocal
|
from app.db import SessionLocal
|
||||||
|
from app.downloads.gc import run_download_gc
|
||||||
from app.models import SchedulerSetting
|
from app.models import SchedulerSetting
|
||||||
from app.notifications import create_notification
|
from app.notifications import create_notification
|
||||||
from app.state import is_sync_paused
|
from app.state import is_sync_paused
|
||||||
|
|
@ -59,6 +60,7 @@ JOB_INTERVALS: dict[str, int] = {
|
||||||
"maintenance": settings.maintenance_interval_minutes,
|
"maintenance": settings.maintenance_interval_minutes,
|
||||||
"demo_reset": settings.demo_reset_minutes,
|
"demo_reset": settings.demo_reset_minutes,
|
||||||
"explore_cleanup": settings.explore_cleanup_minutes,
|
"explore_cleanup": settings.explore_cleanup_minutes,
|
||||||
|
"download_gc": settings.download_gc_minutes,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Sane bounds for an admin-set interval (minutes).
|
# Sane bounds for an admin-set interval (minutes).
|
||||||
|
|
@ -276,6 +278,12 @@ def _explore_cleanup_job() -> None:
|
||||||
_job("explore_cleanup", purge_ephemeral)
|
_job("explore_cleanup", purge_ephemeral)
|
||||||
|
|
||||||
|
|
||||||
|
def _download_gc_job() -> None:
|
||||||
|
# Retention GC for the download center: pre-expiry warnings, TTL deletion, LRU eviction.
|
||||||
|
# Pure disk/DB work (no YouTube quota).
|
||||||
|
_job("download_gc", run_download_gc)
|
||||||
|
|
||||||
|
|
||||||
# job_id -> wrapper. The single source of truth for which jobs exist and how to run one,
|
# job_id -> wrapper. The single source of truth for which jobs exist and how to run one,
|
||||||
# shared by start_scheduler (recurring registration) and trigger_job (manual "run now").
|
# shared by start_scheduler (recurring registration) and trigger_job (manual "run now").
|
||||||
JOB_FUNCS: dict[str, Callable[[], None]] = {
|
JOB_FUNCS: dict[str, Callable[[], None]] = {
|
||||||
|
|
@ -289,6 +297,7 @@ JOB_FUNCS: dict[str, Callable[[], None]] = {
|
||||||
"maintenance": _maintenance_job,
|
"maintenance": _maintenance_job,
|
||||||
"demo_reset": _demo_reset_job,
|
"demo_reset": _demo_reset_job,
|
||||||
"explore_cleanup": _explore_cleanup_job,
|
"explore_cleanup": _explore_cleanup_job,
|
||||||
|
"download_gc": _download_gc_job,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import progress, sysconfig
|
from app import progress, sysconfig
|
||||||
from app.models import Channel, Video
|
from app.models import Channel, Video
|
||||||
|
from app.titles import normalize_title
|
||||||
from app.youtube.client import YouTubeClient, best_thumbnail
|
from app.youtube.client import YouTubeClient, best_thumbnail
|
||||||
from app.youtube.rss import fetch_channel_feed
|
from app.youtube.rss import fetch_channel_feed
|
||||||
from app.youtube.shorts import make_client, probe_is_short
|
from app.youtube.shorts import make_client, probe_is_short
|
||||||
|
|
@ -53,6 +54,11 @@ def _insert_stubs(db: Session, rows: list[dict]) -> int:
|
||||||
return 0
|
return 0
|
||||||
seen: dict[str, dict] = {}
|
seen: dict[str, dict] = {}
|
||||||
for row in rows:
|
for row in rows:
|
||||||
|
# Normalize the title at first insert too (RSS/backfill stubs), so a brand-new video
|
||||||
|
# never flashes its raw title before enrichment refreshes it.
|
||||||
|
raw = row.get("title")
|
||||||
|
if raw:
|
||||||
|
row = {**row, "original_title": raw, "title": normalize_title(raw)}
|
||||||
seen[row["id"]] = row
|
seen[row["id"]] = row
|
||||||
stmt = (
|
stmt = (
|
||||||
pg_insert(Video)
|
pg_insert(Video)
|
||||||
|
|
@ -225,7 +231,11 @@ def apply_video_details(video: Video, item: dict) -> None:
|
||||||
live = item.get("liveStreamingDetails")
|
live = item.get("liveStreamingDetails")
|
||||||
status = item.get("status", {})
|
status = item.get("status", {})
|
||||||
|
|
||||||
video.title = snippet.get("title") or video.title
|
raw_title = snippet.get("title")
|
||||||
|
if raw_title:
|
||||||
|
# Store the raw title and a normalized one for display (feed/search/downloads).
|
||||||
|
video.original_title = raw_title
|
||||||
|
video.title = normalize_title(raw_title)
|
||||||
video.description = snippet.get("description")
|
video.description = snippet.get("description")
|
||||||
if not video.published_at:
|
if not video.published_at:
|
||||||
video.published_at = parse_dt(snippet.get("publishedAt"))
|
video.published_at = parse_dt(snippet.get("publishedAt"))
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,16 @@ SPECS: tuple[ConfigSpec, ...] = (
|
||||||
ConfigSpec("google_client_secret", "str", "google", "google_client_secret", secret=True),
|
ConfigSpec("google_client_secret", "str", "google", "google_client_secret", secret=True),
|
||||||
# --- Access / registration ---
|
# --- Access / registration ---
|
||||||
ConfigSpec("allow_registration", "bool", "access", "allow_registration"),
|
ConfigSpec("allow_registration", "bool", "access", "allow_registration"),
|
||||||
|
# --- Download center (yt-dlp) — per-user defaults + retention/GC + layout ---
|
||||||
|
ConfigSpec("download_total_max_bytes", "int", "downloads", "download_total_max_bytes", min=0),
|
||||||
|
ConfigSpec("download_default_max_bytes", "int", "downloads", "download_default_max_bytes", min=0),
|
||||||
|
ConfigSpec("download_default_max_concurrent", "int", "downloads", "download_default_max_concurrent", min=1, max=20),
|
||||||
|
ConfigSpec("download_default_max_jobs", "int", "downloads", "download_default_max_jobs", min=1, max=100_000),
|
||||||
|
ConfigSpec("download_retention_days", "int", "downloads", "download_retention_days", min=0, max=3_650),
|
||||||
|
ConfigSpec("download_gc_grace_days", "int", "downloads", "download_gc_grace_days", min=0, max=3_650),
|
||||||
|
ConfigSpec("download_layout", "str", "downloads", "download_layout"),
|
||||||
|
ConfigSpec("download_worker_concurrency", "int", "downloads", "download_worker_concurrency", min=1, max=16),
|
||||||
|
ConfigSpec("sponsorblock_default", "bool", "downloads", "sponsorblock_default"),
|
||||||
)
|
)
|
||||||
|
|
||||||
_BY_KEY: dict[str, ConfigSpec] = {s.key: s for s in SPECS}
|
_BY_KEY: dict[str, ConfigSpec] = {s.key: s for s in SPECS}
|
||||||
|
|
|
||||||
80
backend/app/titles.py
Normal file
80
backend/app/titles.py
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
"""Video title normalization — clean the noisy YouTube titles for display + storage.
|
||||||
|
|
||||||
|
Applied where a video's title is written (enrichment) and where a download's title comes from
|
||||||
|
yt-dlp, so the feed, search, channel pages, and the download center all show tidy titles. The
|
||||||
|
raw title is preserved in `Video.original_title` so this is reversible / re-derivable.
|
||||||
|
|
||||||
|
Rules (user-approved "option b"):
|
||||||
|
1. Drop emoji / pictographs / symbols / control chars (keep accents — HU/DE/…).
|
||||||
|
2. Strip trailing SEO hashtag clusters (word hashtags at the end); a numeric "#3" episode
|
||||||
|
marker survives.
|
||||||
|
3. De-shout ALL-CAPS words, context-aware:
|
||||||
|
- If the title is *mostly shouting* (≥ half the multi-letter words are all-caps), every
|
||||||
|
all-caps word is Title-cased (short shouted words like CAR/WAR/ÉN get fixed too), and
|
||||||
|
the first letter is capitalized. Known acronyms (PS, AI, USA, PC…) and words with
|
||||||
|
digits (PS5, 3D) are kept; function words (to/the/és/az…) are lowercased.
|
||||||
|
- Otherwise only long all-caps words (≥4 letters) are Title-cased, so a lone acronym in
|
||||||
|
an otherwise normal title is left alone.
|
||||||
|
4. Collapse repeated punctuation (!!! → !) and whitespace.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
|
_DROP_CATEGORIES = {"So", "Sk", "Cc", "Cf", "Cs", "Co", "Cn"}
|
||||||
|
_LETTER_RUN = re.compile(r"[^\W\d_]+", re.UNICODE)
|
||||||
|
_TRAILING_HASHTAGS = re.compile(r"(?:\s+#[^\s#]*[^\W\d\s_][^\s#]*)+\s*$", re.UNICODE)
|
||||||
|
_REPEAT_PUNCT = re.compile(r"([!?.,\-])\1{1,}")
|
||||||
|
|
||||||
|
# Short function words → lowercased when the title is de-shouted (proper title-case style).
|
||||||
|
_SHORT_LOWER = {
|
||||||
|
"to", "the", "and", "of", "a", "an", "in", "on", "at", "for", "or", "vs", "the",
|
||||||
|
"és", "az", "egy", "meg", "nem", "hogy", "de", "ha", "der", "die", "das", "und", "von",
|
||||||
|
}
|
||||||
|
# Kept as-is even when everything else is de-shouted (common acronyms; compared lowercased).
|
||||||
|
_ACRONYMS = {
|
||||||
|
"ps", "ai", "pc", "tv", "hd", "4k", "8k", "uk", "eu", "us", "usa", "gta", "rpg", "fps",
|
||||||
|
"diy", "vr", "ar", "id", "ok", "faq", "nasa", "fbi", "cia", "ceo", "amd", "pdf", "usb",
|
||||||
|
"gps", "api", "dj", "mc", "suv", "gpu", "cpu", "ssd", "hdmi", "led", "ufo", "dna", "nba",
|
||||||
|
"nfl", "asmr", "pov", "diy", "wtf", "lol", "rtx", "gtx", "ios", "mmo", "vip", "hp",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _titlecase(w: str) -> str:
|
||||||
|
return w[0].upper() + w[1:].lower()
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_title(raw: str | None) -> str | None:
|
||||||
|
"""Return the cleaned title, or the input unchanged for empty/whitespace."""
|
||||||
|
if not raw or not raw.strip():
|
||||||
|
return raw
|
||||||
|
t = unicodedata.normalize("NFKC", raw)
|
||||||
|
t = "".join(ch for ch in t if unicodedata.category(ch) not in _DROP_CATEGORIES)
|
||||||
|
t = _TRAILING_HASHTAGS.sub("", t)
|
||||||
|
|
||||||
|
multi = [r for r in _LETTER_RUN.findall(t) if len(r) >= 2]
|
||||||
|
caps = [r for r in multi if r.isupper()]
|
||||||
|
shouting = len(caps) >= 2 and len(caps) >= 0.5 * len(multi)
|
||||||
|
|
||||||
|
def repl(m: re.Match) -> str:
|
||||||
|
w = m.group(0)
|
||||||
|
if len(w) < 2 or not w.isupper():
|
||||||
|
return w
|
||||||
|
low = w.lower()
|
||||||
|
if low in _ACRONYMS:
|
||||||
|
return w
|
||||||
|
if low in _SHORT_LOWER:
|
||||||
|
return low
|
||||||
|
if shouting or len(w) >= 4:
|
||||||
|
return _titlecase(w)
|
||||||
|
return w # short all-caps in a non-shouting title → likely an acronym, keep
|
||||||
|
|
||||||
|
t = _LETTER_RUN.sub(repl, t)
|
||||||
|
t = _REPEAT_PUNCT.sub(r"\1", t)
|
||||||
|
t = re.sub(r"\s+", " ", t).strip()
|
||||||
|
|
||||||
|
if shouting: # ensure the first letter is capitalized (a leading function word got lowered)
|
||||||
|
for i, ch in enumerate(t):
|
||||||
|
if ch.isalpha():
|
||||||
|
t = t[:i] + ch.upper() + t[i + 1:]
|
||||||
|
break
|
||||||
|
return t or raw
|
||||||
590
backend/app/worker.py
Normal file
590
backend/app/worker.py
Normal file
|
|
@ -0,0 +1,590 @@
|
||||||
|
"""Download worker process entry point.
|
||||||
|
|
||||||
|
Runs in a DEDICATED container (`command: python -m app.worker`, `WORKER_ENABLED=1`), separate
|
||||||
|
from the API so long yt-dlp downloads + ffmpeg postprocessing never block web requests.
|
||||||
|
|
||||||
|
Design:
|
||||||
|
* N loop threads (download_worker_concurrency) each claim a `queued` DownloadJob with
|
||||||
|
FOR UPDATE SKIP LOCKED and process it.
|
||||||
|
* The shared MediaAsset is the dedup unit. A job whose asset is already `ready` finishes
|
||||||
|
instantly (no re-download); a fresh asset is claimed pending→downloading by exactly one
|
||||||
|
worker (a DB compare-and-set), so two jobs for the same video+format never double-download.
|
||||||
|
* Live progress is written to the job row (short-lived sessions, throttled ~1/s) — the
|
||||||
|
frontend polls it; the worker can't reach the API's in-process WebSocket registry.
|
||||||
|
* Pause/cancel are cooperative: the progress hook re-reads the job status and aborts the
|
||||||
|
yt-dlp download if it's no longer `running`.
|
||||||
|
* Crash-safe: on startup, orphaned `running` jobs are requeued and `downloading` assets reset.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
import shutil
|
||||||
|
import signal
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import date, datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yt_dlp
|
||||||
|
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
|
||||||
|
|
||||||
|
log = logging.getLogger("siftlode.worker")
|
||||||
|
|
||||||
|
_stop = threading.Event()
|
||||||
|
|
||||||
|
|
||||||
|
class _Aborted(Exception):
|
||||||
|
"""Raised inside the progress hook when the job was paused/canceled mid-download."""
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_signal(signum, frame):
|
||||||
|
_stop.set()
|
||||||
|
|
||||||
|
|
||||||
|
# --- small DB helpers (short-lived sessions; the download itself holds no transaction) -------
|
||||||
|
|
||||||
|
def _set_job(job_id: int, **fields) -> None:
|
||||||
|
with SessionLocal() as db:
|
||||||
|
job = db.get(DownloadJob, job_id)
|
||||||
|
if job is None:
|
||||||
|
return
|
||||||
|
for k, v in fields.items():
|
||||||
|
setattr(job, k, v)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _job_status(job_id: int) -> str | None:
|
||||||
|
with SessionLocal() as db:
|
||||||
|
return db.execute(
|
||||||
|
text("SELECT status FROM download_jobs WHERE id = :id"), {"id": job_id}
|
||||||
|
).scalar()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_upload_date(raw) -> date | None:
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.strptime(str(raw), "%Y%m%d").date()
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# --- claim + recovery -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _recover_orphans() -> None:
|
||||||
|
"""Reset state left behind by a previous crash so nothing is stuck."""
|
||||||
|
with SessionLocal() as db:
|
||||||
|
db.execute(text("UPDATE download_jobs SET status='queued' WHERE status='running'"))
|
||||||
|
db.execute(text("UPDATE media_assets SET status='pending' WHERE status='downloading'"))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _claim_job() -> int | None:
|
||||||
|
"""Claim the oldest queued job whose user is under their per-user concurrency limit.
|
||||||
|
|
||||||
|
Fetches a window of locked candidates (FOR UPDATE SKIP LOCKED) and picks the first eligible
|
||||||
|
one, so one user flooding the queue can't starve others beyond their max_concurrent."""
|
||||||
|
with SessionLocal() as db:
|
||||||
|
rows = db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT id, user_id FROM download_jobs
|
||||||
|
WHERE status='queued'
|
||||||
|
ORDER BY queue_pos, id
|
||||||
|
FOR UPDATE SKIP LOCKED
|
||||||
|
LIMIT 50
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
).fetchall()
|
||||||
|
chosen = next(
|
||||||
|
(jid for jid, uid in rows if not quota.at_concurrency_limit(db, uid)), None
|
||||||
|
)
|
||||||
|
if chosen is None:
|
||||||
|
db.commit() # release the row locks; nothing eligible right now
|
||||||
|
return None
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE download_jobs SET status='running', phase='starting', updated_at=now() "
|
||||||
|
"WHERE id = :id"
|
||||||
|
),
|
||||||
|
{"id": chosen},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return chosen
|
||||||
|
|
||||||
|
|
||||||
|
def _claim_asset(asset_id: int) -> bool:
|
||||||
|
"""Compare-and-set pending→downloading. True if THIS worker won the right to download."""
|
||||||
|
with SessionLocal() as db:
|
||||||
|
row = db.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE media_assets SET status='downloading' "
|
||||||
|
"WHERE id = :id AND status='pending' RETURNING id"
|
||||||
|
),
|
||||||
|
{"id": asset_id},
|
||||||
|
).fetchone()
|
||||||
|
db.commit()
|
||||||
|
return row is not None
|
||||||
|
|
||||||
|
|
||||||
|
# --- processing -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _process_job(job_id: int) -> None:
|
||||||
|
with SessionLocal() as db:
|
||||||
|
job = db.get(DownloadJob, job_id)
|
||||||
|
if job is None:
|
||||||
|
return
|
||||||
|
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
|
||||||
|
|
||||||
|
if asset is None:
|
||||||
|
_set_job(job_id, status="error", error="No media asset", phase=None)
|
||||||
|
return
|
||||||
|
|
||||||
|
if asset_status == "ready":
|
||||||
|
_finish_from_cache(job_id, asset_id)
|
||||||
|
return
|
||||||
|
if asset_status == "error":
|
||||||
|
_set_job(job_id, status="error", error=asset.error or "Download failed", phase=None)
|
||||||
|
return
|
||||||
|
if asset_status == "downloading":
|
||||||
|
# Another worker owns this asset; wait and let the job be reclaimed later.
|
||||||
|
_set_job(job_id, status="queued", phase="waiting")
|
||||||
|
time.sleep(2)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
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:
|
||||||
|
with SessionLocal() as db:
|
||||||
|
job = db.get(DownloadJob, job_id)
|
||||||
|
asset = db.get(MediaAsset, asset_id)
|
||||||
|
if job and asset:
|
||||||
|
job.status = "done"
|
||||||
|
job.progress = 100
|
||||||
|
job.phase = None
|
||||||
|
if not job.display_name:
|
||||||
|
job.display_name = asset.title
|
||||||
|
asset.last_access_at = datetime.now(timezone.utc)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _fill_asset_meta_early(asset_id: int, info: dict) -> None:
|
||||||
|
"""Populate an asset's display metadata from yt-dlp's info_dict the first time it's seen
|
||||||
|
(only if not already filled at enqueue from our catalog) — so an ad-hoc URL's row shows a
|
||||||
|
real title + thumbnail while it downloads, not just the URL. Zero extra network cost."""
|
||||||
|
with SessionLocal() as db:
|
||||||
|
asset = db.get(MediaAsset, asset_id)
|
||||||
|
if asset is None or asset.title:
|
||||||
|
return
|
||||||
|
asset.title = normalize_title(info.get("title"))
|
||||||
|
asset.uploader = info.get("uploader") or info.get("channel")
|
||||||
|
asset.thumbnail_url = info.get("thumbnail")
|
||||||
|
asset.duration_s = int(info["duration"]) if info.get("duration") else None
|
||||||
|
asset.upload_date = _parse_upload_date(info.get("upload_date"))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_progress_hook(job_id: int, asset_id: int):
|
||||||
|
state = {"last_db": 0.0, "last_check": 0.0, "meta_done": False}
|
||||||
|
|
||||||
|
def hook(d: dict) -> None:
|
||||||
|
now = time.monotonic()
|
||||||
|
# Cooperative pause/cancel: if the job is no longer 'running', abort the download.
|
||||||
|
if now - state["last_check"] >= 2.0:
|
||||||
|
state["last_check"] = now
|
||||||
|
if _job_status(job_id) not in ("running", None):
|
||||||
|
raise _Aborted
|
||||||
|
if not state["meta_done"] and d.get("info_dict"):
|
||||||
|
state["meta_done"] = True
|
||||||
|
try:
|
||||||
|
_fill_asset_meta_early(asset_id, d["info_dict"])
|
||||||
|
except Exception: # noqa: BLE001 — metadata is best-effort, never fail a download
|
||||||
|
pass
|
||||||
|
if d.get("status") != "downloading":
|
||||||
|
return
|
||||||
|
if now - state["last_db"] < 1.0:
|
||||||
|
return
|
||||||
|
state["last_db"] = now
|
||||||
|
# yt-dlp downloads the video and audio streams SEPARATELY (each 0→100%), then merges.
|
||||||
|
# Label the phase so the user understands the bar resetting between streams instead of
|
||||||
|
# seeing a mysterious 95%→5% jump.
|
||||||
|
info = d.get("info_dict") or {}
|
||||||
|
vcodec = info.get("vcodec") or "none"
|
||||||
|
acodec = info.get("acodec") or "none"
|
||||||
|
if vcodec != "none" and acodec == "none":
|
||||||
|
phase = "video"
|
||||||
|
elif acodec != "none" and vcodec == "none":
|
||||||
|
phase = "audio"
|
||||||
|
else:
|
||||||
|
phase = "media"
|
||||||
|
total = d.get("total_bytes") or d.get("total_bytes_estimate") or 0
|
||||||
|
done = d.get("downloaded_bytes") or 0
|
||||||
|
pct = int(done * 100 / total) if total else 0
|
||||||
|
_set_job(
|
||||||
|
job_id,
|
||||||
|
progress=min(pct, 99),
|
||||||
|
speed_bps=int(d["speed"]) if d.get("speed") else None,
|
||||||
|
eta_s=int(d["eta"]) if d.get("eta") else None,
|
||||||
|
phase=phase,
|
||||||
|
)
|
||||||
|
|
||||||
|
return hook
|
||||||
|
|
||||||
|
|
||||||
|
def _pp_phase(pp: str) -> str:
|
||||||
|
"""Map a yt-dlp postprocessor class name to a user-facing phase label. ffmpeg post-steps
|
||||||
|
aren't byte-progress operations (no %), so the UI shows the step name + an indeterminate
|
||||||
|
pulse — this makes the long merge/finalize legible instead of a silent 'Processing'."""
|
||||||
|
if "Merg" in pp:
|
||||||
|
return "merging"
|
||||||
|
if "ExtractAudio" in pp:
|
||||||
|
return "audio_extract"
|
||||||
|
if "Thumbnail" in pp:
|
||||||
|
return "thumbnail"
|
||||||
|
if "SponsorBlock" in pp or "ModifyChapters" in pp:
|
||||||
|
return "sponsorblock"
|
||||||
|
if "Metadata" in pp:
|
||||||
|
return "metadata"
|
||||||
|
return "processing"
|
||||||
|
|
||||||
|
|
||||||
|
def _make_pp_hook(job_id: int):
|
||||||
|
"""Postprocessor hook: surface the current ffmpeg step so the bar shows a concrete label
|
||||||
|
(Merging / Embedding thumbnail / …) instead of freezing at 100% after the streams finish."""
|
||||||
|
|
||||||
|
def hook(d: dict) -> None:
|
||||||
|
if d.get("status") in ("started", "processing"):
|
||||||
|
_set_job(job_id, phase=_pp_phase(d.get("postprocessor") or ""), speed_bps=None, eta_s=None)
|
||||||
|
|
||||||
|
return hook
|
||||||
|
|
||||||
|
|
||||||
|
def _staging_dir(asset_id: int) -> Path:
|
||||||
|
return Path(settings.download_root) / ".staging" / f"asset-{asset_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def _find_thumbnail(staging: Path, media: Path) -> Path | None:
|
||||||
|
for p in staging.iterdir():
|
||||||
|
if p != media and p.suffix.lower() in (".jpg", ".jpeg", ".png", ".webp"):
|
||||||
|
return p
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Errors worth retrying with a different player-client set (YouTube per-client flakiness).
|
||||||
|
_RETRIABLE_MARKERS = (
|
||||||
|
"not a bot",
|
||||||
|
"DRM protected",
|
||||||
|
"Requested format is not available",
|
||||||
|
"Only images are available",
|
||||||
|
"Sign in to confirm",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_with_fallback(job_id: int, asset_id: int, url: str, spec: dict, staging: Path):
|
||||||
|
"""Download via yt-dlp, trying the primary player clients then the POT-backed fallback set
|
||||||
|
on a bot/DRM/format failure. Returns the info dict of the successful attempt."""
|
||||||
|
client_sets = [
|
||||||
|
c for c in (settings.player_client_list, settings.player_client_fallback_list) if c
|
||||||
|
] or [None]
|
||||||
|
outtmpl = str(staging / "%(id)s.%(ext)s")
|
||||||
|
last_exc: Exception | None = None
|
||||||
|
for i, clients in enumerate(client_sets):
|
||||||
|
opts = formats.build_ydl_opts(
|
||||||
|
spec, outtmpl, _make_progress_hook(job_id, asset_id),
|
||||||
|
settings.download_pot_base_url, clients,
|
||||||
|
)
|
||||||
|
opts["postprocessor_hooks"] = [_make_pp_hook(job_id)]
|
||||||
|
try:
|
||||||
|
with yt_dlp.YoutubeDL(opts) as ydl:
|
||||||
|
return ydl.extract_info(url, download=True)
|
||||||
|
except yt_dlp.utils.DownloadError as exc:
|
||||||
|
last_exc = exc
|
||||||
|
retriable = any(m in str(exc) for m in _RETRIABLE_MARKERS)
|
||||||
|
if i < len(client_sets) - 1 and retriable:
|
||||||
|
log.info("job %s: clients %s failed (%s); retrying with fallback", job_id, clients, str(exc)[:70])
|
||||||
|
for p in list(staging.iterdir()): # drop partials before the retry
|
||||||
|
try:
|
||||||
|
p.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
_set_job(job_id, progress=0, phase="downloading")
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
raise last_exc # unreachable (loop either returns or raises)
|
||||||
|
|
||||||
|
|
||||||
|
def _download(job_id: int, asset_id: int, source_kind: str, source_ref: str, spec: dict) -> None:
|
||||||
|
staging = _staging_dir(asset_id)
|
||||||
|
try:
|
||||||
|
if staging.exists():
|
||||||
|
shutil.rmtree(staging, ignore_errors=True)
|
||||||
|
staging.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
_set_job(job_id, phase="downloading", progress=0)
|
||||||
|
url = service.source_url(source_kind, source_ref)
|
||||||
|
info = _extract_with_fallback(job_id, asset_id, url, spec, staging)
|
||||||
|
|
||||||
|
req = (info.get("requested_downloads") or [{}])[0]
|
||||||
|
produced = Path(req.get("filepath") or "")
|
||||||
|
if not produced.exists():
|
||||||
|
# Fallback: the single media file left in staging (ignore the thumbnail).
|
||||||
|
cands = [p for p in staging.iterdir() if p.suffix.lower() not in (".jpg", ".jpeg", ".png", ".webp")]
|
||||||
|
if not cands:
|
||||||
|
raise RuntimeError("yt-dlp produced no output file")
|
||||||
|
produced = cands[0]
|
||||||
|
|
||||||
|
ext = produced.suffix.lstrip(".").lower()
|
||||||
|
meta = storage.MediaMeta(
|
||||||
|
video_id=info.get("id") or source_ref,
|
||||||
|
title=normalize_title(info.get("title")) or source_ref,
|
||||||
|
uploader=info.get("uploader") or info.get("channel") or "Unknown Channel",
|
||||||
|
upload_date=_parse_upload_date(info.get("upload_date")),
|
||||||
|
duration_s=int(info["duration"]) if info.get("duration") else None,
|
||||||
|
description=info.get("description"),
|
||||||
|
thumbnail_url=info.get("thumbnail"),
|
||||||
|
)
|
||||||
|
|
||||||
|
rel = storage.rel_path(meta, ext, settings.download_layout)
|
||||||
|
thumb = _find_thumbnail(staging, produced)
|
||||||
|
storage.place_file(produced, settings.download_root, rel)
|
||||||
|
nfo_ok = storage.write_sidecars(settings.download_root, rel, meta, thumb)
|
||||||
|
|
||||||
|
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.duration_s = meta.duration_s
|
||||||
|
asset.width = int(info["width"]) if info.get("width") else None
|
||||||
|
asset.height = int(info["height"]) if info.get("height") else None
|
||||||
|
asset.container = ext
|
||||||
|
asset.vcodec = info.get("vcodec") if info.get("vcodec") not in (None, "none") else None
|
||||||
|
asset.acodec = info.get("acodec") if info.get("acodec") not in (None, "none") else None
|
||||||
|
asset.title = meta.title
|
||||||
|
asset.uploader = meta.uploader
|
||||||
|
asset.upload_date = meta.upload_date
|
||||||
|
asset.thumbnail_url = meta.thumbnail_url
|
||||||
|
asset.nfo_written = nfo_ok
|
||||||
|
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
|
||||||
|
job.speed_bps = None
|
||||||
|
job.eta_s = None
|
||||||
|
if not job.display_name:
|
||||||
|
job.display_name = meta.title
|
||||||
|
db.commit()
|
||||||
|
log.info("job %s done: %s", job_id, rel)
|
||||||
|
|
||||||
|
except _Aborted:
|
||||||
|
# Paused/canceled mid-download: free the asset for a later retry; keep the job's status
|
||||||
|
# (the route set it to paused/canceled). ref_count is adjusted by the route.
|
||||||
|
_reset_asset_pending(asset_id)
|
||||||
|
log.info("job %s aborted (pause/cancel)", job_id)
|
||||||
|
except Exception as exc: # noqa: BLE001 — surface any failure to the user, keep worker alive
|
||||||
|
msg = str(exc)[:500]
|
||||||
|
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("job %s failed: %s", job_id, msg)
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(staging, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_asset_pending(asset_id: int) -> None:
|
||||||
|
with SessionLocal() as db:
|
||||||
|
asset = db.get(MediaAsset, asset_id)
|
||||||
|
if asset and asset.status == "downloading":
|
||||||
|
asset.status = "pending"
|
||||||
|
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:
|
||||||
|
while not _stop.is_set():
|
||||||
|
try:
|
||||||
|
job_id = _claim_job()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
log.exception("claim failed")
|
||||||
|
_stop.wait(settings.download_poll_seconds)
|
||||||
|
continue
|
||||||
|
if job_id is None:
|
||||||
|
_stop.wait(settings.download_poll_seconds)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
_process_job(job_id)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
log.exception("processing job %s crashed", job_id)
|
||||||
|
_set_job(job_id, status="error", error="Worker error", phase=None)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
signal.signal(signal.SIGTERM, _handle_signal)
|
||||||
|
signal.signal(signal.SIGINT, _handle_signal)
|
||||||
|
|
||||||
|
if not settings.worker_enabled:
|
||||||
|
log.info("WORKER_ENABLED is off; worker exiting (nothing to do).")
|
||||||
|
return
|
||||||
|
|
||||||
|
_recover_orphans()
|
||||||
|
n = max(1, settings.download_worker_concurrency)
|
||||||
|
log.info("Download worker started (concurrency=%d, root=%s).", n, settings.download_root)
|
||||||
|
threads = [threading.Thread(target=_worker_loop, args=(i,), daemon=True) for i in range(n)]
|
||||||
|
for t in threads:
|
||||||
|
t.start()
|
||||||
|
while not _stop.is_set():
|
||||||
|
_stop.wait(1.0)
|
||||||
|
log.info("Download worker stopping…")
|
||||||
|
for t in threads:
|
||||||
|
t.join(timeout=5)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -13,3 +13,12 @@ py3langid>=0.3,<1.0
|
||||||
itsdangerous>=2.1,<3.0
|
itsdangerous>=2.1,<3.0
|
||||||
cryptography>=46.0.7,<47
|
cryptography>=46.0.7,<47
|
||||||
argon2-cffi>=23.1,<26
|
argon2-cffi>=23.1,<26
|
||||||
|
# Download center: yt-dlp does the extraction/download; ffmpeg (OS-level in the Dockerfile)
|
||||||
|
# does the merge + postprocessing. The [default] extra pulls yt-dlp-ejs (the challenge-solver
|
||||||
|
# scripts Deno runs for YouTube's n-signature). yt-dlp is release-often (YouTube changes), so
|
||||||
|
# keep the floor loose and bump it when extraction breaks.
|
||||||
|
yt-dlp[default]>=2025.5.22
|
||||||
|
# PO Token provider plugin: talks to the bgutil sidecar (brainicism/bgutil-ytdlp-pot-provider)
|
||||||
|
# to mint YouTube Proof-of-Origin tokens on demand, bypassing "confirm you're not a bot" and
|
||||||
|
# unlocking high-quality formats. Pin to match the sidecar image tag (versions must align).
|
||||||
|
bgutil-ytdlp-pot-provider==1.3.1
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,9 @@ services:
|
||||||
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-siftlode}:${POSTGRES_PASSWORD:-siftlode}@db:5432/${POSTGRES_DB:-siftlode}
|
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-siftlode}:${POSTGRES_PASSWORD:-siftlode}@db:5432/${POSTGRES_DB:-siftlode}
|
||||||
# This instance owns its scheduler now (its own DB, so no double-write concern).
|
# This instance owns its scheduler now (its own DB, so no double-write concern).
|
||||||
SCHEDULER_ENABLED: "true"
|
SCHEDULER_ENABLED: "true"
|
||||||
|
# Download center: the API serves finished files (it does NOT run the worker loop).
|
||||||
|
DOWNLOAD_ROOT: /downloads
|
||||||
|
WORKER_ENABLED: "false"
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
@ -51,6 +54,49 @@ services:
|
||||||
- apparmor:unconfined
|
- apparmor:unconfined
|
||||||
ports:
|
ports:
|
||||||
- "${APP_PORT:-8080}:8000"
|
- "${APP_PORT:-8080}:8000"
|
||||||
|
volumes:
|
||||||
|
# Downloaded media lives on the host so you can inspect the generated Plex tree.
|
||||||
|
# Gitignored. The worker writes here; the API reads it to serve local downloads.
|
||||||
|
- ./downloads:/downloads
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Dedicated download worker: same image, runs the yt-dlp job loop instead of the API.
|
||||||
|
# It shares the DB (job queue) and 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}
|
||||||
|
# The worker must not also run the scheduler; the API owns that.
|
||||||
|
SCHEDULER_ENABLED: "false"
|
||||||
|
DOWNLOAD_ROOT: /downloads
|
||||||
|
WORKER_ENABLED: "true"
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
bgutil-pot:
|
||||||
|
condition: service_started
|
||||||
|
security_opt:
|
||||||
|
- apparmor:unconfined
|
||||||
|
volumes:
|
||||||
|
- ./downloads:/downloads
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# PO-token provider: mints YouTube Proof-of-Origin tokens on demand so the worker bypasses
|
||||||
|
# bot-detection and gets high-quality formats. The worker reaches it at http://bgutil-pot:4416
|
||||||
|
# (DOWNLOAD_POT_BASE_URL). Pin the tag to match the plugin version in requirements.txt.
|
||||||
|
bgutil-pot:
|
||||||
|
image: brainicism/bgutil-ytdlp-pot-provider:1.3.1
|
||||||
|
init: true
|
||||||
|
security_opt:
|
||||||
|
- apparmor:unconfined
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,10 @@ services:
|
||||||
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-siftlode}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-siftlode}
|
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-siftlode}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-siftlode}
|
||||||
# This instance owns the background scheduler (single writer).
|
# This instance owns the background scheduler (single writer).
|
||||||
SCHEDULER_ENABLED: "true"
|
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:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
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
|
# 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.
|
# front for HTTPS / public exposure — and set OAUTH_REDIRECT_URL to the https URL.
|
||||||
- "${HTTP_PORT:-8080}:8000"
|
- "${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:
|
security_opt:
|
||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
cap_drop:
|
cap_drop:
|
||||||
|
|
@ -60,8 +68,48 @@ services:
|
||||||
start_period: 30s
|
start_period: 30s
|
||||||
restart: unless-stopped
|
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:
|
volumes:
|
||||||
siftlode_pgdata:
|
siftlode_pgdata:
|
||||||
|
siftlode_downloads:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
internal:
|
internal:
|
||||||
|
|
|
||||||
|
|
@ -25,11 +25,19 @@ services:
|
||||||
env_file: .env
|
env_file: .env
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-siftlode}:${POSTGRES_PASSWORD:-siftlode}@db:5432/${POSTGRES_DB:-siftlode}
|
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:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
ports:
|
ports:
|
||||||
- "${APP_PORT:-8080}:8000"
|
- "${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:
|
healthcheck:
|
||||||
test: ["CMD", "python", "-c", "import urllib.request; exit(0 if urllib.request.urlopen('http://localhost:8000/healthz').status == 200 else 1)"]
|
test: ["CMD", "python", "-c", "import urllib.request; exit(0 if urllib.request.urlopen('http://localhost:8000/healthz').status == 200 else 1)"]
|
||||||
interval: 15s
|
interval: 15s
|
||||||
|
|
@ -38,5 +46,39 @@ services:
|
||||||
start_period: 25s
|
start_period: 25s
|
||||||
restart: unless-stopped
|
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:
|
volumes:
|
||||||
pgdata:
|
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,
|
- A machine with **Docker** and the **Docker Compose plugin** (Docker Desktop on Windows/macOS,
|
||||||
or Docker Engine on Linux).
|
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).
|
- Optional: a domain name + reverse proxy if you want HTTPS / public access (see below).
|
||||||
|
|
||||||
## 1. Get the files
|
## 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
|
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`.
|
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
|
## Day-to-day
|
||||||
|
|
||||||
```bash
|
```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 pull
|
||||||
docker compose -f docker-compose.selfhost.yml up -d
|
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 api
|
||||||
|
docker compose -f docker-compose.selfhost.yml logs -f worker
|
||||||
docker compose -f docker-compose.selfhost.yml ps
|
docker compose -f docker-compose.selfhost.yml ps
|
||||||
|
|
||||||
# Stop
|
# Stop
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<title>Siftlode</title>
|
<title>Siftlode</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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,
|
type ThemePrefs,
|
||||||
} from "./lib/theme";
|
} from "./lib/theme";
|
||||||
import { hasFilterParams, isPage, paramsToFilters, readPage, stripUrlParams, type Page } from "./lib/urlState";
|
import { hasFilterParams, isPage, paramsToFilters, readPage, stripUrlParams, type Page } from "./lib/urlState";
|
||||||
|
import { pageTitleKey } from "./lib/pageMeta";
|
||||||
import {
|
import {
|
||||||
loadLayout,
|
loadLayout,
|
||||||
normalizeLayout,
|
normalizeLayout,
|
||||||
|
|
@ -57,6 +58,8 @@ import AdminUsers, { focusAccessRequestsTab } from "./components/AdminUsers";
|
||||||
import SettingsPanel from "./components/SettingsPanel";
|
import SettingsPanel from "./components/SettingsPanel";
|
||||||
import NotificationsPanel from "./components/NotificationsPanel";
|
import NotificationsPanel from "./components/NotificationsPanel";
|
||||||
import Messages from "./components/Messages";
|
import Messages from "./components/Messages";
|
||||||
|
import DownloadCenter from "./components/DownloadCenter";
|
||||||
|
import { setNavigator } from "./lib/nav";
|
||||||
import ChatDock from "./components/ChatDock";
|
import ChatDock from "./components/ChatDock";
|
||||||
import OnboardingWizard from "./components/OnboardingWizard";
|
import OnboardingWizard from "./components/OnboardingWizard";
|
||||||
import { shouldAutoOpenOnboarding } from "./lib/onboarding";
|
import { shouldAutoOpenOnboarding } from "./lib/onboarding";
|
||||||
|
|
@ -279,6 +282,20 @@ export default function App() {
|
||||||
}
|
}
|
||||||
go();
|
go();
|
||||||
}
|
}
|
||||||
|
// 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) {
|
function setSidebarLayout(next: SidebarLayout) {
|
||||||
setSidebarLayoutState(next);
|
setSidebarLayoutState(next);
|
||||||
|
|
@ -728,6 +745,8 @@ export default function App() {
|
||||||
<div className="p-4 max-w-3xl w-full mx-auto">
|
<div className="p-4 max-w-3xl w-full mx-auto">
|
||||||
<Messages meId={meQuery.data!.id} />
|
<Messages meId={meQuery.data!.id} />
|
||||||
</div>
|
</div>
|
||||||
|
) : page === "downloads" && !meQuery.data!.is_demo ? (
|
||||||
|
<DownloadCenter me={meQuery.data!} />
|
||||||
) : page === "settings" ? (
|
) : page === "settings" ? (
|
||||||
<SettingsPanel
|
<SettingsPanel
|
||||||
me={meQuery.data!}
|
me={meQuery.data!}
|
||||||
|
|
|
||||||
66
frontend/src/components/DownloadButton.tsx
Normal file
66
frontend/src/components/DownloadButton.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Download } from "lucide-react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import DownloadDialog from "./DownloadDialog";
|
||||||
|
import { api, type Me } from "../lib/api";
|
||||||
|
|
||||||
|
// Self-contained download affordance for a video card / the player. Hidden for the demo account
|
||||||
|
// (downloads spend server disk + need a real identity). Reflects the video's current state from
|
||||||
|
// the shared per-user download index (one polled query shared across every card).
|
||||||
|
export default function DownloadButton({
|
||||||
|
videoId,
|
||||||
|
title,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
videoId: string;
|
||||||
|
title?: string | null;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const me = qc.getQueryData<Me>(["me"]);
|
||||||
|
const isDemo = !!me?.is_demo;
|
||||||
|
|
||||||
|
const indexQ = useQuery({
|
||||||
|
queryKey: ["download-index"],
|
||||||
|
queryFn: api.downloadIndex,
|
||||||
|
enabled: !isDemo,
|
||||||
|
staleTime: 10000,
|
||||||
|
});
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
if (isDemo) return null;
|
||||||
|
|
||||||
|
const status = indexQ.data?.[videoId];
|
||||||
|
const downloaded = status === "done";
|
||||||
|
const inQueue = status === "queued" || status === "running" || status === "paused";
|
||||||
|
const label = downloaded
|
||||||
|
? t("downloads.button.downloaded")
|
||||||
|
: inQueue
|
||||||
|
? t("downloads.button.queued")
|
||||||
|
: t("downloads.button.label");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
title={label}
|
||||||
|
className={clsx(
|
||||||
|
className ?? "p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg",
|
||||||
|
(downloaded || inQueue) && "text-accent"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Download className={clsx("w-4 h-4", inQueue && "animate-pulse")} />
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<DownloadDialog source={videoId} title={title} onClose={() => setOpen(false)} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
600
frontend/src/components/DownloadCenter.tsx
Normal file
600
frontend/src/components/DownloadCenter.tsx
Normal file
|
|
@ -0,0 +1,600 @@
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
Ban,
|
||||||
|
Download,
|
||||||
|
Pause,
|
||||||
|
Play,
|
||||||
|
Plus,
|
||||||
|
Scissors,
|
||||||
|
Settings2,
|
||||||
|
Share2,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
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";
|
||||||
|
import { notify } from "../lib/notifications";
|
||||||
|
import { formatBytes, formatDuration, formatEta, formatSpeed } from "../lib/format";
|
||||||
|
|
||||||
|
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 GiB = 1024 ** 3;
|
||||||
|
|
||||||
|
const STATUS_CLS: Record<string, string> = {
|
||||||
|
queued: "text-muted",
|
||||||
|
running: "text-accent",
|
||||||
|
paused: "text-amber-400",
|
||||||
|
done: "text-emerald-400",
|
||||||
|
error: "text-red-400",
|
||||||
|
canceled: "text-muted",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Byte-progress phases (show a % bar) vs ffmpeg post-steps (no %, indeterminate pulse).
|
||||||
|
// "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", "editing", "merging", "audio_extract", "thumbnail", "sponsorblock", "metadata", "processing",
|
||||||
|
];
|
||||||
|
|
||||||
|
function StatusPill({ job }: { job: DownloadJob }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
// While downloading, show the concrete phase (video / audio / merging) so the user isn't
|
||||||
|
// confused by the bar resetting between the separate video and audio streams.
|
||||||
|
if (job.status === "running" && job.phase && PHASE_KEYS.includes(job.phase)) {
|
||||||
|
return <span className="text-xs font-medium text-accent">{t(`downloads.phase.${job.phase}`)}</span>;
|
||||||
|
}
|
||||||
|
const key = job.expired ? "expired" : job.status;
|
||||||
|
return (
|
||||||
|
<span className={clsx("text-xs font-medium", STATUS_CLS[job.status] ?? "text-muted")}>
|
||||||
|
{t(`downloads.status.${key}`)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Thumb({ job }: { job: DownloadJob }) {
|
||||||
|
const url = job.asset?.thumbnail_url;
|
||||||
|
return (
|
||||||
|
<div className="w-28 aspect-video shrink-0 rounded-lg overflow-hidden bg-surface border border-border">
|
||||||
|
{url ? <img src={url} alt="" loading="lazy" className="w-full h-full object-cover" /> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function jobTitle(job: DownloadJob): string {
|
||||||
|
return job.display_name || job.asset?.title || job.source_ref;
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconBtn({
|
||||||
|
onClick,
|
||||||
|
title,
|
||||||
|
danger,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
onClick: () => void;
|
||||||
|
title: string;
|
||||||
|
danger?: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
title={title}
|
||||||
|
className={clsx(
|
||||||
|
"p-1.5 rounded-md hover:bg-surface text-muted transition",
|
||||||
|
danger ? "hover:text-red-400" : "hover:text-fg"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DownloadRow({
|
||||||
|
job,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
job: DownloadJob;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const running = job.status === "running";
|
||||||
|
return (
|
||||||
|
<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 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>
|
||||||
|
<div className="flex items-center gap-2 mt-1 text-xs">
|
||||||
|
<StatusPill job={job} />
|
||||||
|
{job.asset?.size_bytes ? <span className="text-muted">· {formatBytes(job.asset.size_bytes)}</span> : null}
|
||||||
|
{job.asset?.duration_s ? <span className="text-muted">· {formatDuration(job.asset.duration_s)}</span> : null}
|
||||||
|
{job.error ? <span className="text-red-400 truncate">· {job.error}</span> : null}
|
||||||
|
</div>
|
||||||
|
{running && (
|
||||||
|
<div className="mt-1.5">
|
||||||
|
{job.phase && !DOWNLOAD_PHASES.includes(job.phase) ? (
|
||||||
|
// ffmpeg post-steps aren't byte-progress — show an indeterminate pulse, no %.
|
||||||
|
<div className="h-1.5 rounded-full bg-surface overflow-hidden">
|
||||||
|
<div className="h-full w-full bg-accent/60 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="h-1.5 rounded-full bg-surface overflow-hidden">
|
||||||
|
<div className="h-full bg-accent transition-all" style={{ width: `${job.progress}%` }} />
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-muted mt-0.5 flex gap-2">
|
||||||
|
<span>{job.progress}%</span>
|
||||||
|
{job.speed_bps ? <span>{formatSpeed(job.speed_bps)}</span> : null}
|
||||||
|
{job.eta_s ? <span>{formatEta(job.eta_s)}</span> : null}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-0.5 shrink-0">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Rename + Share small modals ------------------------------------------------------------
|
||||||
|
|
||||||
|
function RenameModal({ job, onClose }: { job: DownloadJob; onClose: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [name, setName] = useState(jobTitle(job));
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: () => api.renameDownload(job.id, name.trim()),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["downloads"] });
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<Modal title={t("downloads.rename.title")} onClose={onClose}>
|
||||||
|
<label className="block text-sm font-medium mb-1">{t("downloads.rename.label")}</label>
|
||||||
|
<input value={name} onChange={(e) => setName(e.target.value)} className={inputCls} autoFocus />
|
||||||
|
<p className="text-xs text-muted mt-2 mb-4">{t("downloads.rename.hint")}</p>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={() => save.mutate()}
|
||||||
|
disabled={!name.trim() || save.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.rename.save")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Usage bar ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function UsageBar() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
// 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);
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl bg-card/40 p-3 mb-4">
|
||||||
|
<div className="flex justify-between text-sm mb-1.5">
|
||||||
|
<span className="font-medium">{t("downloads.usage.title")}</span>
|
||||||
|
<span className="text-muted">
|
||||||
|
{formatBytes(u.footprint_bytes)}
|
||||||
|
{u.unlimited ? "" : ` / ${formatBytes(u.max_bytes)}`} ·{" "}
|
||||||
|
{u.unlimited ? t("downloads.usage.unlimited") : t("downloads.usage.items", { used: u.active_jobs, max: u.max_jobs })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{!u.unlimited && (
|
||||||
|
<div className="h-2 rounded-full bg-surface overflow-hidden">
|
||||||
|
<div className={clsx("h-full transition-all", pct > 90 ? "bg-red-400" : "bg-accent")} style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Admin system tab -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
function QuotaModal({ userId, email, onClose }: { userId: number; email: string; onClose: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const q = useQuery({ queryKey: ["dl-quota", userId], queryFn: () => api.adminGetDownloadQuota(userId) });
|
||||||
|
const [form, setForm] = useState<{ gb: number; jobs: number; conc: number; unlimited: boolean } | null>(null);
|
||||||
|
const cur = q.data;
|
||||||
|
const state = form ?? (cur ? { gb: Math.round((cur.max_bytes / GiB) * 10) / 10, jobs: cur.max_jobs, conc: cur.max_concurrent, unlimited: cur.unlimited } : null);
|
||||||
|
const done = () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["dl-quota", userId] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["admin-storage"] });
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
api.adminSetDownloadQuota(userId, {
|
||||||
|
max_bytes: Math.round((state!.gb) * GiB),
|
||||||
|
max_jobs: state!.jobs,
|
||||||
|
max_concurrent: state!.conc,
|
||||||
|
unlimited: state!.unlimited,
|
||||||
|
}),
|
||||||
|
onSuccess: done,
|
||||||
|
});
|
||||||
|
const reset = useMutation({ mutationFn: () => api.adminResetDownloadQuota(userId), onSuccess: done });
|
||||||
|
if (!state) return null;
|
||||||
|
const upd = (p: Partial<typeof state>) => setForm({ ...state, ...p });
|
||||||
|
return (
|
||||||
|
<Modal title={t("downloads.admin.quotaTitle", { email })} onClose={onClose}>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<label className="text-sm">
|
||||||
|
{t("downloads.admin.maxBytes")}
|
||||||
|
<input type="number" value={state.gb} onChange={(e) => upd({ gb: Number(e.target.value) })} className={inputCls} disabled={state.unlimited} />
|
||||||
|
</label>
|
||||||
|
<label className="text-sm">
|
||||||
|
{t("downloads.admin.maxJobs")}
|
||||||
|
<input type="number" value={state.jobs} onChange={(e) => upd({ jobs: Number(e.target.value) })} className={inputCls} />
|
||||||
|
</label>
|
||||||
|
<label className="text-sm">
|
||||||
|
{t("downloads.admin.maxConcurrent")}
|
||||||
|
<input type="number" value={state.conc} onChange={(e) => upd({ conc: Number(e.target.value) })} className={inputCls} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<input type="checkbox" checked={state.unlimited} onChange={(e) => upd({ unlimited: e.target.checked })} />
|
||||||
|
{t("downloads.admin.unlimited")}
|
||||||
|
</label>
|
||||||
|
<div className="flex justify-between pt-1">
|
||||||
|
<button onClick={() => reset.mutate()} className="px-3 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-surface transition">
|
||||||
|
{t("downloads.admin.reset")}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => save.mutate()} disabled={save.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.admin.save")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 });
|
||||||
|
const jobs = useLiveQuery(["admin-downloads"], api.adminDownloads, { intervalMs: 5000 });
|
||||||
|
const [quotaFor, setQuotaFor] = useState<{ id: number; email: string } | null>(null);
|
||||||
|
const s = storage.data;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{s && (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-5">
|
||||||
|
{[
|
||||||
|
[t("downloads.admin.readyFiles"), String(s.ready_files)],
|
||||||
|
[t("downloads.admin.totalSize"), formatBytes(s.total_bytes)],
|
||||||
|
[t("downloads.admin.cap"), s.total_cap_bytes ? formatBytes(s.total_cap_bytes) : t("downloads.admin.noCap")],
|
||||||
|
].map(([label, val]) => (
|
||||||
|
<div key={label} className="rounded-xl bg-card/40 p-3">
|
||||||
|
<div className="text-xs text-muted">{label}</div>
|
||||||
|
<div className="text-lg font-semibold mt-0.5">{val}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{s && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<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">
|
||||||
|
<span className="flex-1 truncate">{u.email}</span>
|
||||||
|
<span className="text-muted">{formatBytes(u.footprint_bytes)}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setQuotaFor({ id: u.user_id, email: u.email ?? "" })}
|
||||||
|
className="p-1 rounded hover:bg-surface text-muted hover:text-fg"
|
||||||
|
title={t("downloads.admin.editQuota")}
|
||||||
|
>
|
||||||
|
<Settings2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{s.per_user.length === 0 && (
|
||||||
|
<div className="text-xs text-muted px-3 py-2">{t("downloads.admin.noFootprint")}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="text-sm font-medium mb-2">{t("downloads.tabs.system")}</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{(jobs.data ?? []).map((j) => (
|
||||||
|
<div key={j.id} className="flex items-center gap-3 text-sm px-3 py-1.5 rounded-lg bg-card/40">
|
||||||
|
<span className="flex-1 truncate">{jobTitle(j)}</span>
|
||||||
|
<span className="text-muted truncate w-40">{j.user_email}</span>
|
||||||
|
<StatusPill job={j} />
|
||||||
|
<span className="text-muted w-16 text-right">{j.asset?.size_bytes ? formatBytes(j.asset.size_bytes) : ""}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{jobs.data && jobs.data.length === 0 && <div className="text-sm text-muted py-6 text-center">{t("downloads.empty.admin")}</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{quotaFor && <QuotaModal userId={quotaFor.id} email={quotaFor.email} onClose={() => setQuotaFor(null)} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Main -----------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export default function DownloadCenter({ me }: { me: Me }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
const [tab, setTab] = usePersistedTab("siftlode.downloadTab", "queue");
|
||||||
|
const [addUrl, setAddUrl] = useState("");
|
||||||
|
const [addSource, setAddSource] = useState<string | null>(null);
|
||||||
|
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" });
|
||||||
|
const jobs = jobsQ.data ?? [];
|
||||||
|
const queue = jobs.filter((j) => ["queued", "running", "paused", "error"].includes(j.status));
|
||||||
|
const library = jobs.filter((j) => j.status === "done");
|
||||||
|
|
||||||
|
const invalidate = () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["downloads"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["download-index"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["download-usage"] });
|
||||||
|
};
|
||||||
|
const act = useMutation({
|
||||||
|
mutationFn: ({ id, op }: { id: number; op: "pause" | "resume" | "cancel" | "delete" }) =>
|
||||||
|
op === "pause" ? api.pauseDownload(id)
|
||||||
|
: op === "resume" ? api.resumeDownload(id)
|
||||||
|
: op === "cancel" ? api.cancelDownload(id)
|
||||||
|
: api.deleteDownload(id),
|
||||||
|
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();
|
||||||
|
};
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ id: "queue", label: t("downloads.tabs.queue"), badge: queue.length },
|
||||||
|
{ id: "done", label: t("downloads.tabs.done"), badge: library.length },
|
||||||
|
{ id: "shared", label: t("downloads.tabs.shared") },
|
||||||
|
...(me.role === "admin" ? [{ id: "system", label: t("downloads.tabs.system") }] : []),
|
||||||
|
];
|
||||||
|
|
||||||
|
const saveBtn = (job: DownloadJob) => (
|
||||||
|
<a
|
||||||
|
href={api.downloadFileUrl(job.id)}
|
||||||
|
title={t("downloads.actions.download")}
|
||||||
|
className="p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg transition"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 max-w-4xl w-full mx-auto">
|
||||||
|
<div className="flex items-center justify-between gap-3 mb-1">
|
||||||
|
<h1 className="text-xl font-semibold">{t("downloads.page.title")}</h1>
|
||||||
|
<button
|
||||||
|
onClick={() => setManage(true)}
|
||||||
|
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-surface transition"
|
||||||
|
>
|
||||||
|
<Settings2 className="w-4 h-4" /> {t("downloads.page.manage")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted mb-4">{t("downloads.page.subtitle")}</p>
|
||||||
|
|
||||||
|
<Tabs tabs={tabs} active={tab} onChange={setTab} />
|
||||||
|
|
||||||
|
{tab === "queue" && (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-2 mb-4">
|
||||||
|
<input
|
||||||
|
value={addUrl}
|
||||||
|
onChange={(e) => setAddUrl(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && addUrl.trim() && setAddSource(addUrl.trim())}
|
||||||
|
placeholder={t("downloads.page.addUrl")}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => addUrl.trim() && setAddSource(addUrl.trim())}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium bg-accent text-accent-fg hover:opacity-90 transition shrink-0"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" /> {t("downloads.page.add")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{queue.map((job) => (
|
||||||
|
<DownloadRow key={job.id} job={job}>
|
||||||
|
{(job.status === "queued" || job.status === "running") && (
|
||||||
|
<IconBtn onClick={() => act.mutate({ id: job.id, op: "pause" })} title={t("downloads.actions.pause")}>
|
||||||
|
<Pause className="w-4 h-4" />
|
||||||
|
</IconBtn>
|
||||||
|
)}
|
||||||
|
{(job.status === "paused" || job.status === "error") && (
|
||||||
|
<IconBtn onClick={() => act.mutate({ id: job.id, op: "resume" })} title={t(job.status === "error" ? "downloads.actions.retry" : "downloads.actions.resume")}>
|
||||||
|
<Play className="w-4 h-4" />
|
||||||
|
</IconBtn>
|
||||||
|
)}
|
||||||
|
<IconBtn
|
||||||
|
onClick={() => confirmThen(t("downloads.confirm.cancelTitle"), t("downloads.confirm.cancelBody"), () => act.mutate({ id: job.id, op: "cancel" }))}
|
||||||
|
title={t("downloads.actions.cancel")}
|
||||||
|
danger
|
||||||
|
>
|
||||||
|
<Ban className="w-4 h-4" />
|
||||||
|
</IconBtn>
|
||||||
|
</DownloadRow>
|
||||||
|
))}
|
||||||
|
{queue.length === 0 && <div className="text-sm text-muted py-10 text-center">{t("downloads.empty.queue")}</div>}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "done" && (
|
||||||
|
<>
|
||||||
|
<UsageBar />
|
||||||
|
<div className="space-y-2">
|
||||||
|
{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>
|
||||||
|
<IconBtn onClick={() => setShareJob(job)} title={t("downloads.actions.share")}>
|
||||||
|
<Share2 className="w-4 h-4" />
|
||||||
|
</IconBtn>
|
||||||
|
<IconBtn
|
||||||
|
onClick={() => confirmThen(t("downloads.confirm.deleteTitle"), t("downloads.confirm.deleteBody"), () => act.mutate({ id: job.id, op: "delete" }))}
|
||||||
|
title={t("downloads.actions.delete")}
|
||||||
|
danger
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</IconBtn>
|
||||||
|
</DownloadRow>
|
||||||
|
))}
|
||||||
|
{library.length === 0 && <div className="text-sm text-muted py-10 text-center">{t("downloads.empty.done")}</div>}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "shared" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(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 && (
|
||||||
|
<div className="text-sm text-muted py-10 text-center">{t("downloads.empty.shared")}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "system" && me.role === "admin" && <AdminSystem />}
|
||||||
|
|
||||||
|
{addSource && (
|
||||||
|
<DownloadDialog
|
||||||
|
source={addSource}
|
||||||
|
onClose={() => {
|
||||||
|
setAddSource(null);
|
||||||
|
setAddUrl("");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{manage && <ProfileEditor onClose={() => setManage(false)} />}
|
||||||
|
{renameJob && <RenameModal job={renameJob} onClose={() => setRenameJob(null)} />}
|
||||||
|
{shareJob && <ShareDialog job={shareJob} onClose={() => setShareJob(null)} />}
|
||||||
|
{editJob && <VideoEditor job={editJob} onClose={() => setEditJob(null)} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
frontend/src/components/DownloadDialog.tsx
Normal file
117
frontend/src/components/DownloadDialog.tsx
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import Modal from "./Modal";
|
||||||
|
import ProfileEditor from "./ProfileEditor";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import { notify } from "../lib/notifications";
|
||||||
|
import { navigateTo } from "../lib/nav";
|
||||||
|
|
||||||
|
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";
|
||||||
|
|
||||||
|
// Preset picker shown when the user hits Download on a card / in the player, or adds a URL from
|
||||||
|
// the Download Center. Enqueues via the API and points the user at the Download Center.
|
||||||
|
export default function DownloadDialog({
|
||||||
|
source,
|
||||||
|
title,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
source: string;
|
||||||
|
title?: string | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const profilesQ = useQuery({ queryKey: ["download-profiles"], queryFn: api.downloadProfiles });
|
||||||
|
const [profileId, setProfileId] = useState<number | null>(null);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [manage, setManage] = useState(false);
|
||||||
|
|
||||||
|
const profiles = profilesQ.data ?? [];
|
||||||
|
const chosen = profileId ?? profiles[0]?.id ?? null;
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!chosen || busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await api.enqueueDownload({
|
||||||
|
source,
|
||||||
|
profile_id: chosen,
|
||||||
|
display_name: name.trim() || undefined,
|
||||||
|
});
|
||||||
|
qc.invalidateQueries({ queryKey: ["downloads"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["download-index"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["download-usage"] });
|
||||||
|
notify({
|
||||||
|
level: "success",
|
||||||
|
message: t("downloads.dialog.added"),
|
||||||
|
action: { label: t("downloads.dialog.view"), onClick: () => navigateTo("downloads") },
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
} catch {
|
||||||
|
// Non-quiet request: the global error dialog already surfaced the reason (e.g. quota).
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title={t("downloads.dialog.title")} onClose={onClose}>
|
||||||
|
{title && <p className="text-sm text-muted mb-4 line-clamp-2">{title}</p>}
|
||||||
|
|
||||||
|
<label className="block text-sm font-medium mb-1">{t("downloads.dialog.profile")}</label>
|
||||||
|
<select
|
||||||
|
value={chosen ?? ""}
|
||||||
|
onChange={(e) => setProfileId(Number(e.target.value))}
|
||||||
|
className={inputCls}
|
||||||
|
>
|
||||||
|
{profiles.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setManage(true)}
|
||||||
|
className="text-xs text-accent hover:underline mt-1 mb-4"
|
||||||
|
>
|
||||||
|
{t("downloads.dialog.manage")}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<label className="block text-sm font-medium mb-1">{t("downloads.dialog.nameLabel")}</label>
|
||||||
|
<input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder={t("downloads.dialog.namePlaceholder")}
|
||||||
|
className={`${inputCls} mb-5`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-3 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-surface transition"
|
||||||
|
>
|
||||||
|
{t("downloads.actions.cancel")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={submit}
|
||||||
|
disabled={busy || !chosen}
|
||||||
|
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.dialog.submit")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{manage && (
|
||||||
|
<ProfileEditor
|
||||||
|
onClose={() => {
|
||||||
|
setManage(false);
|
||||||
|
profilesQ.refetch();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,8 @@ import { useTranslation } from "react-i18next";
|
||||||
import { Search, X, Youtube } from "lucide-react";
|
import { Search, X, Youtube } from "lucide-react";
|
||||||
import type { FeedFilters, Me } from "../lib/api";
|
import type { FeedFilters, Me } from "../lib/api";
|
||||||
import type { Page } from "../lib/urlState";
|
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
|
// 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.
|
// status live in the left NavSidebar; the feed's scope toggle now lives in the filter sidebar.
|
||||||
|
|
@ -72,24 +74,8 @@ export default function Header({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 text-center text-sm font-semibold">
|
<div className="flex-1 flex justify-center">
|
||||||
{page === "stats"
|
<PageTitle label={t(pageTitleKey(page))} />
|
||||||
? 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")
|
|
||||||
: t("header.channelManager")}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
Bell,
|
Bell,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
Download,
|
||||||
Home,
|
Home,
|
||||||
Info,
|
Info,
|
||||||
ListVideo,
|
ListVideo,
|
||||||
|
|
@ -147,6 +148,13 @@ export default function NavSidebar({
|
||||||
{ intervalMs: 30000, enabled: !me.is_demo }
|
{ intervalMs: 30000, enabled: !me.is_demo }
|
||||||
);
|
);
|
||||||
const msgUnread = msgUnreadQuery.data?.count ?? 0;
|
const msgUnread = msgUnreadQuery.data?.count ?? 0;
|
||||||
|
// Download center badge = items still working (queued/running/paused). Reuses the same
|
||||||
|
// lightweight per-user index the feed cards poll, so no extra request shape.
|
||||||
|
const dlIndexQuery = useLiveQuery(["download-index"], api.downloadIndex, {
|
||||||
|
intervalMs: 30000,
|
||||||
|
enabled: !me.is_demo,
|
||||||
|
});
|
||||||
|
const dlActive = Object.values(dlIndexQuery.data ?? {}).filter((s) => s !== "done").length;
|
||||||
|
|
||||||
type NavItem = { page: Page; icon: typeof Home; label: string; badge?: number };
|
type NavItem = { page: Page; icon: typeof Home; label: string; badge?: number };
|
||||||
// User-facing content modules vs. admin/system modules, separated by a divider in the rail.
|
// User-facing content modules vs. admin/system modules, separated by a divider in the rail.
|
||||||
|
|
@ -159,6 +167,11 @@ export default function NavSidebar({
|
||||||
...(me.is_demo
|
...(me.is_demo
|
||||||
? []
|
? []
|
||||||
: [{ page: "messages" as Page, icon: MessageSquare, label: t("messages.navLabel"), badge: msgUnread }]),
|
: [{ page: "messages" as Page, icon: MessageSquare, label: t("messages.navLabel"), badge: msgUnread }]),
|
||||||
|
// Download center — YouTube → server → your device. Hidden for the shared demo account
|
||||||
|
// (spends server disk + needs a real identity).
|
||||||
|
...(me.is_demo
|
||||||
|
? []
|
||||||
|
: [{ page: "downloads" as Page, icon: Download, label: t("downloads.navLabel"), badge: dlActive }]),
|
||||||
// Per-user sync status + your own API usage (admins get an extra system-wide tab inside).
|
// Per-user sync status + your own API usage (admins get an extra system-wide tab inside).
|
||||||
{ page: "stats", icon: BarChart3, label: t("header.account.stats") },
|
{ page: "stats", icon: BarChart3, label: t("header.account.stats") },
|
||||||
];
|
];
|
||||||
|
|
@ -243,8 +256,9 @@ export default function NavSidebar({
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setPage("feed")}
|
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")}
|
title={t("header.feed")}
|
||||||
|
aria-label={t("header.feed")}
|
||||||
>
|
>
|
||||||
Sift<span className="text-accent">lode</span>
|
Sift<span className="text-accent">lode</span>
|
||||||
</button>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { AlertTriangle, ArrowLeft, Check, CheckCheck, ExternalLink, SkipBack, SkipForward, Volume2, VolumeX, X } from "lucide-react";
|
import { AlertTriangle, ArrowLeft, Check, CheckCheck, ExternalLink, SkipBack, SkipForward, Volume2, VolumeX, X } from "lucide-react";
|
||||||
import Avatar from "./Avatar";
|
import Avatar from "./Avatar";
|
||||||
import AddToPlaylist from "./AddToPlaylist";
|
import AddToPlaylist from "./AddToPlaylist";
|
||||||
|
import DownloadButton from "./DownloadButton";
|
||||||
import { api, type Video } from "../lib/api";
|
import { api, type Video } from "../lib/api";
|
||||||
import { channelYouTubeUrl, formatDuration, formatViews, relativeTime } from "../lib/format";
|
import { channelYouTubeUrl, formatDuration, formatViews, relativeTime } from "../lib/format";
|
||||||
import { renderDescription } from "../lib/descriptionLinks";
|
import { renderDescription } from "../lib/descriptionLinks";
|
||||||
|
|
@ -630,6 +631,13 @@ export default function PlayerModal({
|
||||||
className="ml-auto shrink-0 inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-fg hover:bg-surface border border-border transition"
|
className="ml-auto shrink-0 inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-fg hover:bg-surface border border-border transition"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{!navigated && (
|
||||||
|
<DownloadButton
|
||||||
|
videoId={active.id}
|
||||||
|
title={active.title}
|
||||||
|
className="shrink-0 inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-fg hover:bg-surface border border-border transition"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{!navigated && (
|
{!navigated && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setWatched(!watched)}
|
onClick={() => setWatched(!watched)}
|
||||||
|
|
|
||||||
215
frontend/src/components/ProfileEditor.tsx
Normal file
215
frontend/src/components/ProfileEditor.tsx
Normal file
|
|
@ -0,0 +1,215 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Lock, Pencil, Plus, Trash2 } from "lucide-react";
|
||||||
|
import Modal from "./Modal";
|
||||||
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
|
import { api, type DownloadProfile, type DownloadSpec } 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 HEIGHTS = [null, 2160, 1440, 1080, 720, 480, 360];
|
||||||
|
|
||||||
|
const BLANK: DownloadSpec = {
|
||||||
|
mode: "av",
|
||||||
|
max_height: 1080,
|
||||||
|
container: "mp4",
|
||||||
|
audio_format: "m4a",
|
||||||
|
vcodec: null,
|
||||||
|
embed_subs: false,
|
||||||
|
embed_chapters: true,
|
||||||
|
embed_thumbnail: true,
|
||||||
|
sponsorblock: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function Check({
|
||||||
|
label,
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (v: boolean) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer select-none">
|
||||||
|
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProfileEditor({ onClose }: { onClose: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
const profilesQ = useQuery({ queryKey: ["download-profiles"], queryFn: api.downloadProfiles });
|
||||||
|
const profiles = profilesQ.data ?? [];
|
||||||
|
const builtins = profiles.filter((p) => p.is_builtin);
|
||||||
|
const mine = profiles.filter((p) => !p.is_builtin);
|
||||||
|
|
||||||
|
const [editing, setEditing] = useState<DownloadProfile | null>(null);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [spec, setSpec] = useState<DownloadSpec>(BLANK);
|
||||||
|
const [formOpen, setFormOpen] = useState(false);
|
||||||
|
|
||||||
|
const invalidate = () => qc.invalidateQueries({ queryKey: ["download-profiles"] });
|
||||||
|
const startNew = () => {
|
||||||
|
setEditing(null);
|
||||||
|
setName("");
|
||||||
|
setSpec(BLANK);
|
||||||
|
setFormOpen(true);
|
||||||
|
};
|
||||||
|
const startEdit = (p: DownloadProfile) => {
|
||||||
|
setEditing(p);
|
||||||
|
setName(p.name);
|
||||||
|
setSpec({ ...BLANK, ...p.spec });
|
||||||
|
setFormOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
editing
|
||||||
|
? api.updateDownloadProfile(editing.id, { name, spec })
|
||||||
|
: api.createDownloadProfile({ name, spec }),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidate();
|
||||||
|
setFormOpen(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const del = useMutation({
|
||||||
|
mutationFn: (id: number) => api.deleteDownloadProfile(id),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onDelete = async (p: DownloadProfile) => {
|
||||||
|
if (await confirm({ title: t("downloads.profiles.delete"), message: t("downloads.profiles.deleteConfirm", { name: p.name }), confirmLabel: t("downloads.profiles.delete"), danger: true })) {
|
||||||
|
del.mutate(p.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const patch = (p: Partial<DownloadSpec>) => setSpec((s) => ({ ...s, ...p }));
|
||||||
|
const set = (k: keyof DownloadSpec) => (e: React.ChangeEvent<HTMLSelectElement>) =>
|
||||||
|
patch({ [k]: e.target.value === "" ? null : e.target.value } as Partial<DownloadSpec>);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title={t("downloads.profiles.title")} onClose={onClose} maxWidth="max-w-xl">
|
||||||
|
{/* Existing formats */}
|
||||||
|
<div className="space-y-1.5 mb-4">
|
||||||
|
<div className="text-xs uppercase tracking-wide text-muted">{t("downloads.profiles.builtin")}</div>
|
||||||
|
{builtins.map((p) => (
|
||||||
|
<div key={p.id} className="flex items-center gap-2 text-sm px-3 py-1.5 rounded-lg bg-surface/60">
|
||||||
|
<Lock className="w-3.5 h-3.5 text-muted shrink-0" />
|
||||||
|
<span className="flex-1 truncate">{p.name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{mine.length > 0 && (
|
||||||
|
<div className="text-xs uppercase tracking-wide text-muted pt-2">{t("downloads.profiles.yours")}</div>
|
||||||
|
)}
|
||||||
|
{mine.map((p) => (
|
||||||
|
<div key={p.id} className="flex items-center gap-2 text-sm px-3 py-1.5 rounded-lg bg-surface/60">
|
||||||
|
<span className="flex-1 truncate">{p.name}</span>
|
||||||
|
<button onClick={() => startEdit(p)} title={t("downloads.actions.rename")} className="p-1 rounded hover:bg-surface text-muted hover:text-fg">
|
||||||
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => onDelete(p)} title={t("downloads.profiles.delete")} className="p-1 rounded hover:bg-surface text-muted hover:text-red-400">
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!formOpen ? (
|
||||||
|
<button
|
||||||
|
onClick={startNew}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium bg-accent text-accent-fg hover:opacity-90 transition"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" /> {t("downloads.profiles.new")}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-xl border border-border p-4 space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">{t("downloads.profiles.name")}</label>
|
||||||
|
<input value={name} onChange={(e) => setName(e.target.value)} placeholder={t("downloads.profiles.namePlaceholder")} className={inputCls} />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">{t("downloads.profiles.mode")}</label>
|
||||||
|
<select value={spec.mode} onChange={set("mode")} className={inputCls}>
|
||||||
|
<option value="av">{t("downloads.profiles.modeAv")}</option>
|
||||||
|
<option value="v">{t("downloads.profiles.modeV")}</option>
|
||||||
|
<option value="a">{t("downloads.profiles.modeA")}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{spec.mode !== "a" ? (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">{t("downloads.profiles.quality")}</label>
|
||||||
|
<select
|
||||||
|
value={spec.max_height ?? ""}
|
||||||
|
onChange={(e) => patch({ max_height: e.target.value === "" ? null : Number(e.target.value) })}
|
||||||
|
className={inputCls}
|
||||||
|
>
|
||||||
|
{HEIGHTS.map((h) => (
|
||||||
|
<option key={h ?? "best"} value={h ?? ""}>
|
||||||
|
{h ? `${h}p` : t("downloads.profiles.best")}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">{t("downloads.profiles.audioFormat")}</label>
|
||||||
|
<select value={spec.audio_format ?? "m4a"} onChange={set("audio_format")} className={inputCls}>
|
||||||
|
<option value="m4a">M4A</option>
|
||||||
|
<option value="mp3">MP3</option>
|
||||||
|
<option value="opus">Opus</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{spec.mode !== "a" && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">{t("downloads.profiles.container")}</label>
|
||||||
|
<select value={spec.container ?? "mp4"} onChange={set("container")} className={inputCls}>
|
||||||
|
<option value="mp4">MP4</option>
|
||||||
|
<option value="mkv">MKV</option>
|
||||||
|
<option value="webm">WebM</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">{t("downloads.profiles.vcodec")}</label>
|
||||||
|
<select value={spec.vcodec ?? ""} onChange={set("vcodec")} className={inputCls}>
|
||||||
|
<option value="">{t("downloads.profiles.any")}</option>
|
||||||
|
<option value="h264">H.264</option>
|
||||||
|
<option value="vp9">VP9</option>
|
||||||
|
<option value="av1">AV1</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5 pt-1">
|
||||||
|
{spec.mode !== "a" && (
|
||||||
|
<Check label={t("downloads.profiles.embedSubs")} checked={spec.embed_subs} onChange={(v) => patch({ embed_subs: v })} />
|
||||||
|
)}
|
||||||
|
<Check label={t("downloads.profiles.embedChapters")} checked={spec.embed_chapters} onChange={(v) => patch({ embed_chapters: v })} />
|
||||||
|
<Check label={t("downloads.profiles.embedThumbnail")} checked={spec.embed_thumbnail} onChange={(v) => patch({ embed_thumbnail: v })} />
|
||||||
|
<Check label={t("downloads.profiles.sponsorblock")} checked={spec.sponsorblock} onChange={(v) => patch({ sponsorblock: v })} />
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 pt-1">
|
||||||
|
<button onClick={() => setFormOpen(false)} className="px-3 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-surface transition">
|
||||||
|
{t("downloads.actions.cancel")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => save.mutate()}
|
||||||
|
disabled={!name.trim() || save.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"
|
||||||
|
>
|
||||||
|
{editing ? t("downloads.profiles.save") : t("downloads.profiles.create")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -142,7 +142,7 @@ function JobRow({
|
||||||
<StatusDot k={statusKey(job)} />
|
<StatusDot k={statusKey(job)} />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="text-sm font-medium flex items-center gap-1.5 flex-wrap">
|
<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">
|
<span className="underline decoration-dotted decoration-muted/40 underline-offset-4 cursor-help">
|
||||||
{t(`scheduler.jobs.${job.id}`, job.id)}
|
{t(`scheduler.jobs.${job.id}`, job.id)}
|
||||||
</span>
|
</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,8 +128,17 @@ export default function SyncStatus({
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{showMain && <div className="flex items-center gap-1.5">{stateNode}</div>}
|
{/* 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 && (
|
{notFull > 0 && (
|
||||||
|
<div className="flex items-center justify-between gap-1.5">
|
||||||
<Tooltip hint={t("header.sync.fullHistoryTooltip")}>
|
<Tooltip hint={t("header.sync.fullHistoryTooltip")}>
|
||||||
<button
|
<button
|
||||||
onClick={onGoToFullHistory}
|
onClick={onGoToFullHistory}
|
||||||
|
|
@ -139,8 +148,9 @@ export default function SyncStatus({
|
||||||
{t("header.sync.withoutFullHistory", { count: notFull })}
|
{t("header.sync.withoutFullHistory", { count: notFull })}
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
{!showMain && pauseBtn}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{pauseBtn && <div className="pt-0.5">{pauseBtn}</div>}
|
|
||||||
</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 { createPortal } from "react-dom";
|
||||||
import { hintsEnabled, subscribeHints } from "../lib/hints";
|
import { hintsEnabled, subscribeHints } from "../lib/hints";
|
||||||
|
|
||||||
type Side = "top" | "bottom";
|
type Side = "top" | "bottom";
|
||||||
type Coords = { left: number; top: number; placement: Side };
|
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
|
/** 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
|
* 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. */
|
* 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 enabled = useSyncExternalStore(subscribeHints, hintsEnabled, hintsEnabled);
|
||||||
const ref = useRef<HTMLSpanElement>(null);
|
const ref = useRef<HTMLSpanElement>(null);
|
||||||
|
const tipRef = useRef<HTMLDivElement>(null);
|
||||||
const [coords, setCoords] = useState<Coords | null>(null);
|
const [coords, setCoords] = useState<Coords | null>(null);
|
||||||
|
|
||||||
function show() {
|
function show() {
|
||||||
|
|
@ -27,8 +31,12 @@ export default function Tooltip({
|
||||||
const r = el.getBoundingClientRect();
|
const r = el.getBoundingClientRect();
|
||||||
// Prefer the requested side; flip to bottom if there's no room above.
|
// Prefer the requested side; flip to bottom if there's no room above.
|
||||||
const placement: Side = side === "bottom" || r.top < 90 ? "bottom" : "top";
|
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({
|
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,
|
top: placement === "top" ? r.top - 8 : r.bottom + 8,
|
||||||
placement,
|
placement,
|
||||||
});
|
});
|
||||||
|
|
@ -37,6 +45,21 @@ export default function Tooltip({
|
||||||
setCoords(null);
|
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}</>;
|
if (!enabled || !hint) return <>{children}</>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -52,6 +75,7 @@ export default function Tooltip({
|
||||||
{coords &&
|
{coords &&
|
||||||
createPortal(
|
createPortal(
|
||||||
<div
|
<div
|
||||||
|
ref={tipRef}
|
||||||
role="tooltip"
|
role="tooltip"
|
||||||
style={{
|
style={{
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Avatar from "./Avatar";
|
import Avatar from "./Avatar";
|
||||||
import AddToPlaylist from "./AddToPlaylist";
|
import AddToPlaylist from "./AddToPlaylist";
|
||||||
|
import DownloadButton from "./DownloadButton";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import type { Video } from "../lib/api";
|
import type { Video } from "../lib/api";
|
||||||
import { formatDuration, formatViews, relativeTime } from "../lib/format";
|
import { formatDuration, formatViews, relativeTime } from "../lib/format";
|
||||||
|
|
@ -67,6 +68,7 @@ function Actions({
|
||||||
<Bookmark className="w-4 h-4" />
|
<Bookmark className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<AddToPlaylist videoId={video.id} />
|
<AddToPlaylist videoId={video.id} />
|
||||||
|
<DownloadButton videoId={video.id} title={video.title} />
|
||||||
<button
|
<button
|
||||||
onClick={act("hidden")}
|
onClick={act("hidden")}
|
||||||
title={video.status === "hidden" ? t("card.unhide") : t("card.hide")}
|
title={video.status === "hidden" ? t("card.unhide") : t("card.hide")}
|
||||||
|
|
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
177
frontend/src/i18n/locales/de/downloads.json
Normal file
177
frontend/src/i18n/locales/de/downloads.json
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
{
|
||||||
|
"navLabel": "Downloads",
|
||||||
|
"button": {
|
||||||
|
"label": "Herunterladen",
|
||||||
|
"queued": "In Warteschlange",
|
||||||
|
"downloaded": "Heruntergeladen"
|
||||||
|
},
|
||||||
|
"dialog": {
|
||||||
|
"title": "Video herunterladen",
|
||||||
|
"profile": "Format",
|
||||||
|
"nameLabel": "Dateiname (optional)",
|
||||||
|
"namePlaceholder": "Leer lassen, um den Videotitel zu verwenden",
|
||||||
|
"submit": "Zu Downloads hinzufügen",
|
||||||
|
"added": "Zu Downloads hinzugefügt",
|
||||||
|
"view": "Öffnen",
|
||||||
|
"manage": "Formate verwalten"
|
||||||
|
},
|
||||||
|
"page": {
|
||||||
|
"title": "Downloads",
|
||||||
|
"subtitle": "Videos auf den Server laden und dann auf dein Gerät speichern.",
|
||||||
|
"addUrl": "YouTube-Link oder Video-ID einfügen…",
|
||||||
|
"add": "Hinzufügen",
|
||||||
|
"manage": "Formate verwalten"
|
||||||
|
},
|
||||||
|
"tabs": {
|
||||||
|
"queue": "Warteschlange",
|
||||||
|
"done": "Bibliothek",
|
||||||
|
"shared": "Mit mir geteilt",
|
||||||
|
"system": "System"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"queued": "Wartet",
|
||||||
|
"running": "Lädt",
|
||||||
|
"paused": "Pausiert",
|
||||||
|
"done": "Fertig",
|
||||||
|
"error": "Fehlgeschlagen",
|
||||||
|
"canceled": "Abgebrochen",
|
||||||
|
"expired": "Abgelaufen"
|
||||||
|
},
|
||||||
|
"phase": {
|
||||||
|
"video": "Video wird geladen",
|
||||||
|
"audio": "Audio wird geladen",
|
||||||
|
"merging": "Zusammenführen",
|
||||||
|
"audio_extract": "Audio wird extrahiert",
|
||||||
|
"thumbnail": "Vorschaubild einbetten",
|
||||||
|
"sponsorblock": "Sponsoren entfernen",
|
||||||
|
"metadata": "Metadaten schreiben",
|
||||||
|
"processing": "Verarbeitung",
|
||||||
|
"editing": "Bearbeitung"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"pause": "Pause",
|
||||||
|
"resume": "Fortsetzen",
|
||||||
|
"cancel": "Abbrechen",
|
||||||
|
"delete": "Entfernen",
|
||||||
|
"retry": "Erneut",
|
||||||
|
"download": "Auf mein Gerät speichern",
|
||||||
|
"rename": "Umbenennen",
|
||||||
|
"share": "Teilen",
|
||||||
|
"edit": "Bearbeiten / schneiden",
|
||||||
|
"removeShared": "Aus meiner Liste entfernen"
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"queue": "Die Warteschlange ist leer.",
|
||||||
|
"done": "Noch keine Downloads.",
|
||||||
|
"shared": "Es wurde nichts mit dir geteilt.",
|
||||||
|
"admin": "Auf dieser Instanz gibt es noch keine Downloads."
|
||||||
|
},
|
||||||
|
"usage": {
|
||||||
|
"title": "Dein Speicher",
|
||||||
|
"items": "{{used}} / {{max}} Elemente",
|
||||||
|
"unlimited": "Unbegrenzt"
|
||||||
|
},
|
||||||
|
"rename": {
|
||||||
|
"title": "Download umbenennen",
|
||||||
|
"label": "Anzeigename",
|
||||||
|
"hint": "Ändert nur den angezeigten und beim Speichern verwendeten Namen — die gespeicherte Datei behält ihren Namen.",
|
||||||
|
"save": "Speichern"
|
||||||
|
},
|
||||||
|
"share": {
|
||||||
|
"title": "Download teilen",
|
||||||
|
"label": "E-Mail des Empfängers",
|
||||||
|
"placeholder": "user@example.com",
|
||||||
|
"submit": "Teilen",
|
||||||
|
"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.",
|
||||||
|
"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",
|
||||||
|
"title": "Download-Formate",
|
||||||
|
"builtin": "Integriert",
|
||||||
|
"yours": "Deine Formate",
|
||||||
|
"new": "Neues Format",
|
||||||
|
"name": "Name",
|
||||||
|
"namePlaceholder": "Mein Format",
|
||||||
|
"mode": "Inhalt",
|
||||||
|
"modeAv": "Video + Audio",
|
||||||
|
"modeV": "Nur Video",
|
||||||
|
"modeA": "Nur Audio",
|
||||||
|
"quality": "Max. Qualität",
|
||||||
|
"best": "Beste",
|
||||||
|
"container": "Container",
|
||||||
|
"audioFormat": "Audioformat",
|
||||||
|
"vcodec": "Video-Codec",
|
||||||
|
"any": "Beliebig",
|
||||||
|
"embedSubs": "Untertitel einbetten",
|
||||||
|
"embedChapters": "Kapitel einbetten",
|
||||||
|
"embedThumbnail": "Vorschaubild einbetten",
|
||||||
|
"sponsorblock": "Sponsor-Segmente überspringen (SponsorBlock)",
|
||||||
|
"save": "Speichern",
|
||||||
|
"create": "Erstellen",
|
||||||
|
"delete": "Löschen",
|
||||||
|
"deleteConfirm": "Das Format „{{name}}“ löschen?"
|
||||||
|
},
|
||||||
|
"admin": {
|
||||||
|
"storageTitle": "Speicher",
|
||||||
|
"readyFiles": "Fertige Dateien",
|
||||||
|
"totalSize": "Gesamtgröße",
|
||||||
|
"cap": "Cache-Limit",
|
||||||
|
"noCap": "kein Limit",
|
||||||
|
"perUser": "Speicher pro Nutzer",
|
||||||
|
"user": "Nutzer",
|
||||||
|
"footprint": "Belegung",
|
||||||
|
"editQuota": "Kontingent bearbeiten",
|
||||||
|
"quotaTitle": "Download-Kontingent — {{email}}",
|
||||||
|
"maxBytes": "Speicherlimit (GB)",
|
||||||
|
"maxJobs": "Max. Downloads",
|
||||||
|
"maxConcurrent": "Max. gleichzeitig",
|
||||||
|
"unlimited": "Unbegrenzt (Speicherlimit umgehen)",
|
||||||
|
"reset": "Auf Standard zurücksetzen",
|
||||||
|
"save": "Speichern",
|
||||||
|
"custom": "individuell",
|
||||||
|
"default": "Standard",
|
||||||
|
"quotaPickPlaceholder": "Kontingent für einen Nutzer festlegen…",
|
||||||
|
"noFootprint": "Noch keine Downloads."
|
||||||
|
},
|
||||||
|
"cols": {
|
||||||
|
"title": "Titel",
|
||||||
|
"channel": "Kanal",
|
||||||
|
"format": "Format",
|
||||||
|
"size": "Größe",
|
||||||
|
"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.",
|
"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.",
|
"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.",
|
"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": {
|
"jobs": {
|
||||||
"rss_poll": "RSS-Abfrage (neue Uploads)",
|
"rss_poll": "RSS-Abfrage (neue Uploads)",
|
||||||
|
|
@ -79,7 +80,8 @@
|
||||||
"playlist_sync": "YouTube-Wiedergabelisten-Sync",
|
"playlist_sync": "YouTube-Wiedergabelisten-Sync",
|
||||||
"maintenance": "Wartung + Validierung",
|
"maintenance": "Wartung + Validierung",
|
||||||
"demo_reset": "Demo-Konto-Reset",
|
"demo_reset": "Demo-Konto-Reset",
|
||||||
"explore_cleanup": "Bereinigung erkundeter Kanäle"
|
"explore_cleanup": "Bereinigung erkundeter Kanäle",
|
||||||
|
"download_gc": "Download-Bereinigung"
|
||||||
},
|
},
|
||||||
"queue": {
|
"queue": {
|
||||||
"recentPending": "Zu synchronisierende Kanäle",
|
"recentPending": "Zu synchronisierende Kanäle",
|
||||||
|
|
|
||||||
177
frontend/src/i18n/locales/en/downloads.json
Normal file
177
frontend/src/i18n/locales/en/downloads.json
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
{
|
||||||
|
"navLabel": "Downloads",
|
||||||
|
"button": {
|
||||||
|
"label": "Download",
|
||||||
|
"queued": "In queue",
|
||||||
|
"downloaded": "Downloaded"
|
||||||
|
},
|
||||||
|
"dialog": {
|
||||||
|
"title": "Download video",
|
||||||
|
"profile": "Format",
|
||||||
|
"nameLabel": "File name (optional)",
|
||||||
|
"namePlaceholder": "Leave blank to use the video title",
|
||||||
|
"submit": "Add to downloads",
|
||||||
|
"added": "Added to downloads",
|
||||||
|
"view": "View",
|
||||||
|
"manage": "Manage formats"
|
||||||
|
},
|
||||||
|
"page": {
|
||||||
|
"title": "Downloads",
|
||||||
|
"subtitle": "Download videos to the server, then save them to your device.",
|
||||||
|
"addUrl": "Paste a YouTube link or video id…",
|
||||||
|
"add": "Add",
|
||||||
|
"manage": "Manage formats"
|
||||||
|
},
|
||||||
|
"tabs": {
|
||||||
|
"queue": "Queue",
|
||||||
|
"done": "Library",
|
||||||
|
"shared": "Shared with me",
|
||||||
|
"system": "System"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"queued": "Queued",
|
||||||
|
"running": "Downloading",
|
||||||
|
"paused": "Paused",
|
||||||
|
"done": "Ready",
|
||||||
|
"error": "Failed",
|
||||||
|
"canceled": "Canceled",
|
||||||
|
"expired": "Expired"
|
||||||
|
},
|
||||||
|
"phase": {
|
||||||
|
"video": "Downloading video",
|
||||||
|
"audio": "Downloading audio",
|
||||||
|
"merging": "Merging",
|
||||||
|
"audio_extract": "Extracting audio",
|
||||||
|
"thumbnail": "Embedding thumbnail",
|
||||||
|
"sponsorblock": "Removing sponsors",
|
||||||
|
"metadata": "Writing metadata",
|
||||||
|
"processing": "Processing",
|
||||||
|
"editing": "Editing"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"pause": "Pause",
|
||||||
|
"resume": "Resume",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"delete": "Remove",
|
||||||
|
"retry": "Retry",
|
||||||
|
"download": "Save to my device",
|
||||||
|
"rename": "Rename",
|
||||||
|
"share": "Share",
|
||||||
|
"edit": "Edit / trim",
|
||||||
|
"removeShared": "Remove from my list"
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"queue": "Nothing in the queue.",
|
||||||
|
"done": "No downloads yet.",
|
||||||
|
"shared": "Nothing has been shared with you.",
|
||||||
|
"admin": "No downloads on this instance yet."
|
||||||
|
},
|
||||||
|
"usage": {
|
||||||
|
"title": "Your storage",
|
||||||
|
"items": "{{used}} / {{max}} items",
|
||||||
|
"unlimited": "Unlimited"
|
||||||
|
},
|
||||||
|
"rename": {
|
||||||
|
"title": "Rename download",
|
||||||
|
"label": "Display name",
|
||||||
|
"hint": "Only changes the name you see and download with — the stored file keeps its own name.",
|
||||||
|
"save": "Save"
|
||||||
|
},
|
||||||
|
"share": {
|
||||||
|
"title": "Share download",
|
||||||
|
"label": "Recipient email",
|
||||||
|
"placeholder": "user@example.com",
|
||||||
|
"submit": "Share",
|
||||||
|
"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.",
|
||||||
|
"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",
|
||||||
|
"title": "Download formats",
|
||||||
|
"builtin": "Built-in",
|
||||||
|
"yours": "Your formats",
|
||||||
|
"new": "New format",
|
||||||
|
"name": "Name",
|
||||||
|
"namePlaceholder": "My format",
|
||||||
|
"mode": "Content",
|
||||||
|
"modeAv": "Video + audio",
|
||||||
|
"modeV": "Video only",
|
||||||
|
"modeA": "Audio only",
|
||||||
|
"quality": "Max quality",
|
||||||
|
"best": "Best",
|
||||||
|
"container": "Container",
|
||||||
|
"audioFormat": "Audio format",
|
||||||
|
"vcodec": "Video codec",
|
||||||
|
"any": "Any",
|
||||||
|
"embedSubs": "Embed subtitles",
|
||||||
|
"embedChapters": "Embed chapters",
|
||||||
|
"embedThumbnail": "Embed thumbnail",
|
||||||
|
"sponsorblock": "Skip sponsor segments (SponsorBlock)",
|
||||||
|
"save": "Save",
|
||||||
|
"create": "Create",
|
||||||
|
"delete": "Delete",
|
||||||
|
"deleteConfirm": "Delete the format “{{name}}”?"
|
||||||
|
},
|
||||||
|
"admin": {
|
||||||
|
"storageTitle": "Storage",
|
||||||
|
"readyFiles": "Ready files",
|
||||||
|
"totalSize": "Total size",
|
||||||
|
"cap": "Cache cap",
|
||||||
|
"noCap": "no cap",
|
||||||
|
"perUser": "Per-user footprint",
|
||||||
|
"user": "User",
|
||||||
|
"footprint": "Footprint",
|
||||||
|
"editQuota": "Edit quota",
|
||||||
|
"quotaTitle": "Download quota — {{email}}",
|
||||||
|
"maxBytes": "Storage limit (GB)",
|
||||||
|
"maxJobs": "Max downloads",
|
||||||
|
"maxConcurrent": "Max concurrent",
|
||||||
|
"unlimited": "Unlimited (bypass storage limit)",
|
||||||
|
"reset": "Reset to default",
|
||||||
|
"save": "Save",
|
||||||
|
"custom": "custom",
|
||||||
|
"default": "default",
|
||||||
|
"quotaPickPlaceholder": "Set a user's quota…",
|
||||||
|
"noFootprint": "No downloads yet."
|
||||||
|
},
|
||||||
|
"cols": {
|
||||||
|
"title": "Title",
|
||||||
|
"channel": "Channel",
|
||||||
|
"format": "Format",
|
||||||
|
"size": "Size",
|
||||||
|
"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.",
|
"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.",
|
"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.",
|
"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": {
|
"jobs": {
|
||||||
"rss_poll": "RSS poll (new uploads)",
|
"rss_poll": "RSS poll (new uploads)",
|
||||||
|
|
@ -79,7 +80,8 @@
|
||||||
"playlist_sync": "YouTube playlist sync",
|
"playlist_sync": "YouTube playlist sync",
|
||||||
"maintenance": "Maintenance + validation",
|
"maintenance": "Maintenance + validation",
|
||||||
"demo_reset": "Demo account reset",
|
"demo_reset": "Demo account reset",
|
||||||
"explore_cleanup": "Explored-channel cleanup"
|
"explore_cleanup": "Explored-channel cleanup",
|
||||||
|
"download_gc": "Download cleanup"
|
||||||
},
|
},
|
||||||
"queue": {
|
"queue": {
|
||||||
"recentPending": "Channels to sync",
|
"recentPending": "Channels to sync",
|
||||||
|
|
|
||||||
177
frontend/src/i18n/locales/hu/downloads.json
Normal file
177
frontend/src/i18n/locales/hu/downloads.json
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
{
|
||||||
|
"navLabel": "Letöltések",
|
||||||
|
"button": {
|
||||||
|
"label": "Letöltés",
|
||||||
|
"queued": "Sorban",
|
||||||
|
"downloaded": "Letöltve"
|
||||||
|
},
|
||||||
|
"dialog": {
|
||||||
|
"title": "Videó letöltése",
|
||||||
|
"profile": "Formátum",
|
||||||
|
"nameLabel": "Fájlnév (opcionális)",
|
||||||
|
"namePlaceholder": "Üresen hagyva a videó címét használja",
|
||||||
|
"submit": "Hozzáadás a letöltésekhez",
|
||||||
|
"added": "Hozzáadva a letöltésekhez",
|
||||||
|
"view": "Megnyitás",
|
||||||
|
"manage": "Formátumok kezelése"
|
||||||
|
},
|
||||||
|
"page": {
|
||||||
|
"title": "Letöltések",
|
||||||
|
"subtitle": "Töltsd le a videókat a szerverre, majd mentsd a saját gépedre.",
|
||||||
|
"addUrl": "Illessz be egy YouTube linket vagy videó azonosítót…",
|
||||||
|
"add": "Hozzáad",
|
||||||
|
"manage": "Formátumok kezelése"
|
||||||
|
},
|
||||||
|
"tabs": {
|
||||||
|
"queue": "Sor",
|
||||||
|
"done": "Könyvtár",
|
||||||
|
"shared": "Velem megosztva",
|
||||||
|
"system": "Rendszer"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"queued": "Várakozik",
|
||||||
|
"running": "Letöltés",
|
||||||
|
"paused": "Szüneteltetve",
|
||||||
|
"done": "Kész",
|
||||||
|
"error": "Sikertelen",
|
||||||
|
"canceled": "Megszakítva",
|
||||||
|
"expired": "Lejárt"
|
||||||
|
},
|
||||||
|
"phase": {
|
||||||
|
"video": "Videósáv letöltése",
|
||||||
|
"audio": "Hangsáv letöltése",
|
||||||
|
"merging": "Összefűzés",
|
||||||
|
"audio_extract": "Hang kinyerése",
|
||||||
|
"thumbnail": "Bélyegkép beágyazása",
|
||||||
|
"sponsorblock": "Szponzorok eltávolítása",
|
||||||
|
"metadata": "Metaadat írása",
|
||||||
|
"processing": "Feldolgozás",
|
||||||
|
"editing": "Szerkesztés"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"pause": "Szünet",
|
||||||
|
"resume": "Folytatás",
|
||||||
|
"cancel": "Megszakítás",
|
||||||
|
"delete": "Eltávolítás",
|
||||||
|
"retry": "Újra",
|
||||||
|
"download": "Mentés a gépemre",
|
||||||
|
"rename": "Átnevezés",
|
||||||
|
"share": "Megosztás",
|
||||||
|
"edit": "Szerkesztés / vágás",
|
||||||
|
"removeShared": "Eltávolítás a listámból"
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"queue": "A sor üres.",
|
||||||
|
"done": "Még nincs letöltés.",
|
||||||
|
"shared": "Még nem osztottak meg veled semmit.",
|
||||||
|
"admin": "Ezen a példányon még nincs letöltés."
|
||||||
|
},
|
||||||
|
"usage": {
|
||||||
|
"title": "Tárhelyed",
|
||||||
|
"items": "{{used}} / {{max}} elem",
|
||||||
|
"unlimited": "Korlátlan"
|
||||||
|
},
|
||||||
|
"rename": {
|
||||||
|
"title": "Letöltés átnevezése",
|
||||||
|
"label": "Megjelenített név",
|
||||||
|
"hint": "Csak a megjelenített és letöltéskor használt nevet módosítja — a tárolt fájl neve nem változik.",
|
||||||
|
"save": "Mentés"
|
||||||
|
},
|
||||||
|
"share": {
|
||||||
|
"title": "Letöltés megosztása",
|
||||||
|
"label": "Címzett e-mail címe",
|
||||||
|
"placeholder": "user@example.com",
|
||||||
|
"submit": "Megosztás",
|
||||||
|
"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.",
|
||||||
|
"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",
|
||||||
|
"title": "Letöltési formátumok",
|
||||||
|
"builtin": "Beépített",
|
||||||
|
"yours": "Saját formátumaid",
|
||||||
|
"new": "Új formátum",
|
||||||
|
"name": "Név",
|
||||||
|
"namePlaceholder": "Saját formátum",
|
||||||
|
"mode": "Tartalom",
|
||||||
|
"modeAv": "Videó + hang",
|
||||||
|
"modeV": "Csak videó",
|
||||||
|
"modeA": "Csak hang",
|
||||||
|
"quality": "Max. minőség",
|
||||||
|
"best": "Legjobb",
|
||||||
|
"container": "Konténer",
|
||||||
|
"audioFormat": "Hangformátum",
|
||||||
|
"vcodec": "Videó codec",
|
||||||
|
"any": "Bármelyik",
|
||||||
|
"embedSubs": "Feliratok beágyazása",
|
||||||
|
"embedChapters": "Fejezetek beágyazása",
|
||||||
|
"embedThumbnail": "Bélyegkép beágyazása",
|
||||||
|
"sponsorblock": "Szponzorált szakaszok kihagyása (SponsorBlock)",
|
||||||
|
"save": "Mentés",
|
||||||
|
"create": "Létrehozás",
|
||||||
|
"delete": "Törlés",
|
||||||
|
"deleteConfirm": "Törlöd a(z) „{{name}}” formátumot?"
|
||||||
|
},
|
||||||
|
"admin": {
|
||||||
|
"storageTitle": "Tárhely",
|
||||||
|
"readyFiles": "Kész fájlok",
|
||||||
|
"totalSize": "Teljes méret",
|
||||||
|
"cap": "Gyorsítótár-korlát",
|
||||||
|
"noCap": "nincs korlát",
|
||||||
|
"perUser": "Felhasználónkénti tárhelyhasználat",
|
||||||
|
"user": "Felhasználó",
|
||||||
|
"footprint": "Használat",
|
||||||
|
"editQuota": "Kvóta szerkesztése",
|
||||||
|
"quotaTitle": "Letöltési kvóta — {{email}}",
|
||||||
|
"maxBytes": "Tárhelykorlát (GB)",
|
||||||
|
"maxJobs": "Max. letöltés",
|
||||||
|
"maxConcurrent": "Max. egyidejű",
|
||||||
|
"unlimited": "Korlátlan (tárhelykorlát kikapcsolása)",
|
||||||
|
"reset": "Visszaállítás alapértékre",
|
||||||
|
"save": "Mentés",
|
||||||
|
"custom": "egyedi",
|
||||||
|
"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",
|
||||||
|
"channel": "Csatorna",
|
||||||
|
"format": "Formátum",
|
||||||
|
"size": "Méret",
|
||||||
|
"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.",
|
"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.",
|
"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.",
|
"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": {
|
"jobs": {
|
||||||
"rss_poll": "RSS-lekérdezés (új feltöltések)",
|
"rss_poll": "RSS-lekérdezés (új feltöltések)",
|
||||||
|
|
@ -79,7 +80,8 @@
|
||||||
"playlist_sync": "YouTube lejátszási listák szinkronja",
|
"playlist_sync": "YouTube lejátszási listák szinkronja",
|
||||||
"maintenance": "Karbantartás + ellenőrzés",
|
"maintenance": "Karbantartás + ellenőrzés",
|
||||||
"demo_reset": "Demo fiók visszaállítása",
|
"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": {
|
"queue": {
|
||||||
"recentPending": "Szinkronra váró csatorna",
|
"recentPending": "Szinkronra váró csatorna",
|
||||||
|
|
|
||||||
|
|
@ -623,6 +623,137 @@ export interface AdminUserRow {
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- download center ---
|
||||||
|
export interface DownloadSpec {
|
||||||
|
mode: "av" | "v" | "a"; // audio+video | video-only | audio-only
|
||||||
|
max_height: number | null;
|
||||||
|
container: string | null;
|
||||||
|
audio_format: string | null;
|
||||||
|
vcodec: string | null;
|
||||||
|
embed_subs: boolean;
|
||||||
|
embed_chapters: boolean;
|
||||||
|
embed_thumbnail: boolean;
|
||||||
|
sponsorblock: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DownloadProfile {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
spec: DownloadSpec;
|
||||||
|
is_builtin: boolean;
|
||||||
|
sort_order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DownloadAsset {
|
||||||
|
id: number;
|
||||||
|
status: string;
|
||||||
|
title: string | null;
|
||||||
|
uploader: string | null;
|
||||||
|
upload_date: string | null;
|
||||||
|
duration_s: number | null;
|
||||||
|
size_bytes: number | null;
|
||||||
|
width: number | null;
|
||||||
|
height: number | null;
|
||||||
|
container: string | null;
|
||||||
|
thumbnail_url: string | null;
|
||||||
|
expires_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DownloadStatus =
|
||||||
|
| "queued"
|
||||||
|
| "running"
|
||||||
|
| "paused"
|
||||||
|
| "done"
|
||||||
|
| "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;
|
||||||
|
progress: number;
|
||||||
|
speed_bps: number | null;
|
||||||
|
eta_s: number | null;
|
||||||
|
phase: string | null;
|
||||||
|
error: string | null;
|
||||||
|
queue_pos: number;
|
||||||
|
created_at: string | null;
|
||||||
|
source_kind: string;
|
||||||
|
source_ref: string;
|
||||||
|
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;
|
||||||
|
shared?: boolean;
|
||||||
|
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;
|
||||||
|
running_jobs: number;
|
||||||
|
max_bytes: number;
|
||||||
|
max_concurrent: number;
|
||||||
|
max_jobs: number;
|
||||||
|
unlimited: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminStorage {
|
||||||
|
ready_files: number;
|
||||||
|
total_bytes: number;
|
||||||
|
total_assets: number;
|
||||||
|
total_cap_bytes: number;
|
||||||
|
per_user: { user_id: number; email: string | null; footprint_bytes: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminDownloadQuota {
|
||||||
|
user_id: number;
|
||||||
|
custom: boolean;
|
||||||
|
max_bytes: number;
|
||||||
|
max_concurrent: number;
|
||||||
|
max_jobs: number;
|
||||||
|
unlimited: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
me: (): Promise<Me> => req("/api/me"),
|
me: (): Promise<Me> => req("/api/me"),
|
||||||
accounts: (): Promise<Account[]> => req("/api/me/accounts"),
|
accounts: (): Promise<Account[]> => req("/api/me/accounts"),
|
||||||
|
|
@ -914,6 +1045,97 @@ export const api = {
|
||||||
updateTag: (id: number, patch: { name?: string; color?: string }) =>
|
updateTag: (id: number, patch: { name?: string; color?: string }) =>
|
||||||
req(`/api/tags/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
req(`/api/tags/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
||||||
deleteTag: (id: number) => req(`/api/tags/${id}`, { method: "DELETE" }),
|
deleteTag: (id: number) => req(`/api/tags/${id}`, { method: "DELETE" }),
|
||||||
|
|
||||||
|
// --- download center ---
|
||||||
|
downloadProfiles: (): Promise<DownloadProfile[]> => req("/api/downloads/profiles"),
|
||||||
|
createDownloadProfile: (p: { name: string; spec: DownloadSpec }): Promise<DownloadProfile> =>
|
||||||
|
req("/api/downloads/profiles", { method: "POST", body: JSON.stringify(p) }),
|
||||||
|
updateDownloadProfile: (
|
||||||
|
id: number,
|
||||||
|
patch: { name?: string; spec?: DownloadSpec }
|
||||||
|
): Promise<DownloadProfile> =>
|
||||||
|
req(`/api/downloads/profiles/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
||||||
|
deleteDownloadProfile: (id: number) =>
|
||||||
|
req(`/api/downloads/profiles/${id}`, { method: "DELETE" }),
|
||||||
|
|
||||||
|
enqueueDownload: (body: {
|
||||||
|
source: string;
|
||||||
|
profile_id?: number;
|
||||||
|
spec?: DownloadSpec;
|
||||||
|
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> =>
|
||||||
|
req(`/api/downloads/${id}/pause`, { method: "POST" }, { idempotent: true }),
|
||||||
|
resumeDownload: (id: number): Promise<DownloadJob> =>
|
||||||
|
req(`/api/downloads/${id}/resume`, { method: "POST" }, { idempotent: true }),
|
||||||
|
cancelDownload: (id: number): Promise<DownloadJob> =>
|
||||||
|
req(`/api/downloads/${id}/cancel`, { method: "POST" }, { idempotent: true }),
|
||||||
|
deleteDownload: (id: number) => req(`/api/downloads/${id}`, { method: "DELETE" }),
|
||||||
|
renameDownload: (id: number, display_name: string): Promise<DownloadJob> =>
|
||||||
|
req(`/api/downloads/${id}`, { method: "PATCH", body: JSON.stringify({ display_name }) }),
|
||||||
|
shareDownload: (id: number, email: string): Promise<{ shared_with: string }> =>
|
||||||
|
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.
|
||||||
|
downloadFileUrl: (id: number): string => {
|
||||||
|
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"),
|
||||||
|
adminDownloadStorage: (): Promise<AdminStorage> => req("/api/admin/downloads/storage"),
|
||||||
|
adminGetDownloadQuota: (userId: number): Promise<AdminDownloadQuota> =>
|
||||||
|
req(`/api/admin/downloads/quota/${userId}`),
|
||||||
|
adminSetDownloadQuota: (
|
||||||
|
userId: number,
|
||||||
|
patch: Partial<Pick<AdminDownloadQuota, "max_bytes" | "max_concurrent" | "max_jobs" | "unlimited">>
|
||||||
|
): Promise<AdminDownloadQuota> =>
|
||||||
|
req(`/api/admin/downloads/quota/${userId}`, { method: "PUT", body: JSON.stringify(patch) }),
|
||||||
|
adminResetDownloadQuota: (userId: number): Promise<AdminDownloadQuota> =>
|
||||||
|
req(`/api/admin/downloads/quota/${userId}`, { method: "DELETE" }),
|
||||||
};
|
};
|
||||||
|
|
||||||
export { HttpError };
|
export { HttpError };
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,26 @@ export function relativeTime(iso: string | null): string {
|
||||||
return relativeFromMs(new Date(iso).getTime());
|
return relativeFromMs(new Date(iso).getTime());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Human file size, binary units (1024). e.g. 501747 -> "490 KB", 5368709120 -> "5.0 GB". */
|
||||||
|
export function formatBytes(bytes: number | null | undefined): string {
|
||||||
|
const n = Number(bytes);
|
||||||
|
if (!n || n < 0) return "0 B";
|
||||||
|
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||||
|
let i = 0;
|
||||||
|
let v = n;
|
||||||
|
while (v >= 1024 && i < units.length - 1) {
|
||||||
|
v /= 1024;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return `${v >= 100 || i === 0 ? Math.round(v) : v.toFixed(1)} ${units[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Download speed from bytes/sec, e.g. "2.4 MB/s". */
|
||||||
|
export function formatSpeed(bps: number | null | undefined): string {
|
||||||
|
if (!bps) return "";
|
||||||
|
return `${formatBytes(bps)}/s`;
|
||||||
|
}
|
||||||
|
|
||||||
/** Relative-time label from an epoch-millis timestamp (the ms-based half of relativeTime, for
|
/** Relative-time label from an epoch-millis timestamp (the ms-based half of relativeTime, for
|
||||||
* client-side notifications whose timestamps are already numbers). One implementation, one
|
* client-side notifications whose timestamps are already numbers). One implementation, one
|
||||||
* set of `time.*` strings — shared instead of re-implemented per component. */
|
* set of `time.*` strings — shared instead of re-implemented per component. */
|
||||||
|
|
|
||||||
14
frontend/src/lib/nav.ts
Normal file
14
frontend/src/lib/nav.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import type { Page } from "./urlState";
|
||||||
|
|
||||||
|
// Tiny decoupled navigator so deep components (a feed card's download toast, the Download
|
||||||
|
// Center) can switch pages without threading setPage through the whole tree. App registers
|
||||||
|
// its setPage on mount; navigateTo is a no-op until then.
|
||||||
|
let _navigate: ((p: Page) => void) | null = null;
|
||||||
|
|
||||||
|
export function setNavigator(fn: (p: Page) => void): void {
|
||||||
|
_navigate = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function navigateTo(p: Page): void {
|
||||||
|
_navigate?.(p);
|
||||||
|
}
|
||||||
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[] = [
|
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",
|
version: "0.21.0",
|
||||||
date: "2026-07-02",
|
date: "2026-07-02",
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,7 @@ export const PAGES = [
|
||||||
"users",
|
"users",
|
||||||
"notifications",
|
"notifications",
|
||||||
"messages",
|
"messages",
|
||||||
|
"downloads",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type Page = (typeof PAGES)[number];
|
export type Page = (typeof PAGES)[number];
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import ErrorBoundary from "./components/ErrorBoundary";
|
||||||
import { ConfirmProvider } from "./components/ConfirmProvider";
|
import { ConfirmProvider } from "./components/ConfirmProvider";
|
||||||
import PrivacyPolicy from "./components/legal/PrivacyPolicy";
|
import PrivacyPolicy from "./components/legal/PrivacyPolicy";
|
||||||
import Terms from "./components/legal/Terms";
|
import Terms from "./components/legal/Terms";
|
||||||
|
import WatchPage from "./components/WatchPage";
|
||||||
import "./i18n";
|
import "./i18n";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
|
|
@ -19,7 +20,8 @@ const queryClient = new QueryClient({
|
||||||
const path = window.location.pathname;
|
const path = window.location.pathname;
|
||||||
const root =
|
const root =
|
||||||
path === "/privacy" ? <PrivacyPolicy /> :
|
path === "/privacy" ? <PrivacyPolicy /> :
|
||||||
path === "/terms" ? <Terms /> : (
|
path === "/terms" ? <Terms /> :
|
||||||
|
path.startsWith("/watch/") ? <WatchPage /> : (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<ConfirmProvider>
|
<ConfirmProvider>
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,10 @@ POSTGRES_PASSWORD=$pgPass
|
||||||
SECRET_KEY=$secretKey
|
SECRET_KEY=$secretKey
|
||||||
TOKEN_ENCRYPTION_KEY=$fernet
|
TOKEN_ENCRYPTION_KEY=$fernet
|
||||||
OAUTH_REDIRECT_URL=$url/auth/callback
|
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
|
"@ | Set-Content -Path .env -Encoding ascii
|
||||||
Write-Host "Wrote .env (secrets generated)."
|
Write-Host "Wrote .env (secrets generated)."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,10 @@ POSTGRES_PASSWORD=$POSTGRES_PASSWORD
|
||||||
SECRET_KEY=$SECRET_KEY
|
SECRET_KEY=$SECRET_KEY
|
||||||
TOKEN_ENCRYPTION_KEY=$TOKEN_ENCRYPTION_KEY
|
TOKEN_ENCRYPTION_KEY=$TOKEN_ENCRYPTION_KEY
|
||||||
OAUTH_REDIRECT_URL=$URL/auth/callback
|
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
|
EOF
|
||||||
chmod 600 .env
|
chmod 600 .env
|
||||||
echo "Wrote .env (secrets generated)."
|
echo "Wrote .env (secrets generated)."
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue