Merge: devops repo split — public repo keeps only example configs

This commit is contained in:
npeter83 2026-07-05 08:06:27 +02:00
commit cb72106329
12 changed files with 285 additions and 8 deletions

View file

@ -62,3 +62,15 @@ SCHEDULER_ENABLED=true
# directory instead — e.g. one your Plex server can read — to keep the Plex-style tree there. # directory instead — e.g. one your Plex server can read — to keep the Plex-style tree there.
# The path must be writable by the container user (uid of `appuser`, 1000): chown 1000:1000 <dir>. # The path must be writable by the container user (uid of `appuser`, 1000): chown 1000:1000 <dir>.
# DOWNLOAD_HOST_PATH=/mnt/media/youtube # 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

View file

@ -37,11 +37,14 @@ _TS_CONFIG = "public.unaccent_simple"
@router.post("/test") @router.post("/test")
def test_connection(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict: 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 """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: try:
with PlexClient(db) as plex: with PlexClient(db) as plex:
info = plex.server_info() info = plex.server_info()
sections = plex.sections() sections = plex.sections()
media_check = _media_check(db, plex, sections)
except PlexNotConfigured as e: except PlexNotConfigured as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except PlexError as 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 for s in sections
if s.get("type") in ("movie", "show") 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") @router.post("/sync")
def trigger_sync(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict: 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 """Admin: mirror the enabled Plex sections into the local catalog. Synchronous (can take a

View file

@ -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:

View file

@ -194,6 +194,14 @@ export default function ConfigPanel() {
version: plexResult.version ?? "", version: plexResult.version ?? "",
})} })}
</p> </p>
{plexResult.media_check?.checked &&
(plexResult.media_check.ok ? (
<p className="text-[11px] text-emerald-500 mb-2">{t("config.plexMediaOk")}</p>
) : (
<p className="text-[11px] text-red-400 mb-2">
{t("config.plexMediaMissing", { path: plexResult.media_check.local_path ?? "" })}
</p>
))}
<p className="text-[11px] text-muted mb-1.5">{t("config.plexPickLibraries")}</p> <p className="text-[11px] text-muted mb-1.5">{t("config.plexPickLibraries")}</p>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
{plexResult.sections.map((s) => ( {plexResult.sections.map((s) => (

View file

@ -56,6 +56,7 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
const [muted, setMuted] = useState(false); const [muted, setMuted] = useState(false);
const [fs, setFs] = useState(false); const [fs, setFs] = useState(false);
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const [uiVisible, setUiVisible] = useState(true); const [uiVisible, setUiVisible] = useState(true);
const hideTimer = useRef<number | null>(null); const hideTimer = useRef<number | null>(null);
// Selected audio / subtitle stream ordinals (null audio = default first; null subtitle = off). // 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; const video = videoRef.current;
if (!video) return; if (!video) return;
setReady(false); setReady(false);
setLoadError(null);
let sess; let sess;
try { try {
sess = await api.plexSession(id, startAt, audioRef.current, subRef.current); 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; return;
} }
sessionStartRef.current = sess.start_s; sessionStartRef.current = sess.start_s;
@ -112,6 +124,11 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
setReady(true); setReady(true);
video.play().catch(() => {}); 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 { } else {
// direct file (or Safari native HLS) // direct file (or Safari native HLS)
video.src = sess.url; video.src = sess.url;
@ -125,7 +142,7 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
video.addEventListener("loadedmetadata", onMeta); video.addEventListener("loadedmetadata", onMeta);
} }
}, },
[id], [id, t],
); );
// Start playback when the detail arrives (resume from the saved position unless already watched). // 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 && ( {!ready && (
<div className="absolute inset-0 grid place-items-center text-white/70 text-sm pointer-events-none"> <div className="absolute inset-0 grid place-items-center px-6 text-center text-sm pointer-events-none">
{t("plex.player.loading")} {loadError ? (
<span className="max-w-md text-red-300">{loadError}</span>
) : (
<span className="text-white/70">{t("plex.player.loading")}</span>
)}
</div> </div>
)} )}

View file

@ -194,6 +194,8 @@
"plexTestOk": "Verbunden mit {{name}}", "plexTestOk": "Verbunden mit {{name}}",
"plexTestFailed": "Plex-Verbindung fehlgeschlagen — prüfe URL und Token", "plexTestFailed": "Plex-Verbindung fehlgeschlagen — prüfe URL und Token",
"plexConnected": "Verbunden mit {{name}} {{version}}", "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:", "plexPickLibraries": "Anzuzeigende Bibliotheken:",
"plexPickHint": "Keine auszuwählen entspricht dem Anzeigen aller Bibliotheken. Nicht vergessen zu speichern.", "plexPickHint": "Keine auszuwählen entspricht dem Anzeigen aller Bibliotheken. Nicht vergessen zu speichern.",
"plexMovies": "Filme", "plexMovies": "Filme",

View file

@ -47,7 +47,10 @@
"tracks": "Audio & Untertitel", "tracks": "Audio & Untertitel",
"audio": "Audio", "audio": "Audio",
"subtitles": "Untertitel", "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": { "playable": {
"direct": "Spielt direkt im Browser", "direct": "Spielt direkt im Browser",

View file

@ -194,6 +194,8 @@
"plexTestOk": "Connected to {{name}}", "plexTestOk": "Connected to {{name}}",
"plexTestFailed": "Plex connection failed — check the URL and token", "plexTestFailed": "Plex connection failed — check the URL and token",
"plexConnected": "Connected to {{name}} {{version}}", "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:", "plexPickLibraries": "Libraries to expose:",
"plexPickHint": "Unchecking all is the same as exposing every library. Remember to Save.", "plexPickHint": "Unchecking all is the same as exposing every library. Remember to Save.",
"plexMovies": "Movies", "plexMovies": "Movies",

View file

@ -47,7 +47,10 @@
"tracks": "Audio & subtitles", "tracks": "Audio & subtitles",
"audio": "Audio", "audio": "Audio",
"subtitles": "Subtitles", "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": { "playable": {
"direct": "Plays directly in the browser", "direct": "Plays directly in the browser",

View file

@ -194,6 +194,8 @@
"plexTestOk": "Kapcsolódva: {{name}}", "plexTestOk": "Kapcsolódva: {{name}}",
"plexTestFailed": "A Plex-kapcsolat sikertelen — ellenőrizd az URL-t és a tokent", "plexTestFailed": "A Plex-kapcsolat sikertelen — ellenőrizd az URL-t és a tokent",
"plexConnected": "Kapcsolódva: {{name}} {{version}}", "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:", "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.", "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", "plexMovies": "Filmek",

View file

@ -47,7 +47,10 @@
"tracks": "Audió és felirat", "tracks": "Audió és felirat",
"audio": "Audió", "audio": "Audió",
"subtitles": "Felirat", "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": { "playable": {
"direct": "Közvetlenül játszható a böngészőben", "direct": "Közvetlenül játszható a böngészőben",

View file

@ -622,6 +622,14 @@ export interface PlexTestResult {
server_name: string; server_name: string;
version?: string; version?: string;
sections: PlexSection[]; 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). // Plex browse/search/drill-down (the mirrored catalog rendered as feed-style cards).