Merge: promote dev to prod

This commit is contained in:
npeter83 2026-07-09 02:37:31 +02:00
commit 93bca8ba74
13 changed files with 1406 additions and 188 deletions

View file

@ -1 +1 @@
0.32.1
0.33.0

View file

@ -43,23 +43,27 @@ class HlsSession:
self.key = key
self.dir = directory
self.proc = proc
self.start_s = start_s
self.start_s = start_s # the REQUESTED seek offset
# The REAL media start: with `-ss X -c:v copy` ffmpeg can only cut at the nearest earlier
# keyframe K<=X, and hls.js zero-bases the timeline to K (so video.currentTime=0 is K, not X).
# We measure K from the first segment's absolute PTS (kept absolute by `-copyts`) and report
# THAT as the session start, so the client's absolute clock (start + currentTime) and the
# subtitle cue-shift both key off the true content position — no drift. Provisional = X until
# the first segment is probed.
self.media_start_s = start_s
self.entry = entry # the playlist filename hls.js should load (master.m3u8 with subs, else index.m3u8)
self.last_access = time.time()
def _ffmpeg_cmd(src: Path, start_s: float, out_dir: Path, audio_ord: int | None) -> list[str]:
args = ["ffmpeg", "-nostdin", "-loglevel", "error"]
if start_s > 0:
args += ["-ss", f"{start_s:.3f}"] # fast keyframe seek before -i
args += ["-i", str(src)]
# Video always stream-copied; the selected audio track (default = first) is transcoded to AAC.
# Subtitles are NOT muxed here — they're served as standalone WebVTT tracks (GET /subtitle) that
# the browser overlays, so choosing a subtitle no longer restarts this session (and external
# sidecar subs, which aren't in the file, work — the old `-map 0:s` broke on those).
args += ["-map", "0:v:0", "-map", f"0:a:{audio_ord if audio_ord is not None else 0}?", "-sn"]
args += ["-c:v", "copy", "-c:a", "aac", "-ac", "2", "-b:a", "192k"]
args += [
def _seek_opts(start_s: float) -> list[str]:
# -noaccurate_seek: with `-ss` before -i, video (stream-copy) backs up to the keyframe K<=X,
# but the re-encoded audio is normally trimmed to the exact X — so audio starts (X-K) AFTER
# video → seconds of silence at the start of every seek/audio-switch. noaccurate_seek makes
# audio ALSO start at K, aligned with the video (K is our real clock anchor anyway).
return ["-noaccurate_seek", "-ss", f"{start_s:.3f}"] if start_s > 0 else []
_HLS_TAIL = [
"-f", "hls",
"-hls_time", str(_SEG_SECONDS),
"-hls_list_size", "0",
@ -70,10 +74,88 @@ def _ffmpeg_cmd(src: Path, start_s: float, out_dir: Path, audio_ord: int | None)
"-hls_segment_type", "mpegts",
"-hls_flags", "independent_segments+temp_file",
]
args += ["-hls_segment_filename", str(out_dir / "seg_%d.ts"), str(out_dir / "index.m3u8")]
def _probe_audio_langs(src: Path) -> list[str]:
"""One entry per audio stream (its language tag, or "" if untagged). len() = audio-track count.
Used to build the multi-rendition var_stream_map. Empty on probe failure single-audio path."""
try:
out = subprocess.run(
["ffprobe", "-v", "error", "-select_streams", "a", "-show_entries", "stream_tags=language",
"-of", "csv=p=0", str(src)],
capture_output=True, text=True, timeout=10,
)
return [ln.strip() for ln in out.stdout.splitlines()]
except (subprocess.SubprocessError, OSError):
return []
def _ffmpeg_cmd(
src: Path, start_s: float, out_dir: Path, audio_ord: int | None,
aoff: float = 0.0, audio_langs: list[str] | None = None,
) -> list[str]:
args = ["ffmpeg", "-nostdin", "-loglevel", "error"]
# Keep ORIGINAL timestamps on the output so the first segment carries the true absolute PTS of the
# keyframe ffmpeg actually seeked to. hls.js still zero-bases playback to that PTS; we read it back
# (see _probe_first_pts) to learn the real content offset. Without -copyts the segment PTS is
# rewritten toward 0 and the true offset is unknowable, which caused the ~seconds subtitle lead.
args += _seek_opts(start_s) + ["-copyts", "-i", str(src)]
ai = 0 # input index the audio maps come from
if aoff:
# A/V-sync offset: read the file a SECOND time for audio only, with `-itsoffset` shifting the
# audio PTS by ±aoff relative to the (input-0) video. Maps audio from input 1 then.
args += ["-itsoffset", f"{aoff:.3f}"] + _seek_opts(start_s) + ["-copyts", "-i", str(src)]
ai = 1
# Video always stream-copied; audio → AAC. Subtitles are NOT muxed here — they're separate WebVTT
# tracks (GET /subtitle) the browser overlays, so choosing a subtitle doesn't restart this session.
if audio_langs:
# MULTI-RENDITION: map EVERY audio track as an alternate HLS rendition in one group, so hls.js
# switches audio CLIENT-SIDE on the same timeline (no session restart, no drift) — the premium
# audio-switch path. Produces master.m3u8 + stream_%v.m3u8 (v0=video, v1..=audio) + seg_%v_%d.ts.
args += ["-map", "0:v:0"]
for i in range(len(audio_langs)):
args += ["-map", f"{ai}:a:{i}?"]
args += ["-sn", "-c:v", "copy", "-c:a", "aac", "-ac", "2", "-b:a", "192k"]
var = ["v:0,agroup:aud"]
for i, lang in enumerate(audio_langs):
entry = f"a:{i},agroup:aud,name:a{i}"
if lang:
entry += f",language:{lang}"
if i == 0:
entry += ",default:yes"
var.append(entry)
args += _HLS_TAIL + [
"-master_pl_name", "master.m3u8",
"-var_stream_map", " ".join(var),
"-hls_segment_filename", str(out_dir / "seg_%v_%d.ts"), str(out_dir / "stream_%v.m3u8"),
]
else:
ao = audio_ord if audio_ord is not None else 0
args += ["-map", "0:v:0", "-map", f"{ai}:a:{ao}?", "-sn"]
args += ["-c:v", "copy", "-c:a", "aac", "-ac", "2", "-b:a", "192k"]
args += _HLS_TAIL + ["-hls_segment_filename", str(out_dir / "seg_%d.ts"), str(out_dir / "index.m3u8")]
return args
def _probe_first_pts(seg: Path) -> float | None:
"""Read the first video packet's absolute PTS from a finished HLS segment (`-copyts` kept it
absolute). This is the true content position of the segment's first frame — the value hls.js
zero-bases the timeline to. Returns None on any failure (caller falls back to the requested offset)."""
try:
out = subprocess.run(
["ffprobe", "-v", "error", "-select_streams", "v:0", "-read_intervals", "%+#1",
"-show_entries", "packet=pts_time", "-of", "csv=p=0", str(seg)],
capture_output=True, text=True, timeout=10,
)
for line in out.stdout.splitlines():
tok = line.strip().strip(",")
if tok:
return float(tok)
except (subprocess.SubprocessError, ValueError, OSError):
pass
return None
def _kill(s: HlsSession) -> None:
try:
s.proc.terminate()
@ -100,31 +182,50 @@ def start_session(
item: PlexItem,
start_s: float,
audio_ord: int | None = None,
aoff: float = 0.0,
multi: bool = False,
) -> HlsSession | None:
"""(Re)start the HLS remux for an item at the given offset, with an optional selected audio track
(ordinal among audio streams). Subtitles are separate WebVTT tracks (not muxed here). Returns
None if the local file can't be read."""
"""(Re)start the HLS remux for an item at the given offset. `multi` (item has >1 audio track) maps
every audio track as an HLS rendition so the client switches audio without a restart; otherwise a
single audio track (`audio_ord`) is muxed. Subtitles are separate WebVTT tracks (not muxed here).
Returns None if the local file can't be read."""
src = paths.local_media_path(db, item.file_path)
if src is None:
return None
key = item.rating_key
start_s = max(0.0, float(start_s))
audio_langs = _probe_audio_langs(src) if multi else []
is_multi = len(audio_langs) > 1 # only worth a master playlist when there really are ≥2 tracks
with _lock:
old = _sessions.pop(key, None)
if old is not None:
_kill(old)
directory = _HLS_ROOT / f"{key}_{int(start_s)}_{audio_ord}"
tag = "multi" if is_multi else str(audio_ord)
directory = _HLS_ROOT / f"{key}_{int(start_s)}_{tag}_{aoff:+.2f}"
shutil.rmtree(directory, ignore_errors=True)
directory.mkdir(parents=True, exist_ok=True)
proc = subprocess.Popen(
_ffmpeg_cmd(src, start_s, directory, audio_ord),
_ffmpeg_cmd(src, start_s, directory, audio_ord, aoff, audio_langs if is_multi else None),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
s = HlsSession(key, directory, proc, start_s, "index.m3u8")
s = HlsSession(key, directory, proc, start_s, "master.m3u8" if is_multi else "index.m3u8")
_sessions[key] = s
_enforce_cap()
log.info("plex hls session start key=%s start=%.1f audio=%s", key, start_s, audio_ord)
# Learn the REAL start offset K from the first VIDEO segment (outside the lock — this blocks on
# ffmpeg producing it, and we must not hold up other sessions). The client reads `media_start_s`, so
# its absolute clock + subtitle shift key off the true keyframe, not the requested offset. On
# timeout/probe failure we keep the provisional requested offset (old behaviour, small drift).
if start_s > 0:
seg0 = directory / ("seg_0_0.ts" if is_multi else "seg_0.ts") # multi: video variant = v0
if wait_for(seg0, timeout=25.0):
k = _probe_first_pts(seg0)
if k is not None and k >= 0:
s.media_start_s = k
log.info(
"plex hls session start key=%s req=%.1f real=%.3f multi=%s audio=%s",
key, start_s, s.media_start_s, is_multi, audio_ord,
)
return s

View file

@ -943,6 +943,63 @@ def _srt_to_vtt(text: str) -> str:
return "WEBVTT\n\n" + body.strip() + "\n"
def _vtt_ts_to_s(ts: str) -> float:
parts = ts.split(":")
h = "0" if len(parts) == 2 else parts[0]
m, rest = parts[-2], parts[-1]
sec, _, ms = rest.partition(".")
return int(h) * 3600 + int(m) * 60 + int(sec) + int(ms or 0) / 1000.0
def _vtt_s_to_ts(x: float) -> str:
x = max(0.0, x)
h = int(x // 3600); x -= h * 3600
m = int(x // 60); x -= m * 60
s = int(x); ms = int(round((x - s) * 1000))
if ms >= 1000:
s += 1; ms -= 1000
return f"{h:02d}:{m:02d}:{s:02d}.{ms:03d}"
def _shift_vtt(vtt: str, offset: float) -> str:
"""Subtract `offset` seconds from every cue's start/end. WebVTT cues carry ABSOLUTE content times,
but an HLS remux session's clock is zero-based at the real keyframe (see stream.py media_start_s),
so a subtitle overlaid on that session must be shifted by that offset to line up with speech.
Processes cue BLOCKS so a cue that shifts fully into the past (end <= 0) can be DROPPED entirely.
(Collapsing them to 00:00:00.000-->00:00:00.000 made EVERY past cue count as active at currentTime 0
where a resume/seek lands piling all of them on screen until playback advanced past 0.) A cue
straddling 0 is clamped to start at 0."""
if not offset:
return vtt
out_blocks: list[str] = []
for block in re.split(r"\n[ \t]*\n", vtt):
if "-->" not in block:
out_blocks.append(block) # header / NOTE / STYLE — keep verbatim
continue
lines = block.split("\n")
keep = True
for i, ln in enumerate(lines):
if "-->" not in ln:
continue
left, _, right = ln.partition("-->")
rparts = right.strip().split(None, 1)
settings = (" " + rparts[1]) if len(rparts) > 1 else ""
try:
st = _vtt_ts_to_s(left.strip()) - offset
en = _vtt_ts_to_s(rparts[0]) - offset
except (ValueError, IndexError):
break # unparseable timing → leave the block as-is
if en <= 0:
keep = False # whole cue is before the session start → drop the block
else:
lines[i] = f"{_vtt_s_to_ts(st)} --> {_vtt_s_to_ts(en)}{settings}" # _vtt_s_to_ts clamps st>=0
break
if keep:
out_blocks.append("\n".join(lines))
return "\n\n".join(out_blocks)
def _ffmpeg_to_vtt(src: str, sub_ord: int | None) -> str:
"""Convert one subtitle stream of a file to WebVTT via ffmpeg (for embedded subs, and non-SRT
external subs written to a temp file). `sub_ord` = ordinal among the file's subtitle streams."""
@ -1047,16 +1104,18 @@ def image(rating_key: str, request: Request, variant: str = "thumb") -> Response
@router.get("/subtitle/{rating_key}/{sub_ord}")
def subtitle(rating_key: str, sub_ord: int, request: Request) -> Response:
def subtitle(rating_key: str, sub_ord: int, request: Request, offset: float = 0.0) -> Response:
"""Serve the ord-th subtitle track as WebVTT for the browser's <track> — so choosing a subtitle
overlays it directly (no HLS re-mux / session restart). Handles EXTERNAL sidecar subs (fetched
from Plex via the stream `key` the old `-map 0:s` broke on these since they're not in the file)
AND embedded text subs (extracted from the local file). Image subs (PGS/VobSub) 415. Disk-cached."""
_require_session(request)
# Cache the ABSOLUTE-time VTT once (per rk+ord); the per-session cue-shift is applied on the way
# out, so a resume/seek to any offset reuses the same cached track (no cache-per-offset explosion).
cache_key = f"sub_{rating_key}_{sub_ord}"
hit = _sub_cache_read(cache_key)
if hit is not None:
return Response(hit, media_type="text/vtt")
return Response(_shift_vtt(hit, offset), media_type="text/vtt")
try:
with SessionLocal() as db:
it = _item_or_404(db, rating_key)
@ -1094,7 +1153,7 @@ def subtitle(rating_key: str, sub_ord: int, request: Request) -> Response:
except PlexError as e:
raise HTTPException(status_code=502, detail=f"Subtitle fetch failed: {e}")
_sub_cache_write(cache_key, vtt)
return Response(vtt, media_type="text/vtt")
return Response(_shift_vtt(vtt, offset), media_type="text/vtt")
# --- Playback (local file → direct 206 or on-the-fly HLS remux; P2) ----------------------------
@ -1439,6 +1498,8 @@ def stream_session(
rating_key: str,
start: float = 0.0,
audio: int | None = None,
aoff: float = 0.0,
multi: bool = False,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
@ -1450,14 +1511,18 @@ def stream_session(
it = _item_or_404(db, rating_key)
if it.playable == "transcode":
raise HTTPException(status_code=501, detail="This format needs transcoding (a later phase).")
if it.playable == "direct" and audio is None:
# `multi` (item has ≥2 audio tracks) forces the HLS path so ALL audio tracks ship as renditions and
# the client can switch audio without a restart — even for otherwise direct-playable files.
if it.playable == "direct" and audio is None and not aoff and not multi:
return {"mode": "direct", "url": f"/api/plex/stream/{it.rating_key}/file", "start_s": 0.0}
if plex_paths.local_media_path(db, it.file_path) is None:
raise HTTPException(status_code=404, detail="Media file not found on disk")
s = plex_stream.start_session(db, it, start, audio)
s = plex_stream.start_session(db, it, start, audio, aoff, multi)
if s is None:
raise HTTPException(status_code=404, detail="Media file not found on disk")
return {"mode": "hls", "start_s": s.start_s, "url": f"/api/plex/stream/{it.rating_key}/hls/{s.entry}"}
# Report the REAL media start (the keyframe ffmpeg landed on, which hls.js zero-bases to), not the
# requested offset — the client's absolute clock and the subtitle cue-shift both key off this.
return {"mode": "hls", "start_s": s.media_start_s, "url": f"/api/plex/stream/{it.rating_key}/hls/{s.entry}"}
@router.get("/stream/{rating_key}/file")
@ -1500,11 +1565,15 @@ def stream_hls_file(
raise HTTPException(status_code=404, detail="Not found")
s = plex_stream.current_session(str(rating_key))
if s is None:
# No session yet: only the entry playlist for a remux item may auto-start one at 0.
# No session yet (e.g. reaped): the ENTRY playlist may auto-start one at 0 — master.m3u8 for a
# multi-audio item, index.m3u8 for a single-audio remux item.
it = _item_or_404(db, rating_key)
if filename != "index.m3u8" or it.playable != "remux":
raise HTTPException(status_code=404, detail="No active playback session")
if filename == "master.m3u8":
s = plex_stream.start_session(db, it, 0.0, multi=True)
elif filename == "index.m3u8" and it.playable == "remux":
s = plex_stream.start_session(db, it, 0.0)
else:
raise HTTPException(status_code=404, detail="No active playback session")
if s is None:
raise HTTPException(status_code=404, detail="Media file not found on disk")
path = s.dir / filename

View file

@ -260,6 +260,12 @@ export default function App() {
}
}, [plexFiltersRaw]);
const setPlexFilters = (f: PlexFilters) => setPlexFiltersRaw(JSON.stringify(f));
// The Plex library search term is its OWN ephemeral state — deliberately NOT the persisted feed
// `filters.q`. Sharing that box meant a Plex search leaked into the feed and, being persisted,
// greeted you with a stale query (colliding with a persisted collection filter → confusing
// "0 matches") after a reload. Kept in App so it survives page switches within a session, but
// resets on reload.
const [plexQ, setPlexQ] = useState("");
// Bumped to tell the channel manager to drop a stale column filter when we send the user
// there to see a specific set (the header's "without full history" link).
const [channelsFilterReset, setChannelsFilterReset] = useState(0);
@ -767,6 +773,8 @@ export default function App() {
me={meQuery.data!}
filters={filters}
setFilters={setFilters}
plexQ={plexQ}
setPlexQ={setPlexQ}
page={page}
onYtSearch={enterYtSearch}
/>
@ -819,8 +827,8 @@ export default function App() {
/>
) : page === "plex" && meQuery.data!.plex_enabled ? (
<PlexBrowse
q={filters.q}
onClearSearch={() => setFilters({ ...filters, q: "" })}
q={plexQ}
onClearSearch={() => setPlexQ("")}
library={plexLib}
show={plexShowFilter}
sort={plexSort}

View file

@ -12,12 +12,17 @@ export default function Header({
me,
filters,
setFilters,
plexQ,
setPlexQ,
page,
onYtSearch,
}: {
me: Me;
filters: FeedFilters;
setFilters: (f: FeedFilters) => void;
// Plex has its own ephemeral search term (see App) — the box is shared UI, the state is not.
plexQ: string;
setPlexQ: (q: string) => void;
page: Page;
// Trigger a live YouTube search for the current term (hidden for the demo account, which
// can't spend the shared quota).
@ -25,11 +30,14 @@ export default function Header({
}) {
const { t } = useTranslation();
const canSearchYt = !me.is_demo;
const trimmedQ = filters.q.trim();
// The search box serves both the YouTube feed and the Plex module (integrated search); the live
// YouTube-search escalation (Enter / button) is feed-only.
// YouTube-search escalation (Enter / button) is feed-only. On Plex it drives `plexQ`, on the feed
// the persisted `filters.q`.
const isSearchPage = page === "feed" || page === "plex";
const isPlex = page === "plex";
const isYtCapable = page === "feed" && canSearchYt;
const searchValue = isPlex ? plexQ : filters.q;
const trimmedQ = searchValue.trim();
return (
<header className="glass h-14 shrink-0 border-b border-border flex items-center gap-3 px-4 z-20">
@ -38,14 +46,18 @@ export default function Header({
<div className="flex-1 relative">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted" />
<input
value={filters.q}
value={searchValue}
onChange={(e) => {
const q = e.target.value;
if (isPlex) {
setPlexQ(q);
return;
}
// When a search first appears, rank the feed by relevance — set atomically with
// the query (race-free vs. per-keystroke updates). Only overrides the default
// "newest" sort; a custom sort the user chose is left alone. Cleared in Feed.
const startSearch =
page === "feed" && !filters.q.trim() && !!q.trim() && filters.sort === "newest";
!filters.q.trim() && !!q.trim() && filters.sort === "newest";
setFilters({ ...filters, q, ...(startSearch ? { sort: "relevance" } : {}) });
}}
onKeyDown={(e) => {
@ -56,9 +68,9 @@ export default function Header({
placeholder={page === "plex" ? t("plex.searchPlaceholder") : t("header.searchPlaceholder")}
className="w-full bg-card border border-border rounded-full pl-9 pr-9 py-2 text-sm outline-none focus:border-accent"
/>
{filters.q && (
{searchValue && (
<button
onClick={() => setFilters({ ...filters, q: "" })}
onClick={() => (isPlex ? setPlexQ("") : setFilters({ ...filters, q: "" }))}
title={t("header.clearSearch")}
aria-label={t("header.clearSearch")}
className="absolute right-2.5 top-1/2 -translate-y-1/2 p-0.5 rounded-full text-muted hover:text-fg hover:bg-border/60 transition"

View file

@ -2,7 +2,14 @@ import { lazy, Suspense, useEffect, useLayoutEffect, useRef, useState, type CSSP
import { useTranslation } from "react-i18next";
import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, CheckCircle2, Info, ListPlus, Play } from "lucide-react";
import { api, type PlexCard, type PlexFilters, type PlexPerson } from "../lib/api";
import {
api,
EMPTY_PLEX_FILTERS,
plexFilterCount,
type PlexCard,
type PlexFilters,
type PlexPerson,
} from "../lib/api";
import { useDebounced } from "../lib/useDebounced";
import { useHistorySubview } from "../lib/history";
import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
@ -267,7 +274,21 @@ export default function PlexBrowse({
{browseQ.isLoading ? (
<p className="text-muted text-sm">{t("plex.loading")}</p>
) : items.length === 0 ? (
dq && plexFilterCount(filters) > 0 ? (
// A search that comes up empty WHILE filters are active is usually the filters, not the
// query — say so and offer a one-click escape, instead of a bare "No matches".
<div className="flex flex-wrap items-center gap-3 text-sm text-muted">
<span>{t("plex.noMatchesFiltered", { count: plexFilterCount(filters) })}</span>
<button
onClick={() => setFilters(EMPTY_PLEX_FILTERS)}
className="rounded-full border border-border bg-card px-3 py-1 text-xs font-medium transition hover:border-accent hover:text-accent"
>
{t("plex.filter.clearAll")}
</button>
</div>
) : (
<p className="text-muted text-sm">{dq ? t("plex.noMatches") : t("plex.empty")}</p>
)
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
{items.map((c) => (

File diff suppressed because it is too large Load diff

View file

@ -57,6 +57,8 @@
"count": "{{count}} Titel",
"searchCount": "{{count}} Treffer",
"noMatches": "Keine Treffer.",
"noMatchesFiltered_one": "Keine Treffer — ein Filter schränkt die Ergebnisse zusätzlich ein.",
"noMatchesFiltered_other": "Keine Treffer — {{count}} Filter schränken die Ergebnisse zusätzlich ein.",
"loading": "Wird geladen…",
"empty": "Noch nichts hier. Starte eine Plex-Synchronisierung auf der Admin-Konfigurationsseite.",
"loadMore": "Mehr laden",
@ -120,8 +122,36 @@
"fullscreen": "Vollbild",
"info": "Medieninfo",
"back": "Stopp & zurück zum Feed",
"help": "Diese Hilfe anzeigen"
}
"help": "Diese Hilfe anzeigen",
"audio": "Audiospur wechseln",
"subtitle": "Untertitel wechseln",
"fineSeek": "Feines Springen"
},
"settings": "Einstellungen",
"sync": "Offsets",
"subOffset": "Untertitel-Verzögerung",
"audioOffset": "Audio-Verzögerung",
"playback": "Wiedergabe",
"seekStep": "Sprungweite",
"fineSeek": "Feines Springen (Strg)",
"autoHide": "Steuerung ausblenden",
"subStyle": "Untertitel",
"subSize": "Schriftgröße",
"subPos": "Position",
"subColor": "Textfarbe",
"subBg": "Hintergrund",
"subBgNone": "Keiner",
"clock": "Uhr",
"clockShow": "Uhr anzeigen",
"clock24h": "24-Stunden",
"clockSize": "Größe",
"clockColor": "Farbe",
"clockDate": "Datum anzeigen",
"subShadow": "Schatten",
"subShadowColor": "Schattenfarbe",
"autoSkipIntro": "Intro autom. überspringen",
"autoSkipCredits": "Abspann autom. überspringen",
"autoSkipDelay": "Auto-Skip-Verzögerung"
},
"info": {
"title": "Medieninfo",

View file

@ -57,6 +57,8 @@
"count": "{{count}} titles",
"searchCount": "{{count}} matches",
"noMatches": "No matches.",
"noMatchesFiltered_one": "No matches — a filter is also narrowing the results.",
"noMatchesFiltered_other": "No matches — {{count}} filters are also narrowing the results.",
"loading": "Loading…",
"empty": "Nothing here yet. Run a Plex sync from the admin Config page.",
"loadMore": "Load more",
@ -120,8 +122,36 @@
"fullscreen": "Fullscreen",
"info": "Media info",
"back": "Stop & back to feed",
"help": "Show this help"
}
"help": "Show this help",
"audio": "Cycle audio track",
"subtitle": "Cycle subtitle",
"fineSeek": "Fine seek"
},
"settings": "Settings",
"sync": "Offsets",
"subOffset": "Subtitle delay",
"audioOffset": "Audio delay",
"playback": "Playback",
"seekStep": "Seek step",
"fineSeek": "Fine seek (Ctrl)",
"autoHide": "Auto-hide controls",
"subStyle": "Subtitles",
"subSize": "Text size",
"subPos": "Position",
"subColor": "Text color",
"subBg": "Background",
"subBgNone": "None",
"clock": "Clock",
"clockShow": "Show clock",
"clock24h": "24-hour",
"clockSize": "Size",
"clockColor": "Color",
"clockDate": "Show date",
"subShadow": "Shadow",
"subShadowColor": "Shadow color",
"autoSkipIntro": "Auto-skip intro",
"autoSkipCredits": "Auto-skip credits",
"autoSkipDelay": "Auto-skip delay"
},
"info": {
"title": "Media info",

View file

@ -57,6 +57,8 @@
"count": "{{count}} cím",
"searchCount": "{{count}} találat",
"noMatches": "Nincs találat.",
"noMatchesFiltered_one": "Nincs találat — egy szűrő is szűkíti az eredményt.",
"noMatchesFiltered_other": "Nincs találat — {{count}} szűrő is szűkíti az eredményt.",
"loading": "Betöltés…",
"empty": "Még nincs itt semmi. Futtass egy Plex-szinkront az admin Konfiguráció oldalról.",
"loadMore": "Több betöltése",
@ -120,8 +122,36 @@
"fullscreen": "Teljes képernyő",
"info": "Média infó",
"back": "Leállítás és vissza a feedre",
"help": "Súgó megjelenítése"
}
"help": "Súgó megjelenítése",
"audio": "Hangsáv váltása",
"subtitle": "Felirat váltása",
"fineSeek": "Finom léptetés"
},
"settings": "Beállítások",
"sync": "Eltolások",
"subOffset": "Felirat-eltolás",
"audioOffset": "Hang-eltolás",
"playback": "Lejátszás",
"seekStep": "Léptetés",
"fineSeek": "Finom léptetés (Ctrl)",
"autoHide": "Vezérlők elrejtése",
"subStyle": "Feliratok",
"subSize": "Szövegméret",
"subPos": "Pozíció",
"subColor": "Szövegszín",
"subBg": "Háttér",
"subBgNone": "Nincs",
"clock": "Óra",
"clockShow": "Óra megjelenítése",
"clock24h": "24 órás",
"clockSize": "Méret",
"clockColor": "Szín",
"clockDate": "Dátum",
"subShadow": "Árnyék",
"subShadowColor": "Árnyék színe",
"autoSkipIntro": "Intro auto-átugrás",
"autoSkipCredits": "Stáblista auto-átugrás",
"autoSkipDelay": "Auto-skip késleltetés"
},
"info": {
"title": "Média infó",

View file

@ -1218,19 +1218,44 @@ export const api = {
req(`/api/plex/show/${encodeURIComponent(id)}`),
plexItem: (id: string): Promise<PlexItemDetail> =>
req(`/api/plex/item/${encodeURIComponent(id)}`),
plexSession: (id: string, start = 0, audio?: number | null): Promise<PlexPlaySession> => {
plexSession: (id: string, start = 0, audio?: number | null, aoff = 0, multi = false): Promise<PlexPlaySession> => {
const p = new URLSearchParams({ start: String(Math.max(0, Math.floor(start))) });
if (audio != null) p.set("audio", String(audio));
if (aoff) p.set("aoff", String(aoff));
if (multi) p.set("multi", "1"); // ≥2 audio tracks → multi-rendition HLS (client-side audio switch)
return req(`/api/plex/stream/${encodeURIComponent(id)}/session?${p.toString()}`, { method: "POST" });
},
// WebVTT URL for a text subtitle track (used directly as a <track src>). Same-origin → cookie-authed.
plexSubtitleUrl: (id: string, ord: number): string =>
`/api/plex/subtitle/${encodeURIComponent(id)}/${ord}`,
// `offset` = the session's real media start (0 for direct-play); the backend shifts the absolute
// cue times onto the HLS session's zero-based clock so subtitles line up with speech after a seek.
plexSubtitleUrl: (id: string, ord: number, offset = 0): string =>
`/api/plex/subtitle/${encodeURIComponent(id)}/${ord}${offset ? `?offset=${offset}` : ""}`,
plexProgress: (id: string, position_seconds: number, duration_seconds: number): Promise<unknown> =>
req(`/api/plex/item/${encodeURIComponent(id)}/progress`, {
method: "POST",
body: JSON.stringify({ position_seconds, duration_seconds }),
}),
// Fire-and-forget progress save that SURVIVES page unload (F5/close/navigate): a normal fetch is
// cancelled on unload and React effect cleanup doesn't run on a full reload, so the last position
// (esp. right after a seek) would be lost. `keepalive` lets the POST complete during unload; we
// replicate req()'s credentials + account header (no retry — there's no page left to retry on).
plexProgressBeacon: (id: string, position_seconds: number, duration_seconds: number): void => {
const active = getActiveAccount();
try {
void fetch(`/api/plex/item/${encodeURIComponent(id)}/progress`, {
method: "POST",
keepalive: true,
credentials: "include",
headers: {
"Content-Type": "application/json",
...(active != null ? { [ACTIVE_ACCOUNT_HEADER]: String(active) } : {}),
},
body: JSON.stringify({ position_seconds, duration_seconds }),
}).catch(() => {});
} catch {
/* ignore */
}
},
plexSetState: (id: string, status: "new" | "watched" | "hidden"): Promise<unknown> =>
req(`/api/plex/item/${encodeURIComponent(id)}/state`, {
method: "POST",

View file

@ -14,6 +14,21 @@ export interface ReleaseEntry {
}
export const RELEASE_NOTES: ReleaseEntry[] = [
{
version: "0.33.0",
date: "2026-07-09",
summary: "Watch your Plex library right here — a full player — plus smarter Plex search.",
features: [
"Plex player: play any film or episode straight from Siftlode. It resumes where you left off, remembers your volume, and — when the file's format needs it — streams through an on-the-fly conversion so it just plays.",
"Player controls: scrub the timeline by clicking or dragging it, hover for a timestamp, switch audio tracks and subtitles, and see the expected finish time in brackets next to the time remaining. A live clock sits in the top corner.",
"Skip Intro / Skip Credits buttons appear on marked titles, with an optional auto-skip countdown (which pauses when you pause); credits can auto-advance to the next episode.",
"Personalise the player from the gear menu — subtitle/audio sync offsets, seek step, subtitle style (size, position, colour, background, shadow) and the corner clock. The whole player is 25% larger for lean-back viewing, and Back / Esc (keyboard or mouse) step out of an open menu before leaving the player.",
],
fixes: [
"Plex search now has its own box: it no longer leaks into — or gets left behind by — the feed search, and it clears on reload instead of greeting you with a stale query.",
'A Plex search that comes up empty while a filter is active now says so and offers a one-click "Clear filters", instead of a bare "No matches".',
],
},
{
version: "0.32.1",
date: "2026-07-07",

View file

@ -1,4 +1,4 @@
import { useState } from "react";
import { useCallback, useState } from "react";
// Central registry of every localStorage key the app uses. One place to see them all, rename
// safely, and avoid collisions — instead of `"siftlode.x"` string literals scattered across
@ -158,3 +158,24 @@ export function useAccountPersistedState(
};
return [value, set];
}
/** A per-account persisted JSON OBJECT: `patch(partial)` merges + saves, unknown/missing keys fall
* back to `defaults` (via readAccountMerged) so adding a field later is safe. Used for bundles of
* related per-account settings (e.g. the Plex player prefs: volume, offsets, seek steps, sub style). */
export function useAccountPersistedObject<T extends object>(
base: string,
defaults: T,
): [T, (patch: Partial<T>) => void] {
const [value, setValue] = useState<T>(() => readAccountMerged(base, defaults));
const patch = useCallback(
(p: Partial<T>) => {
setValue((prev) => {
const next = { ...prev, ...p };
writeAccount(base, next);
return next;
});
},
[base],
);
return [value, patch];
}