Merge: promote dev to prod
This commit is contained in:
commit
daa6c62f9c
14 changed files with 297 additions and 9 deletions
12
.env.example
12
.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 <dir>.
|
||||
# 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
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.23.0
|
||||
0.23.1
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
175
docker-compose.home.yml.example
Normal file
175
docker-compose.home.yml.example
Normal 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:
|
||||
|
|
@ -194,6 +194,14 @@ export default function ConfigPanel() {
|
|||
version: plexResult.version ?? "",
|
||||
})}
|
||||
</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>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{plexResult.sections.map((s) => (
|
||||
|
|
|
|||
|
|
@ -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<string | null>(null);
|
||||
const [uiVisible, setUiVisible] = useState(true);
|
||||
const hideTimer = useRef<number | null>(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 && (
|
||||
<div className="absolute inset-0 grid place-items-center text-white/70 text-sm pointer-events-none">
|
||||
{t("plex.player.loading")}
|
||||
<div className="absolute inset-0 grid place-items-center px-6 text-center text-sm pointer-events-none">
|
||||
{loadError ? (
|
||||
<span className="max-w-md text-red-300">{loadError}</span>
|
||||
) : (
|
||||
<span className="text-white/70">{t("plex.player.loading")}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -14,6 +14,17 @@ export interface ReleaseEntry {
|
|||
}
|
||||
|
||||
export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
{
|
||||
version: "0.23.1",
|
||||
date: "2026-07-05",
|
||||
summary: "Clearer Plex playback errors, and an admin media-mount check.",
|
||||
fixes: [
|
||||
"Plex player: when a video can't start, it now explains why — the media file isn't reachable on the server, or its format needs transcoding — instead of showing “Loading…” forever.",
|
||||
],
|
||||
features: [
|
||||
"Admins: the Plex “Test connection” now also checks that a real media file is readable through the configured mount, so a missing media mount or a wrong path map is caught immediately.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.23.0",
|
||||
date: "2026-07-05",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue