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

{t("downloads.rename.hint")}

+
+ +
+
+ ); +} + +// --- 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 ( +
+
+ {t("downloads.usage.title")} + + {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 })} + +
+ {!u.unlimited && ( +
+
90 ? "bg-red-400" : "bg-accent")} style={{ width: `${pct}%` }} /> +
+ )} +
+ ); +} + +// --- 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) => setForm({ ...state, ...p }); + return ( + +
+
+ + + +
+ +
+ + +
+
+
+ ); +} + +// 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 ( +
+ { + 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 && ( +
+ {filtered.map((u) => ( + + ))} +
+ )} +
+ ); +} + +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 ( +
+ {s && ( +
+ {[ + [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]) => ( +
+
{label}
+
{val}
+
+ ))} +
+ )} + + {s && ( +
+
+
{t("downloads.admin.perUser")}
+ {/* Set a quota for any user, regardless of whether they've downloaded anything. */} + +
+
+ {s.per_user.map((u) => ( +
+ {u.email} + {formatBytes(u.footprint_bytes)} + +
+ ))} + {s.per_user.length === 0 && ( +
{t("downloads.admin.noFootprint")}
+ )} +
+
+ )} + +
{t("downloads.tabs.system")}
+
+ {(jobs.data ?? []).map((j) => ( +
+ {jobTitle(j)} + {j.user_email} + + {j.asset?.size_bytes ? formatBytes(j.asset.size_bytes) : ""} +
+ ))} + {jobs.data && jobs.data.length === 0 &&
{t("downloads.empty.admin")}
} +
+ + {quotaFor && setQuotaFor(null)} />} +
+ ); +} + +// --- 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(null); + const [manage, setManage] = useState(false); + const [renameJob, setRenameJob] = useState(null); + const [shareJob, setShareJob] = useState(null); + const [editJob, setEditJob] = useState(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) => ( +
+ + + ); + + return ( +
+
+

{t("downloads.page.title")}

+ +
+

{t("downloads.page.subtitle")}

+ + + + {tab === "queue" && ( + <> +
+ setAddUrl(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && addUrl.trim() && setAddSource(addUrl.trim())} + placeholder={t("downloads.page.addUrl")} + className={inputCls} + /> + +
+
+ {queue.map((job) => ( + + {(job.status === "queued" || job.status === "running") && ( + act.mutate({ id: job.id, op: "pause" })} title={t("downloads.actions.pause")}> + + + )} + {(job.status === "paused" || job.status === "error") && ( + act.mutate({ id: job.id, op: "resume" })} title={t(job.status === "error" ? "downloads.actions.retry" : "downloads.actions.resume")}> + + + )} + confirmThen(t("downloads.confirm.cancelTitle"), t("downloads.confirm.cancelBody"), () => act.mutate({ id: job.id, op: "cancel" }))} + title={t("downloads.actions.cancel")} + danger + > + + + + ))} + {queue.length === 0 &&
{t("downloads.empty.queue")}
} +
+ + )} + + {tab === "done" && ( + <> + +
+ {library.map((job) => ( + + {saveBtn(job)} + {job.can_download && (job.asset?.duration_s ?? 0) > 0 && ( + setEditJob(job)} title={t("downloads.actions.edit")}> + + + )} + setRenameJob(job)} title={t("downloads.actions.rename")}> + + + setShareJob(job)} title={t("downloads.actions.share")}> + + + confirmThen(t("downloads.confirm.deleteTitle"), t("downloads.confirm.deleteBody"), () => act.mutate({ id: job.id, op: "delete" }))} + title={t("downloads.actions.delete")} + danger + > + + + + ))} + {library.length === 0 &&
{t("downloads.empty.done")}
} +
+ + )} + + {tab === "shared" && ( +
+ {(sharedQ.data ?? []).map((job) => ( + + {job.can_download && saveBtn(job)} + {job.can_download && (job.asset?.duration_s ?? 0) > 0 && ( + setEditJob(job)} title={t("downloads.actions.edit")}> + + + )} + + confirmThen( + t("downloads.confirm.removeSharedTitle"), + t("downloads.confirm.removeSharedBody"), + () => removeShared.mutate(job.id) + ) + } + title={t("downloads.actions.removeShared")} + danger + > + + + + ))} + {sharedQ.data && sharedQ.data.length === 0 && ( +
{t("downloads.empty.shared")}
+ )} +
+ )} + + {tab === "system" && me.role === "admin" && } + + {addSource && ( + { + setAddSource(null); + setAddUrl(""); + }} + /> + )} + {manage && setManage(false)} />} + {renameJob && setRenameJob(null)} />} + {shareJob && setShareJob(null)} />} + {editJob && setEditJob(null)} />} +
+ ); +} diff --git a/frontend/src/components/DownloadDialog.tsx b/frontend/src/components/DownloadDialog.tsx new file mode 100644 index 0000000..1f27474 --- /dev/null +++ b/frontend/src/components/DownloadDialog.tsx @@ -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(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 ( + + {title &&

{title}

} + + + + + + + setName(e.target.value)} + placeholder={t("downloads.dialog.namePlaceholder")} + className={`${inputCls} mb-5`} + /> + +
+ + +
+ + {manage && ( + { + setManage(false); + profilesQ.refetch(); + }} + /> + )} +
+ ); +} diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 2dc8024..2967d18 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -2,6 +2,8 @@ import { useTranslation } from "react-i18next"; import { Search, X, Youtube } from "lucide-react"; import type { FeedFilters, Me } from "../lib/api"; import type { Page } from "../lib/urlState"; +import { pageTitleKey } from "../lib/pageMeta"; +import PageTitle from "./PageTitle"; // Contextual top bar over the content column. Primary navigation, account and the per-user sync // status live in the left NavSidebar; the feed's scope toggle now lives in the filter sidebar. @@ -72,24 +74,8 @@ export default function Header({ )}
) : ( -
- {page === "stats" - ? t("header.usageStats") - : page === "playlists" - ? t("header.account.playlists") - : page === "settings" - ? t("settings.title") - : page === "scheduler" - ? t("header.scheduler") - : page === "config" - ? t("header.configuration") - : page === "users" - ? t("header.users") - : page === "notifications" - ? t("inbox.navLabel") - : page === "messages" - ? t("messages.navLabel") - : t("header.channelManager")} +
+
)} diff --git a/frontend/src/components/NavSidebar.tsx b/frontend/src/components/NavSidebar.tsx index db5dde3..965d6be 100644 --- a/frontend/src/components/NavSidebar.tsx +++ b/frontend/src/components/NavSidebar.tsx @@ -8,6 +8,7 @@ import { Bell, ChevronLeft, ChevronRight, + Download, Home, Info, ListVideo, @@ -147,6 +148,13 @@ export default function NavSidebar({ { intervalMs: 30000, enabled: !me.is_demo } ); 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 }; // 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 ? [] : [{ 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). { page: "stats", icon: BarChart3, label: t("header.account.stats") }, ]; @@ -243,8 +256,9 @@ export default function NavSidebar({ {!collapsed && ( diff --git a/frontend/src/components/PageTitle.tsx b/frontend/src/components/PageTitle.tsx new file mode 100644 index 0000000..1ea61ae --- /dev/null +++ b/frontend/src/components/PageTitle.tsx @@ -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 ( + + + {label} + + ); +} diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index 973ada7..f8456d9 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -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 Avatar from "./Avatar"; import AddToPlaylist from "./AddToPlaylist"; +import DownloadButton from "./DownloadButton"; import { api, type Video } from "../lib/api"; import { channelYouTubeUrl, formatDuration, formatViews, relativeTime } from "../lib/format"; 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" /> )} + {!navigated && ( + + )} {!navigated && ( + +
+ ))} +
+ + {!formOpen ? ( + + ) : ( +
+
+ + setName(e.target.value)} placeholder={t("downloads.profiles.namePlaceholder")} className={inputCls} /> +
+
+
+ + +
+ {spec.mode !== "a" ? ( +
+ + +
+ ) : ( +
+ + +
+ )} + {spec.mode !== "a" && ( + <> +
+ + +
+
+ + +
+ + )} +
+
+ {spec.mode !== "a" && ( + patch({ embed_subs: v })} /> + )} + patch({ embed_chapters: v })} /> + patch({ embed_thumbnail: v })} /> + patch({ sponsorblock: v })} /> +
+
+ + +
+
+ )} + + ); +} diff --git a/frontend/src/components/Scheduler.tsx b/frontend/src/components/Scheduler.tsx index ba13bfe..92aadf6 100644 --- a/frontend/src/components/Scheduler.tsx +++ b/frontend/src/components/Scheduler.tsx @@ -142,7 +142,7 @@ function JobRow({
- + {t(`scheduler.jobs.${job.id}`, job.id)} diff --git a/frontend/src/components/ShareDialog.tsx b/frontend/src/components/ShareDialog.tsx new file mode 100644 index 0000000..6bacfd1 --- /dev/null +++ b/frontend/src/components/ShareDialog.tsx @@ -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 ( +
+
+ {t("downloads.share.userSection")} +
+

{t("downloads.share.userHint")}

+
+ { + 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 && ( +
+ {filtered.map((u) => ( + + ))} +
+ )} +
+ {recipients.data && list.length === 0 && ( +

{t("downloads.share.noUsers")}

+ )} +
+ ); +} + +// --- 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 ( +
+
+ + + + +
+
+ {t("downloads.share.views", { n: link.view_count })} + {badges.map((b) => ( + + {b === t("downloads.share.hasPassword") && } + {b} + + ))} +
+ +
+
+ ); +} + +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(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 ( +
+
+ {t("downloads.share.linkSection")} +
+

{t("downloads.share.linkHint")}

+ +
+ {(links.data ?? []).map((l) => ( + + ))} + {links.data && links.data.length === 0 && !creating && ( +

{t("downloads.share.noLinks")}

+ )} +
+ + {creating ? ( +
+ +
+ {t("downloads.share.expiry")}: + {EXPIRY_DAYS.map((d) => ( + + ))} +
+ setPassword(e.target.value)} + placeholder={t("downloads.share.password")} + className={inputCls} + autoComplete="new-password" + /> +
+ + +
+
+ ) : ( + + )} +
+ ); +} + +export default function ShareDialog({ job, onClose }: { job: DownloadJob; onClose: () => void }) { + const { t } = useTranslation(); + return ( + +
+ +
+ +
+ + ); +} diff --git a/frontend/src/components/SyncStatus.tsx b/frontend/src/components/SyncStatus.tsx index 435d70d..c3d7bcf 100644 --- a/frontend/src/components/SyncStatus.tsx +++ b/frontend/src/components/SyncStatus.tsx @@ -128,19 +128,29 @@ export default function SyncStatus({ - {showMain &&
{stateNode}
} - {notFull > 0 && ( - - - + {/* 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 && ( +
+ {stateNode} + {pauseBtn} +
+ )} + {notFull > 0 && ( +
+ + + + {!showMain && pauseBtn} +
)} - {pauseBtn &&
{pauseBtn}
}
); } diff --git a/frontend/src/components/Tooltip.tsx b/frontend/src/components/Tooltip.tsx index 3c77373..3d2c68e 100644 --- a/frontend/src/components/Tooltip.tsx +++ b/frontend/src/components/Tooltip.tsx @@ -1,10 +1,13 @@ -import { useRef, useState, useSyncExternalStore } from "react"; +import { useLayoutEffect, useRef, useState, useSyncExternalStore } from "react"; import { createPortal } from "react-dom"; import { hintsEnabled, subscribeHints } from "../lib/hints"; type Side = "top" | "bottom"; type Coords = { left: number; top: number; placement: Side }; +const MARGIN = 8; +const MAX_HALF = 120; // half of the tooltip's max-w-[240px] — the widest it can get + /** Wrap any element to show a short glass hint caption on hover — but only while the * app-wide hints toggle (Settings → Appearance) is on. Rendered in a portal with * fixed positioning so it is never clipped by an overflow/stacking ancestor. */ @@ -19,6 +22,7 @@ export default function Tooltip({ }) { const enabled = useSyncExternalStore(subscribeHints, hintsEnabled, hintsEnabled); const ref = useRef(null); + const tipRef = useRef(null); const [coords, setCoords] = useState(null); function show() { @@ -27,8 +31,12 @@ export default function Tooltip({ const r = el.getBoundingClientRect(); // Prefer the requested side; flip to bottom if there's no room above. const placement: Side = side === "bottom" || r.top < 90 ? "bottom" : "top"; + const center = r.left + r.width / 2; + // Clamp using the MAX half-width so the caption can never overflow the viewport, even on the + // first paint (before we know its real width). A left-edge anchor near x=0 would otherwise push + // the centered box off-screen. The layout effect below refines this to the actual width. setCoords({ - left: Math.min(Math.max(r.left + r.width / 2, 92), window.innerWidth - 92), + left: Math.min(Math.max(center, MAX_HALF + MARGIN), window.innerWidth - MAX_HALF - MARGIN), top: placement === "top" ? r.top - 8 : r.bottom + 8, placement, }); @@ -37,6 +45,21 @@ export default function Tooltip({ setCoords(null); } + // Once rendered, re-center on the anchor using the caption's ACTUAL width (a short hint doesn't + // need the full 240px reservation), still clamped inside the viewport. + useLayoutEffect(() => { + const tip = tipRef.current; + const el = ref.current; + if (!coords || !tip || !el) return; + const r = el.getBoundingClientRect(); + const half = tip.offsetWidth / 2; + const left = Math.min( + Math.max(r.left + r.width / 2, half + MARGIN), + window.innerWidth - half - MARGIN + ); + if (Math.abs(left - coords.left) > 0.5) setCoords((c) => (c ? { ...c, left } : c)); + }, [coords]); + if (!enabled || !hint) return <>{children}; return ( @@ -52,6 +75,7 @@ export default function Tooltip({ {coords && createPortal(
+ +
{tc(current)} / {tc(duration)}
+
+ +
+ + {/* timeline: filmstrip + segments (keep/drop tint) + markers + playhead + hover preview */} +
+
{ + setDrag({ type: "seek" }); + seek(timeAt(e.clientX)); + }} + onPointerMove={(e) => setHoverX(e.clientX)} + onPointerLeave={() => setHoverX(null)} + > + {/* aspect-correct filmstrip */} + {stripCells > 0 && ( +
+ {Array.from({ length: stripCells }).map((_, i) => ( +
+ ))} +
+ )} + {/* segment tint: dropped = dimmed + hatched */} + {segments.map((s, i) => ( +
setSelId(s.id)} + title={s.keep ? t("editor.keptSeg") : t("editor.droppedSeg")} + > + {/* keep/drop toggle */} + +
+ ))} + {/* draggable boundary markers */} + {boundaries.map((b, bi) => ( +
+
{ + e.stopPropagation(); + setDrag({ type: "boundary", bi }); + }} + /> + +
+ ))} + {/* playhead */} +
+
+ + {/* hover-scrub thumbnail */} + {strip && hoverTime != null && ( +
+
+
{tc(hoverTime)}
+
+ )} +
+ + {/* selected-segment editor */} + {sel && ( +
+ {t("editor.segment", { n: selIdx + 1 })} + +
+ + + {tc(sel.end - sel.start)} +
+ )} + + {/* output mode + summary */} +
+ {t("editor.output.label")}: + {(["separate", "join"] as const).map((m) => ( + + ))} +
+ + {t("editor.keptSummary", { n: kept.length, dur: tc(keptDur) })} + +
+ + {/* crop + precision */} +
+ + {!cropOn ? ( +
+ {(["accurate", "fast"] as const).map((m) => { + const on = (m === "accurate") === accurate; + return ( + + ); + })} +
+ ) : ( +
{t("editor.cropReencode")}
+ )} +
+ +
+ + setName(e.target.value)} placeholder={defaultName} className={inputCls} /> +
+ +
+ {reencode ? t("editor.willReencode") : t("editor.willCopy")} +
+ + +
+
+
+ + ); +} diff --git a/frontend/src/components/WatchPage.tsx b/frontend/src/components/WatchPage.tsx new file mode 100644 index 0000000..ecec257 --- /dev/null +++ b/frontend/src/components/WatchPage.tsx @@ -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/ 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(null); + const [password, setPassword] = useState(""); + const [error, setError] = useState(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 ( +
+
+
+ + Siftlode +
+ + {status === "loading" &&
{T.loading}
} + + {status === "gone" && ( +
+
+
{T.gone}
+
{T.goneHint}
+
+
+ )} + + {status === "password" && ( +
+
+
+ {T.passwordTitle} +
+ 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 &&
{error}
} + +
+
+ )} + + {status === "ready" && meta && ( +
+
+
+
+
+

{meta.title || "Video"}

+ {meta.uploader &&
{meta.uploader}
} +
+ {meta.allow_download && ( + + {T.download} + + )} +
+
+ )} +
+
{T.brand}
+
+ ); +} diff --git a/frontend/src/i18n/locales/de/downloads.json b/frontend/src/i18n/locales/de/downloads.json new file mode 100644 index 0000000..0054699 --- /dev/null +++ b/frontend/src/i18n/locales/de/downloads.json @@ -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" +} diff --git a/frontend/src/i18n/locales/de/editor.json b/frontend/src/i18n/locales/de/editor.json new file mode 100644 index 0000000..e82903c --- /dev/null +++ b/frontend/src/i18n/locales/de/editor.json @@ -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" +} diff --git a/frontend/src/i18n/locales/de/scheduler.json b/frontend/src/i18n/locales/de/scheduler.json index 8c68143..4fca278 100644 --- a/frontend/src/i18n/locales/de/scheduler.json +++ b/frontend/src/i18n/locales/de/scheduler.json @@ -67,7 +67,8 @@ "subscriptions": "Importiert die YouTube-Abos jedes Nutzers neu. Fällt er aus, werden auf YouTube hinzugefügte oder entfernte Abos hier nicht übernommen.", "playlist_sync": "Spiegelt die YouTube-Wiedergabelisten jedes Nutzers in die App. Fällt er aus, erscheinen Änderungen an deinen YouTube-Listen lokal nicht.", "maintenance": "Findet Videos, die nicht mehr abspielbar sind (gelöscht, auf privat gesetzt, abgebrochene Premieren), blendet sie aus dem Feed aus und entfernt sie nach einer Schonfrist; prüft die am längsten nicht geprüften Videos erneut. Fällt er aus, bleiben tote Videos im Katalog.", - "explore_cleanup": "Entfernt Kanäle, die du erkundet, aber nie abonniert hast (und ihre Videos), nach einer Karenzzeit, damit Neugier-Stöbern den Katalog nicht überfüllt. Wenn es stoppt, bleiben verlassene erkundete Kanäle bestehen." + "explore_cleanup": "Entfernt Kanäle, die du erkundet, aber nie abonniert hast (und ihre Videos), nach einer Karenzzeit, damit Neugier-Stöbern den Katalog nicht überfüllt. Wenn es stoppt, bleiben verlassene erkundete Kanäle bestehen.", + "download_gc": "Löscht heruntergeladene Dateien nach Ablauf der Aufbewahrungsfrist, gibt nicht mehr belegten Speicher frei und warnt vor dem Ablauf einer geteilten Datei. Fällt er aus, häufen sich alte Downloads und Speicherplatz wird nicht freigegeben." }, "jobs": { "rss_poll": "RSS-Abfrage (neue Uploads)", @@ -79,7 +80,8 @@ "playlist_sync": "YouTube-Wiedergabelisten-Sync", "maintenance": "Wartung + Validierung", "demo_reset": "Demo-Konto-Reset", - "explore_cleanup": "Bereinigung erkundeter Kanäle" + "explore_cleanup": "Bereinigung erkundeter Kanäle", + "download_gc": "Download-Bereinigung" }, "queue": { "recentPending": "Zu synchronisierende Kanäle", diff --git a/frontend/src/i18n/locales/en/downloads.json b/frontend/src/i18n/locales/en/downloads.json new file mode 100644 index 0000000..06db1f6 --- /dev/null +++ b/frontend/src/i18n/locales/en/downloads.json @@ -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" +} diff --git a/frontend/src/i18n/locales/en/editor.json b/frontend/src/i18n/locales/en/editor.json new file mode 100644 index 0000000..2429f0a --- /dev/null +++ b/frontend/src/i18n/locales/en/editor.json @@ -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" +} diff --git a/frontend/src/i18n/locales/en/scheduler.json b/frontend/src/i18n/locales/en/scheduler.json index 9106858..c8c8fce 100644 --- a/frontend/src/i18n/locales/en/scheduler.json +++ b/frontend/src/i18n/locales/en/scheduler.json @@ -67,7 +67,8 @@ "subscriptions": "Re-imports every user's YouTube subscriptions. If it stops, channels subscribed to or dropped on YouTube aren't reflected here.", "playlist_sync": "Mirrors each user's YouTube playlists into the app. If it stops, changes to your YouTube playlists don't show up locally.", "maintenance": "Finds videos that can no longer be played (deleted, made private, abandoned premieres), hides them from the feed and removes them after a grace period; re-checks the least-recently-checked videos. If it stops, dead videos linger in the catalogue.", - "explore_cleanup": "Removes channels you explored but never subscribed to (and their videos) after a grace period, so curiosity browsing doesn't pile up in the catalogue. If it stops, abandoned explored channels linger." + "explore_cleanup": "Removes channels you explored but never subscribed to (and their videos) after a grace period, so curiosity browsing doesn't pile up in the catalogue. If it stops, abandoned explored channels linger.", + "download_gc": "Deletes downloaded files past their retention window, reclaims space no one is holding anymore, and warns owners before a shared file expires. If it stops, old downloads pile up and disk space isn't freed." }, "jobs": { "rss_poll": "RSS poll (new uploads)", @@ -79,7 +80,8 @@ "playlist_sync": "YouTube playlist sync", "maintenance": "Maintenance + validation", "demo_reset": "Demo account reset", - "explore_cleanup": "Explored-channel cleanup" + "explore_cleanup": "Explored-channel cleanup", + "download_gc": "Download cleanup" }, "queue": { "recentPending": "Channels to sync", diff --git a/frontend/src/i18n/locales/hu/downloads.json b/frontend/src/i18n/locales/hu/downloads.json new file mode 100644 index 0000000..4091429 --- /dev/null +++ b/frontend/src/i18n/locales/hu/downloads.json @@ -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" +} diff --git a/frontend/src/i18n/locales/hu/editor.json b/frontend/src/i18n/locales/hu/editor.json new file mode 100644 index 0000000..2030538 --- /dev/null +++ b/frontend/src/i18n/locales/hu/editor.json @@ -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" +} diff --git a/frontend/src/i18n/locales/hu/scheduler.json b/frontend/src/i18n/locales/hu/scheduler.json index dc90f61..ba01c02 100644 --- a/frontend/src/i18n/locales/hu/scheduler.json +++ b/frontend/src/i18n/locales/hu/scheduler.json @@ -67,7 +67,8 @@ "subscriptions": "Újraimportálja minden felhasználó YouTube-feliratkozásait. Ha leáll, a YouTube-on felvett vagy törölt feliratkozások nem tükröződnek itt.", "playlist_sync": "Tükrözi a felhasználók YouTube lejátszási listáit az appba. Ha leáll, a YouTube-listák változásai nem jelennek meg lokálisan.", "maintenance": "Megkeresi a már sehol le nem játszható videókat (törölt, priváttá tett, elhagyott premier), elrejti őket a hírfolyamból, és türelmi idő után eltávolítja; újraellenőrzi a legrégebben ellenőrzött videókat. Ha leáll, a halott videók a katalógusban maradnak.", - "explore_cleanup": "A türelmi idő után eltávolítja a felfedezett, de nem követett csatornákat (és videóikat), hogy a kíváncsiság-böngészés ne halmozódjon a katalógusban. Ha leáll, az elhagyott felfedezett csatornák megmaradnak." + "explore_cleanup": "A türelmi idő után eltávolítja a felfedezett, de nem követett csatornákat (és videóikat), hogy a kíváncsiság-böngészés ne halmozódjon a katalógusban. Ha leáll, az elhagyott felfedezett csatornák megmaradnak.", + "download_gc": "Törli a megőrzési időn túli letöltéseket, felszabadítja a már senki által nem használt helyet, és a lejárat előtt figyelmezteti a tulajdonost. Ha leáll, a régi letöltések felhalmozódnak, és a tárhely nem szabadul fel." }, "jobs": { "rss_poll": "RSS-lekérdezés (új feltöltések)", @@ -79,7 +80,8 @@ "playlist_sync": "YouTube lejátszási listák szinkronja", "maintenance": "Karbantartás + ellenőrzés", "demo_reset": "Demo fiók visszaállítása", - "explore_cleanup": "Felfedezett csatornák takarítása" + "explore_cleanup": "Felfedezett csatornák takarítása", + "download_gc": "Letöltések takarítása" }, "queue": { "recentPending": "Szinkronra váró csatorna", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 94d297f..0e60943 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -623,6 +623,137 @@ export interface AdminUserRow { 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/" — 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 = { me: (): Promise => req("/api/me"), accounts: (): Promise => req("/api/me/accounts"), @@ -914,6 +1045,97 @@ export const api = { updateTag: (id: number, patch: { name?: string; color?: string }) => req(`/api/tags/${id}`, { method: "PATCH", body: JSON.stringify(patch) }), deleteTag: (id: number) => req(`/api/tags/${id}`, { method: "DELETE" }), + + // --- download center --- + downloadProfiles: (): Promise => req("/api/downloads/profiles"), + createDownloadProfile: (p: { name: string; spec: DownloadSpec }): Promise => + req("/api/downloads/profiles", { method: "POST", body: JSON.stringify(p) }), + updateDownloadProfile: ( + id: number, + patch: { name?: string; spec?: DownloadSpec } + ): Promise => + 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 => + req("/api/downloads", { method: "POST", body: JSON.stringify(body) }), + enqueueEdit: (body: { + source_job_id: number; + edit_spec: EditSpec; + display_name?: string; + }): Promise => + req("/api/downloads/edit", { method: "POST", body: JSON.stringify(body) }), + downloadStoryboard: (id: number): Promise => + req(`/api/downloads/${id}/storyboard`), + downloads: (): Promise => req("/api/downloads"), + downloadsShared: (): Promise => 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 => req("/api/downloads/usage"), + downloadIndex: (): Promise> => req("/api/downloads/index"), + pauseDownload: (id: number): Promise => + req(`/api/downloads/${id}/pause`, { method: "POST" }, { idempotent: true }), + resumeDownload: (id: number): Promise => + req(`/api/downloads/${id}/resume`, { method: "POST" }, { idempotent: true }), + cancelDownload: (id: number): Promise => + 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 => + 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 => req("/api/downloads/recipients"), + // Public watch links (share by link). + downloadLinks: (jobId: number): Promise => req(`/api/downloads/${jobId}/links`), + createDownloadLink: ( + jobId: number, + body: { allow_download?: boolean; expires_days?: number | null; password?: string } + ): Promise => + 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 => + 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 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 /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 => req("/api/admin/downloads"), + adminDownloadStorage: (): Promise => req("/api/admin/downloads/storage"), + adminGetDownloadQuota: (userId: number): Promise => + req(`/api/admin/downloads/quota/${userId}`), + adminSetDownloadQuota: ( + userId: number, + patch: Partial> + ): Promise => + req(`/api/admin/downloads/quota/${userId}`, { method: "PUT", body: JSON.stringify(patch) }), + adminResetDownloadQuota: (userId: number): Promise => + req(`/api/admin/downloads/quota/${userId}`, { method: "DELETE" }), }; export { HttpError }; diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts index e0bb0d4..04b7700 100644 --- a/frontend/src/lib/format.ts +++ b/frontend/src/lib/format.ts @@ -5,6 +5,26 @@ export function relativeTime(iso: string | null): string { 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 * client-side notifications whose timestamps are already numbers). One implementation, one * set of `time.*` strings — shared instead of re-implemented per component. */ diff --git a/frontend/src/lib/nav.ts b/frontend/src/lib/nav.ts new file mode 100644 index 0000000..e0bb440 --- /dev/null +++ b/frontend/src/lib/nav.ts @@ -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); +} diff --git a/frontend/src/lib/pageMeta.ts b/frontend/src/lib/pageMeta.ts new file mode 100644 index 0000000..19dd9d9 --- /dev/null +++ b/frontend/src/lib/pageMeta.ts @@ -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"; + } +} diff --git a/frontend/src/lib/releaseNotes.ts b/frontend/src/lib/releaseNotes.ts index 5d5d5eb..abf8efa 100644 --- a/frontend/src/lib/releaseNotes.ts +++ b/frontend/src/lib/releaseNotes.ts @@ -14,6 +14,20 @@ export interface ReleaseEntry { } export const RELEASE_NOTES: ReleaseEntry[] = [ + { + version: "0.22.0", + date: "2026-07-04", + summary: "The Download Center — save videos to the server, edit them into clips, and share them.", + features: [ + "Download Center: save any video (or a search result) to the server with yt-dlp, laid out Plex-style with a poster + info file. Choose a built-in format preset or make your own, keep an eye on your per-user storage quota, and save the finished file to your device.", + "Built-in video editor: trim, crop, and cut a download into segments — keep the parts you want as separate clips or join them into one file. Pick a precise (frame-accurate) or fast cut, with a scrub timeline and a hover preview.", + "Sharing: share a download with another user (it appears in their “Shared with me”, where they can edit their own copy or remove it), or hand out a public watch link that plays on a clean, login-free page — with an optional password, an expiry, and a stream-only vs downloadable toggle.", + "Tidier video titles across the feed, search and downloads — clickbait ALL-CAPS and trailing hashtag clutter are cleaned up for display (the original title is kept underneath).", + ], + fixes: [ + "The browser tab now carries an app icon and shows the section you're on; clicking the Siftlode logo returns you to the feed.", + ], + }, { version: "0.21.0", date: "2026-07-02", diff --git a/frontend/src/lib/urlState.ts b/frontend/src/lib/urlState.ts index 36b0e11..9265993 100644 --- a/frontend/src/lib/urlState.ts +++ b/frontend/src/lib/urlState.ts @@ -100,6 +100,7 @@ export const PAGES = [ "users", "notifications", "messages", + "downloads", ] as const; export type Page = (typeof PAGES)[number]; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 88bb2c6..8923e7d 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -6,6 +6,7 @@ import ErrorBoundary from "./components/ErrorBoundary"; import { ConfirmProvider } from "./components/ConfirmProvider"; import PrivacyPolicy from "./components/legal/PrivacyPolicy"; import Terms from "./components/legal/Terms"; +import WatchPage from "./components/WatchPage"; import "./i18n"; import "./index.css"; @@ -19,7 +20,8 @@ const queryClient = new QueryClient({ const path = window.location.pathname; const root = path === "/privacy" ? : - path === "/terms" ? : ( + path === "/terms" ? : + path.startsWith("/watch/") ? : ( diff --git a/install.ps1 b/install.ps1 index bd2f2e9..c3069de 100644 --- a/install.ps1 +++ b/install.ps1 @@ -28,6 +28,10 @@ POSTGRES_PASSWORD=$pgPass SECRET_KEY=$secretKey TOKEN_ENCRYPTION_KEY=$fernet OAUTH_REDIRECT_URL=$url/auth/callback + +# Optional: store Download Center media in a host folder (e.g. one your Plex server reads) instead +# of a Docker volume. Must be writable by uid 1000 (chown -R 1000:1000 ). +# DOWNLOAD_HOST_PATH=/mnt/media/youtube "@ | Set-Content -Path .env -Encoding ascii Write-Host "Wrote .env (secrets generated)." } diff --git a/install.sh b/install.sh index f9e3773..c6ad65b 100755 --- a/install.sh +++ b/install.sh @@ -26,6 +26,10 @@ POSTGRES_PASSWORD=$POSTGRES_PASSWORD SECRET_KEY=$SECRET_KEY TOKEN_ENCRYPTION_KEY=$TOKEN_ENCRYPTION_KEY OAUTH_REDIRECT_URL=$URL/auth/callback + +# Optional: store Download Center media in a host folder (e.g. one your Plex server reads) instead +# of a Docker volume. Must be writable by uid 1000 (sudo chown -R 1000:1000 ). +# DOWNLOAD_HOST_PATH=/mnt/media/youtube EOF chmod 600 .env echo "Wrote .env (secrets generated)."