From 2138f3c357a02ae55731a08ed0e309830e13e498 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 5 Jul 2026 06:58:34 +0200 Subject: [PATCH 1/3] feat(plex): Test-connection media mount probe Extend admin POST /api/plex/test to resolve one real media file through the local mount (a mirrored sample, else a single leaf fetched from Plex) and report whether it is readable. A missing media bind-mount or a wrong plex_path_map is now caught at Test time rather than only surfacing later as a dead player. --- backend/app/routes/plex.py | 40 +++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/backend/app/routes/plex.py b/backend/app/routes/plex.py index b16d78b..7a761f2 100644 --- a/backend/app/routes/plex.py +++ b/backend/app/routes/plex.py @@ -37,11 +37,14 @@ _TS_CONFIG = "public.unaccent_simple" @router.post("/test") def test_connection(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict: """Admin: verify the configured Plex server URL + token, returning the server name and the - movie/show sections (for the config library-picker).""" + movie/show sections (for the config library-picker). Also probes ONE real media file through + the local mount, so a missing bind-mount / wrong path-map is caught here rather than only + surfacing as a dead player later.""" try: with PlexClient(db) as plex: info = plex.server_info() sections = plex.sections() + media_check = _media_check(db, plex, sections) except PlexNotConfigured as e: raise HTTPException(status_code=400, detail=str(e)) except PlexError as e: @@ -55,9 +58,44 @@ def test_connection(_: User = Depends(admin_user), db: Session = Depends(get_db) for s in sections if s.get("type") in ("movie", "show") ], + "media_check": media_check, } +def _sample_plex_path(db: Session, plex: PlexClient, sections: list[dict]) -> str | None: + """A real Plex-side media file path to validate the mount + path-map against. Prefers the + already-mirrored catalog (cheap); before the first sync, pulls one movie leaf from an enabled + section straight from Plex.""" + row = db.query(PlexItem.file_path).filter(PlexItem.file_path.isnot(None)).first() + if row and row[0]: + return row[0] + wanted = plex_sync._enabled_section_keys(db) + for s in sections: + if s.get("type") != "movie": + continue + key = str(s.get("key")) + if wanted is not None and key not in wanted: + continue + items, _ = plex.section_items(key, 1, 0, 1) + if items: + fp = plex_sync._media_facts(items[0]).get("file_path") + if fp: + return fp + return None + + +def _media_check(db: Session, plex: PlexClient, sections: list[dict]) -> dict: + """Resolve a sample Plex file path to its local mount path and confirm it's readable, so the + admin sees at Test time whether the physical media is actually reachable (bind-mount + + path-map correct). ``checked=False`` when no sample is available (empty library).""" + plex_path = _sample_plex_path(db, plex, sections) + if not plex_path: + return {"checked": False} + local_path = plex_paths.map_path(db, plex_path) + ok = plex_paths.local_media_path(db, plex_path) is not None + return {"checked": True, "ok": ok, "plex_path": plex_path, "local_path": local_path} + + @router.post("/sync") def trigger_sync(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict: """Admin: mirror the enabled Plex sections into the local catalog. Synchronous (can take a From fdd39ef3e499ecb239019400cfec57fcd9505b73 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 5 Jul 2026 06:58:34 +0200 Subject: [PATCH 2/3] feat(plex): surface playback errors + media-mount status in the UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PlexPlayer: when the stream session can't start, show why instead of an eternal spinner — 404 (physical file unreachable: missing media mount / wrong path map), 501 (format needs transcoding), or a generic failure; also catch fatal hls.js errors. ConfigPanel: render the Test-connection media_check (mount OK, or a warning naming the unreadable local path). New plex/config i18n in en/hu/de. --- frontend/src/components/ConfigPanel.tsx | 8 +++++++ frontend/src/components/PlexPlayer.tsx | 29 ++++++++++++++++++++---- frontend/src/i18n/locales/de/config.json | 2 ++ frontend/src/i18n/locales/de/plex.json | 5 +++- frontend/src/i18n/locales/en/config.json | 2 ++ frontend/src/i18n/locales/en/plex.json | 5 +++- frontend/src/i18n/locales/hu/config.json | 2 ++ frontend/src/i18n/locales/hu/plex.json | 5 +++- frontend/src/lib/api.ts | 8 +++++++ 9 files changed, 59 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/ConfigPanel.tsx b/frontend/src/components/ConfigPanel.tsx index 2198268..70f2b3c 100644 --- a/frontend/src/components/ConfigPanel.tsx +++ b/frontend/src/components/ConfigPanel.tsx @@ -194,6 +194,14 @@ export default function ConfigPanel() { version: plexResult.version ?? "", })}

+ {plexResult.media_check?.checked && + (plexResult.media_check.ok ? ( +

{t("config.plexMediaOk")}

+ ) : ( +

+ {t("config.plexMediaMissing", { path: plexResult.media_check.local_path ?? "" })} +

+ ))}

{t("config.plexPickLibraries")}

{plexResult.sections.map((s) => ( diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx index 083f28a..9ecbfe6 100644 --- a/frontend/src/components/PlexPlayer.tsx +++ b/frontend/src/components/PlexPlayer.tsx @@ -56,6 +56,7 @@ export default function PlexPlayer({ itemId, onClose }: Props) { const [muted, setMuted] = useState(false); const [fs, setFs] = useState(false); const [ready, setReady] = useState(false); + const [loadError, setLoadError] = useState(null); const [uiVisible, setUiVisible] = useState(true); const hideTimer = useRef(null); // Selected audio / subtitle stream ordinals (null audio = default first; null subtitle = off). @@ -83,10 +84,21 @@ export default function PlexPlayer({ itemId, onClose }: Props) { const video = videoRef.current; if (!video) return; setReady(false); + setLoadError(null); let sess; try { sess = await api.plexSession(id, startAt, audioRef.current, subRef.current); - } catch { + } catch (e: any) { + // Surface WHY playback can't start instead of an eternal spinner. 404 = the physical file + // isn't reachable (missing media bind-mount or a wrong plex_path_map); 501 = the codec + // needs full transcoding (a later phase). + setLoadError( + e?.status === 501 + ? t("plex.player.errTranscode") + : e?.status === 404 + ? t("plex.player.errNotFound") + : t("plex.player.errGeneric"), + ); return; } sessionStartRef.current = sess.start_s; @@ -112,6 +124,11 @@ export default function PlexPlayer({ itemId, onClose }: Props) { setReady(true); video.play().catch(() => {}); }); + // A fatal HLS error (e.g. the remux session died / the file became unreadable) would + // otherwise leave the spinner up forever — surface it instead. + hls.on(Hls.Events.ERROR, (_e, data) => { + if (data.fatal) setLoadError(t("plex.player.errGeneric")); + }); } else { // direct file (or Safari native HLS) video.src = sess.url; @@ -125,7 +142,7 @@ export default function PlexPlayer({ itemId, onClose }: Props) { video.addEventListener("loadedmetadata", onMeta); } }, - [id], + [id, t], ); // Start playback when the detail arrives (resume from the saved position unless already watched). @@ -407,8 +424,12 @@ export default function PlexPlayer({ itemId, onClose }: Props) { /> {!ready && ( -
- {t("plex.player.loading")} +
+ {loadError ? ( + {loadError} + ) : ( + {t("plex.player.loading")} + )}
)} diff --git a/frontend/src/i18n/locales/de/config.json b/frontend/src/i18n/locales/de/config.json index 21c34bf..ee35d7f 100644 --- a/frontend/src/i18n/locales/de/config.json +++ b/frontend/src/i18n/locales/de/config.json @@ -194,6 +194,8 @@ "plexTestOk": "Verbunden mit {{name}}", "plexTestFailed": "Plex-Verbindung fehlgeschlagen — prüfe URL und Token", "plexConnected": "Verbunden mit {{name}} {{version}}", + "plexMediaOk": "Medieneinbindung OK — eine Beispieldatei wurde auf der Festplatte gefunden.", + "plexMediaMissing": "Medien unter {{path}} nicht lesbar — prüfe, ob die Plex-Medien in den Container eingebunden sind und die Pfadzuordnung stimmt. Die Wiedergabe schlägt fehl, bis dies behoben ist.", "plexPickLibraries": "Anzuzeigende Bibliotheken:", "plexPickHint": "Keine auszuwählen entspricht dem Anzeigen aller Bibliotheken. Nicht vergessen zu speichern.", "plexMovies": "Filme", diff --git a/frontend/src/i18n/locales/de/plex.json b/frontend/src/i18n/locales/de/plex.json index 89c013c..9a6ab69 100644 --- a/frontend/src/i18n/locales/de/plex.json +++ b/frontend/src/i18n/locales/de/plex.json @@ -47,7 +47,10 @@ "tracks": "Audio & Untertitel", "audio": "Audio", "subtitles": "Untertitel", - "subOff": "Aus" + "subOff": "Aus", + "errNotFound": "Diese Datei kann nicht abgespielt werden — die Medien sind auf dem Server nicht erreichbar (die Plex-Medieneinbindung fehlt oder die Pfadzuordnung ist falsch). Bitte den Administrator, die Plex-Konfiguration zu prüfen.", + "errTranscode": "Das Format dieser Datei erfordert eine Transkodierung, die noch nicht unterstützt wird.", + "errGeneric": "Die Wiedergabe konnte nicht gestartet werden. Bitte erneut versuchen." }, "playable": { "direct": "Spielt direkt im Browser", diff --git a/frontend/src/i18n/locales/en/config.json b/frontend/src/i18n/locales/en/config.json index dc4242a..ac00434 100644 --- a/frontend/src/i18n/locales/en/config.json +++ b/frontend/src/i18n/locales/en/config.json @@ -194,6 +194,8 @@ "plexTestOk": "Connected to {{name}}", "plexTestFailed": "Plex connection failed — check the URL and token", "plexConnected": "Connected to {{name}} {{version}}", + "plexMediaOk": "Media mount OK — a sample file was found on disk.", + "plexMediaMissing": "Media not readable at {{path}} — check that the Plex media is bind-mounted into the container and that the path map is correct. Playback will fail until this is fixed.", "plexPickLibraries": "Libraries to expose:", "plexPickHint": "Unchecking all is the same as exposing every library. Remember to Save.", "plexMovies": "Movies", diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json index 1570159..d99d03e 100644 --- a/frontend/src/i18n/locales/en/plex.json +++ b/frontend/src/i18n/locales/en/plex.json @@ -47,7 +47,10 @@ "tracks": "Audio & subtitles", "audio": "Audio", "subtitles": "Subtitles", - "subOff": "Off" + "subOff": "Off", + "errNotFound": "This file can't be played — the media isn't reachable on the server (the Plex media mount is missing or the path map is wrong). Ask the admin to check the Plex configuration.", + "errTranscode": "This file's format needs transcoding, which isn't supported yet.", + "errGeneric": "Playback couldn't start. Please try again." }, "playable": { "direct": "Plays directly in the browser", diff --git a/frontend/src/i18n/locales/hu/config.json b/frontend/src/i18n/locales/hu/config.json index 2bcee67..f4e165a 100644 --- a/frontend/src/i18n/locales/hu/config.json +++ b/frontend/src/i18n/locales/hu/config.json @@ -194,6 +194,8 @@ "plexTestOk": "Kapcsolódva: {{name}}", "plexTestFailed": "A Plex-kapcsolat sikertelen — ellenőrizd az URL-t és a tokent", "plexConnected": "Kapcsolódva: {{name}} {{version}}", + "plexMediaOk": "A media-mount rendben — egy mintafájl megtalálható a lemezen.", + "plexMediaMissing": "A média nem olvasható itt: {{path}} — ellenőrizd, hogy a Plex média be van-e mountolva a konténerbe, és hogy helyes-e az útvonal-leképezés. A lejátszás addig nem fog működni, amíg ez nincs javítva.", "plexPickLibraries": "Megjelenítendő könyvtárak:", "plexPickHint": "Ha egyiket sem jelölöd be, az az összes könyvtár megjelenítésével egyenértékű. Ne feledj menteni.", "plexMovies": "Filmek", diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json index 490e3ed..60c58ae 100644 --- a/frontend/src/i18n/locales/hu/plex.json +++ b/frontend/src/i18n/locales/hu/plex.json @@ -47,7 +47,10 @@ "tracks": "Audió és felirat", "audio": "Audió", "subtitles": "Felirat", - "subOff": "Ki" + "subOff": "Ki", + "errNotFound": "Ez a fájl nem játszható le — a média nem érhető el a szerveren (hiányzik a Plex media-mount, vagy hibás az útvonal-leképezés). Kérd meg az adminisztrátort, hogy ellenőrizze a Plex beállításait.", + "errTranscode": "Ennek a fájlnak a formátuma transzkódolást igényel, ami még nem támogatott.", + "errGeneric": "A lejátszást nem sikerült elindítani. Próbáld újra." }, "playable": { "direct": "Közvetlenül játszható a böngészőben", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 492da09..7a8df7d 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -622,6 +622,14 @@ export interface PlexTestResult { server_name: string; version?: string; sections: PlexSection[]; + // Whether a sample media file resolved + was readable through the local mount (bind-mount + + // path-map check). checked=false when the library is empty (nothing to probe yet). + media_check?: { + checked: boolean; + ok?: boolean; + plex_path?: string; + local_path?: string; + }; } // Plex browse/search/drill-down (the mirrored catalog rendered as feed-style cards). From 0b98548d4fde076f7fa4e852d17f163fd14078cf Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 5 Jul 2026 08:05:59 +0200 Subject: [PATCH 3/3] chore(devops): move prod deploy config out of the app repo The real docker-compose.home.yml + CLAUDE.md lived only in the working tree via a fragile .git/info/exclude hack (leak-prone). They now live in a separate PRIVATE devops repo (siftlode-ops). This repo keeps only a generic, placeholder-templated docker-compose.home.yml.example plus the existing .env.example (extended with the optional Plex keys), so nothing environment-specific can leak here. Exclude emptied. --- .env.example | 12 +++ docker-compose.home.yml.example | 175 ++++++++++++++++++++++++++++++++ 2 files changed, 187 insertions(+) create mode 100644 docker-compose.home.yml.example diff --git a/.env.example b/.env.example index 06725d1..cb17209 100644 --- a/.env.example +++ b/.env.example @@ -62,3 +62,15 @@ SCHEDULER_ENABLED=true # 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 + +# --- Plex integration (optional) --- +# The optional Plex module browses/plays your Plex library, playing the LOCAL physical file +# directly (Plex is used for metadata only). Enable it in the admin Config page (server URL, +# token, path map, libraries). For playback the container must be able to READ the media files, +# so bind-mount your Plex media into the container read-only via PLEX_MEDIA_HOST_PATH — it maps +# to /plex-media inside the container (the local side of the admin "path map", whose default is +# /data=/plex-media). Leave unset if you don't use Plex. +# PLEX_MEDIA_HOST_PATH=/mnt/media +# Optional: scratch dir for on-the-fly HLS remux segments. Defaults to DOWNLOAD_ROOT; point it at +# fast local storage if your download volume is slow. +# PLEX_HLS_DIR=/var/tmp/plex-hls diff --git a/docker-compose.home.yml.example b/docker-compose.home.yml.example new file mode 100644 index 0000000..69c40b8 --- /dev/null +++ b/docker-compose.home.yml.example @@ -0,0 +1,175 @@ +# Home deployment — the app + database run on the home server (a Proxmox LXC), and a small +# public VPS reverse-proxies the public hostname to it over a private WireGuard tunnel. This +# keeps the heavy work (full-catalog feed/count/facet scans) on strong, always-on home +# hardware while the VPS stays a thin, cheap public endpoint. +# +# Differences from docker-compose.prod.yml (the bare-VPS stack): runs as Docker-in-LXC +# (security_opt apparmor:unconfined), Postgres gets real RAM + tuning so the catalog stays +# hot in cache, and the api is published on the LXC (reachable only via the WireGuard peer / +# trusted LAN — never port-forwarded to the public internet; the VPS Caddy is the public TLS +# front door, so the OAuth redirect URL is unchanged). +# +# Deploy (from the build box, over the LAN): +# cd /docker && docker compose -f docker-compose.home.yml --env-file .env pull && \ +# docker compose -f docker-compose.home.yml --env-file .env up -d +# +# Required keys in /docker/.env: POSTGRES_PASSWORD, SECRET_KEY (>=32 chars), +# TOKEN_ENCRYPTION_KEY (Fernet), GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, +# OAUTH_REDIRECT_URL=https://siftlode.example.com/auth/callback, ALLOWED_EMAILS, ADMIN_EMAILS, +# YOUTUBE_API_KEY. APP_VERSION is exported by the deploy step (selects the image tag). +# Optional: DOWNLOAD_HOST_PATH (host dir for the download tree) and PLEX_MEDIA_HOST_PATH (host dir +# holding the Plex library, mounted read-only for the optional Plex module). + +name: siftlode + +services: + db: + image: postgres:16-alpine + environment: + POSTGRES_USER: ${POSTGRES_USER:-siftlode} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set in the env file} + POSTGRES_DB: ${POSTGRES_DB:-siftlode} + # Tuned for the home box (plenty of RAM): keep the ~350 MB catalog hot in shared_buffers, + # tell the planner about the large OS cache, give sorts headroom, and price random access + # like an SSD so index plans aren't penalised. (See the feed EXPLAIN analysis.) + command: + - postgres + - -c + - shared_buffers=1GB + - -c + - effective_cache_size=4GB + - -c + - work_mem=32MB + - -c + - maintenance_work_mem=256MB + - -c + - random_page_cost=1.1 + - -c + - max_parallel_workers_per_gather=2 + volumes: + - siftlode_pgdata:/var/lib/postgresql/data + networks: [internal] + mem_limit: 4g + cpus: 3.0 + security_opt: + - no-new-privileges:true + - apparmor:unconfined + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-siftlode} -d ${POSTGRES_DB:-siftlode}"] + interval: 10s + timeout: 5s + retries: 12 + restart: unless-stopped + + api: + # Pulled prebuilt image published by `siftlode publish`; the tag follows the released + # VERSION (the deploy step exports APP_VERSION). No build on the host. + image: registry.example.com/you/siftlode:${APP_VERSION:-latest} + env_file: + - .env + environment: + DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-siftlode}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-siftlode} + # The public instance owns the background scheduler (single writer). + SCHEDULER_ENABLED: "true" + # Download center: the API serves finished files + builds filmstrips (read-only root is fine — + # it only writes to the /downloads mount + tmpfs). The yt-dlp job loop runs in `worker` below. + DOWNLOAD_ROOT: /downloads + WORKER_ENABLED: "false" + depends_on: + db: + condition: service_healthy + networks: [internal] + ports: + # Published on the LXC. Reached by the VPS Caddy over the WireGuard tunnel + # (10.x.x.x:8080) and on the trusted home LAN. Never forwarded to the public internet. + - "8080:8000" + volumes: + # Downloaded media. Defaults to a Docker-managed named volume; set DOWNLOAD_HOST_PATH in .env + # to a host directory (e.g. one your Plex server reads) to keep the Plex-style tree there. + - ${DOWNLOAD_HOST_PATH:-siftlode_downloads}:/downloads + # Plex media, read-only. Only used when the optional Plex module is enabled: set + # PLEX_MEDIA_HOST_PATH in .env to the host directory holding the Plex library (the local side + # of plex_path_map, default /plex-media). Left unset → an empty named volume (harmless; the + # Plex module is off by default). On this home stack (LXC on the Plex host) it's a plain host + # bind-mount — no SMB (that's the localdev-only path). + - ${PLEX_MEDIA_HOST_PATH:-siftlode_plex_media}:/plex-media:ro + mem_limit: 1536m + cpus: 2.0 + pids_limit: 512 + security_opt: + - no-new-privileges:true + - apparmor:unconfined + cap_drop: + - ALL + read_only: true + tmpfs: + - /tmp + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/healthz').status==200 else 1)"] + interval: 15s + timeout: 5s + retries: 5 + start_period: 30s + restart: unless-stopped + + # Download worker: same image, runs the yt-dlp / ffmpeg job loop instead of the API. Shares the + # DB (job queue) and the downloads mount with the API. Not read-only (yt-dlp/deno/ffmpeg write to + # $HOME caches + the staging dir); heavier limits since it does the video encoding. + worker: + image: registry.example.com/you/siftlode:${APP_VERSION:-latest} + command: ["python", "-m", "app.worker"] + env_file: + - .env + environment: + DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-siftlode}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-siftlode} + # The API owns the scheduler; the worker must not also run it. + SCHEDULER_ENABLED: "false" + DOWNLOAD_ROOT: /downloads + WORKER_ENABLED: "true" + depends_on: + db: + condition: service_healthy + # Start only after the API is healthy — it applies the DB migrations on its own startup, + # so this guarantees the download tables exist before the worker touches them. + api: + condition: service_healthy + bgutil-pot: + condition: service_started + networks: [internal] + volumes: + - ${DOWNLOAD_HOST_PATH:-siftlode_downloads}:/downloads + # Plex media (read-only) — same mount as the api; used by Plex clip jobs (a later phase). + - ${PLEX_MEDIA_HOST_PATH:-siftlode_plex_media}:/plex-media:ro + mem_limit: 2g + cpus: 2.0 + pids_limit: 512 + security_opt: + - no-new-privileges:true + - apparmor:unconfined + cap_drop: + - ALL + tmpfs: + - /tmp + restart: unless-stopped + + # PO-token provider sidecar: mints YouTube Proof-of-Origin tokens on demand so the worker beats + # bot-detection + unlocks high-quality formats. The worker reaches it at http://bgutil-pot:4416 + # (config default). Pin the tag to the plugin version in requirements.txt. + bgutil-pot: + image: brainicism/bgutil-ytdlp-pot-provider:1.3.1 + init: true + networks: [internal] + mem_limit: 512m + security_opt: + - no-new-privileges:true + - apparmor:unconfined + restart: unless-stopped + +volumes: + siftlode_pgdata: + siftlode_downloads: + # Empty fallback for the Plex media mount when PLEX_MEDIA_HOST_PATH is unset (Plex module off). + siftlode_plex_media: + +networks: + internal: