Merge: promote dev to prod
This commit is contained in:
commit
93bca8ba74
13 changed files with 1406 additions and 188 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.32.1
|
0.33.0
|
||||||
|
|
@ -43,37 +43,119 @@ class HlsSession:
|
||||||
self.key = key
|
self.key = key
|
||||||
self.dir = directory
|
self.dir = directory
|
||||||
self.proc = proc
|
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.entry = entry # the playlist filename hls.js should load (master.m3u8 with subs, else index.m3u8)
|
||||||
self.last_access = time.time()
|
self.last_access = time.time()
|
||||||
|
|
||||||
|
|
||||||
def _ffmpeg_cmd(src: Path, start_s: float, out_dir: Path, audio_ord: int | None) -> list[str]:
|
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",
|
||||||
|
# EVENT (not VOD): ffmpeg writes/appends the playlist AS segments complete, so playback can
|
||||||
|
# start immediately from this session's offset. VOD only writes the playlist at the end. The
|
||||||
|
# full seekbar comes from our known duration + the seek-restart model, not from the playlist.
|
||||||
|
"-hls_playlist_type", "event",
|
||||||
|
"-hls_segment_type", "mpegts",
|
||||||
|
"-hls_flags", "independent_segments+temp_file",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
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"]
|
args = ["ffmpeg", "-nostdin", "-loglevel", "error"]
|
||||||
if start_s > 0:
|
# Keep ORIGINAL timestamps on the output so the first segment carries the true absolute PTS of the
|
||||||
args += ["-ss", f"{start_s:.3f}"] # fast keyframe seek before -i
|
# keyframe ffmpeg actually seeked to. hls.js still zero-bases playback to that PTS; we read it back
|
||||||
args += ["-i", str(src)]
|
# (see _probe_first_pts) to learn the real content offset. Without -copyts the segment PTS is
|
||||||
# Video always stream-copied; the selected audio track (default = first) is transcoded to AAC.
|
# rewritten toward 0 and the true offset is unknowable, which caused the ~seconds subtitle lead.
|
||||||
# Subtitles are NOT muxed here — they're served as standalone WebVTT tracks (GET /subtitle) that
|
args += _seek_opts(start_s) + ["-copyts", "-i", str(src)]
|
||||||
# the browser overlays, so choosing a subtitle no longer restarts this session (and external
|
ai = 0 # input index the audio maps come from
|
||||||
# sidecar subs, which aren't in the file, work — the old `-map 0:s` broke on those).
|
if aoff:
|
||||||
args += ["-map", "0:v:0", "-map", f"0:a:{audio_ord if audio_ord is not None else 0}?", "-sn"]
|
# A/V-sync offset: read the file a SECOND time for audio only, with `-itsoffset` shifting the
|
||||||
args += ["-c:v", "copy", "-c:a", "aac", "-ac", "2", "-b:a", "192k"]
|
# audio PTS by ±aoff relative to the (input-0) video. Maps audio from input 1 then.
|
||||||
args += [
|
args += ["-itsoffset", f"{aoff:.3f}"] + _seek_opts(start_s) + ["-copyts", "-i", str(src)]
|
||||||
"-f", "hls",
|
ai = 1
|
||||||
"-hls_time", str(_SEG_SECONDS),
|
# Video always stream-copied; audio → AAC. Subtitles are NOT muxed here — they're separate WebVTT
|
||||||
"-hls_list_size", "0",
|
# tracks (GET /subtitle) the browser overlays, so choosing a subtitle doesn't restart this session.
|
||||||
# EVENT (not VOD): ffmpeg writes/appends the playlist AS segments complete, so playback can
|
if audio_langs:
|
||||||
# start immediately from this session's offset. VOD only writes the playlist at the end. The
|
# MULTI-RENDITION: map EVERY audio track as an alternate HLS rendition in one group, so hls.js
|
||||||
# full seekbar comes from our known duration + the seek-restart model, not from the playlist.
|
# switches audio CLIENT-SIDE on the same timeline (no session restart, no drift) — the premium
|
||||||
"-hls_playlist_type", "event",
|
# audio-switch path. Produces master.m3u8 + stream_%v.m3u8 (v0=video, v1..=audio) + seg_%v_%d.ts.
|
||||||
"-hls_segment_type", "mpegts",
|
args += ["-map", "0:v:0"]
|
||||||
"-hls_flags", "independent_segments+temp_file",
|
for i in range(len(audio_langs)):
|
||||||
]
|
args += ["-map", f"{ai}:a:{i}?"]
|
||||||
args += ["-hls_segment_filename", str(out_dir / "seg_%d.ts"), str(out_dir / "index.m3u8")]
|
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
|
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:
|
def _kill(s: HlsSession) -> None:
|
||||||
try:
|
try:
|
||||||
s.proc.terminate()
|
s.proc.terminate()
|
||||||
|
|
@ -100,32 +182,51 @@ def start_session(
|
||||||
item: PlexItem,
|
item: PlexItem,
|
||||||
start_s: float,
|
start_s: float,
|
||||||
audio_ord: int | None = None,
|
audio_ord: int | None = None,
|
||||||
|
aoff: float = 0.0,
|
||||||
|
multi: bool = False,
|
||||||
) -> HlsSession | None:
|
) -> HlsSession | None:
|
||||||
"""(Re)start the HLS remux for an item at the given offset, with an optional selected audio track
|
"""(Re)start the HLS remux for an item at the given offset. `multi` (item has >1 audio track) maps
|
||||||
(ordinal among audio streams). Subtitles are separate WebVTT tracks (not muxed here). Returns
|
every audio track as an HLS rendition so the client switches audio without a restart; otherwise a
|
||||||
None if the local file can't be read."""
|
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)
|
src = paths.local_media_path(db, item.file_path)
|
||||||
if src is None:
|
if src is None:
|
||||||
return None
|
return None
|
||||||
key = item.rating_key
|
key = item.rating_key
|
||||||
start_s = max(0.0, float(start_s))
|
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:
|
with _lock:
|
||||||
old = _sessions.pop(key, None)
|
old = _sessions.pop(key, None)
|
||||||
if old is not None:
|
if old is not None:
|
||||||
_kill(old)
|
_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)
|
shutil.rmtree(directory, ignore_errors=True)
|
||||||
directory.mkdir(parents=True, exist_ok=True)
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
proc = subprocess.Popen(
|
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,
|
stdout=subprocess.DEVNULL,
|
||||||
stderr=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
|
_sessions[key] = s
|
||||||
_enforce_cap()
|
_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
|
||||||
return s
|
# 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
|
||||||
|
|
||||||
|
|
||||||
def current_session(key: str) -> HlsSession | None:
|
def current_session(key: str) -> HlsSession | None:
|
||||||
|
|
|
||||||
|
|
@ -943,6 +943,63 @@ def _srt_to_vtt(text: str) -> str:
|
||||||
return "WEBVTT\n\n" + body.strip() + "\n"
|
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:
|
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
|
"""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."""
|
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}")
|
@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
|
"""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
|
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)
|
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."""
|
AND embedded text subs (extracted from the local file). Image subs (PGS/VobSub) → 415. Disk-cached."""
|
||||||
_require_session(request)
|
_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}"
|
cache_key = f"sub_{rating_key}_{sub_ord}"
|
||||||
hit = _sub_cache_read(cache_key)
|
hit = _sub_cache_read(cache_key)
|
||||||
if hit is not None:
|
if hit is not None:
|
||||||
return Response(hit, media_type="text/vtt")
|
return Response(_shift_vtt(hit, offset), media_type="text/vtt")
|
||||||
try:
|
try:
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
it = _item_or_404(db, rating_key)
|
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:
|
except PlexError as e:
|
||||||
raise HTTPException(status_code=502, detail=f"Subtitle fetch failed: {e}")
|
raise HTTPException(status_code=502, detail=f"Subtitle fetch failed: {e}")
|
||||||
_sub_cache_write(cache_key, vtt)
|
_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) ----------------------------
|
# --- Playback (local file → direct 206 or on-the-fly HLS remux; P2) ----------------------------
|
||||||
|
|
@ -1439,6 +1498,8 @@ def stream_session(
|
||||||
rating_key: str,
|
rating_key: str,
|
||||||
start: float = 0.0,
|
start: float = 0.0,
|
||||||
audio: int | None = None,
|
audio: int | None = None,
|
||||||
|
aoff: float = 0.0,
|
||||||
|
multi: bool = False,
|
||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
|
@ -1450,14 +1511,18 @@ def stream_session(
|
||||||
it = _item_or_404(db, rating_key)
|
it = _item_or_404(db, rating_key)
|
||||||
if it.playable == "transcode":
|
if it.playable == "transcode":
|
||||||
raise HTTPException(status_code=501, detail="This format needs transcoding (a later phase).")
|
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}
|
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:
|
if plex_paths.local_media_path(db, it.file_path) is None:
|
||||||
raise HTTPException(status_code=404, detail="Media file not found on disk")
|
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:
|
if s is None:
|
||||||
raise HTTPException(status_code=404, detail="Media file not found on disk")
|
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")
|
@router.get("/stream/{rating_key}/file")
|
||||||
|
|
@ -1500,11 +1565,15 @@ def stream_hls_file(
|
||||||
raise HTTPException(status_code=404, detail="Not found")
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
s = plex_stream.current_session(str(rating_key))
|
s = plex_stream.current_session(str(rating_key))
|
||||||
if s is None:
|
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)
|
it = _item_or_404(db, rating_key)
|
||||||
if filename != "index.m3u8" or it.playable != "remux":
|
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")
|
raise HTTPException(status_code=404, detail="No active playback session")
|
||||||
s = plex_stream.start_session(db, it, 0.0)
|
|
||||||
if s is None:
|
if s is None:
|
||||||
raise HTTPException(status_code=404, detail="Media file not found on disk")
|
raise HTTPException(status_code=404, detail="Media file not found on disk")
|
||||||
path = s.dir / filename
|
path = s.dir / filename
|
||||||
|
|
|
||||||
|
|
@ -260,6 +260,12 @@ export default function App() {
|
||||||
}
|
}
|
||||||
}, [plexFiltersRaw]);
|
}, [plexFiltersRaw]);
|
||||||
const setPlexFilters = (f: PlexFilters) => setPlexFiltersRaw(JSON.stringify(f));
|
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
|
// 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).
|
// there to see a specific set (the header's "without full history" link).
|
||||||
const [channelsFilterReset, setChannelsFilterReset] = useState(0);
|
const [channelsFilterReset, setChannelsFilterReset] = useState(0);
|
||||||
|
|
@ -767,6 +773,8 @@ export default function App() {
|
||||||
me={meQuery.data!}
|
me={meQuery.data!}
|
||||||
filters={filters}
|
filters={filters}
|
||||||
setFilters={setFilters}
|
setFilters={setFilters}
|
||||||
|
plexQ={plexQ}
|
||||||
|
setPlexQ={setPlexQ}
|
||||||
page={page}
|
page={page}
|
||||||
onYtSearch={enterYtSearch}
|
onYtSearch={enterYtSearch}
|
||||||
/>
|
/>
|
||||||
|
|
@ -819,8 +827,8 @@ export default function App() {
|
||||||
/>
|
/>
|
||||||
) : page === "plex" && meQuery.data!.plex_enabled ? (
|
) : page === "plex" && meQuery.data!.plex_enabled ? (
|
||||||
<PlexBrowse
|
<PlexBrowse
|
||||||
q={filters.q}
|
q={plexQ}
|
||||||
onClearSearch={() => setFilters({ ...filters, q: "" })}
|
onClearSearch={() => setPlexQ("")}
|
||||||
library={plexLib}
|
library={plexLib}
|
||||||
show={plexShowFilter}
|
show={plexShowFilter}
|
||||||
sort={plexSort}
|
sort={plexSort}
|
||||||
|
|
|
||||||
|
|
@ -12,12 +12,17 @@ export default function Header({
|
||||||
me,
|
me,
|
||||||
filters,
|
filters,
|
||||||
setFilters,
|
setFilters,
|
||||||
|
plexQ,
|
||||||
|
setPlexQ,
|
||||||
page,
|
page,
|
||||||
onYtSearch,
|
onYtSearch,
|
||||||
}: {
|
}: {
|
||||||
me: Me;
|
me: Me;
|
||||||
filters: FeedFilters;
|
filters: FeedFilters;
|
||||||
setFilters: (f: FeedFilters) => void;
|
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;
|
page: Page;
|
||||||
// Trigger a live YouTube search for the current term (hidden for the demo account, which
|
// Trigger a live YouTube search for the current term (hidden for the demo account, which
|
||||||
// can't spend the shared quota).
|
// can't spend the shared quota).
|
||||||
|
|
@ -25,11 +30,14 @@ export default function Header({
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const canSearchYt = !me.is_demo;
|
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
|
// 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 isSearchPage = page === "feed" || page === "plex";
|
||||||
|
const isPlex = page === "plex";
|
||||||
const isYtCapable = page === "feed" && canSearchYt;
|
const isYtCapable = page === "feed" && canSearchYt;
|
||||||
|
const searchValue = isPlex ? plexQ : filters.q;
|
||||||
|
const trimmedQ = searchValue.trim();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="glass h-14 shrink-0 border-b border-border flex items-center gap-3 px-4 z-20">
|
<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">
|
<div className="flex-1 relative">
|
||||||
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted" />
|
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted" />
|
||||||
<input
|
<input
|
||||||
value={filters.q}
|
value={searchValue}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const q = e.target.value;
|
const q = e.target.value;
|
||||||
|
if (isPlex) {
|
||||||
|
setPlexQ(q);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// When a search first appears, rank the feed by relevance — set atomically with
|
// 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
|
// 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.
|
// "newest" sort; a custom sort the user chose is left alone. Cleared in Feed.
|
||||||
const startSearch =
|
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" } : {}) });
|
setFilters({ ...filters, q, ...(startSearch ? { sort: "relevance" } : {}) });
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
|
|
@ -56,9 +68,9 @@ export default function Header({
|
||||||
placeholder={page === "plex" ? t("plex.searchPlaceholder") : t("header.searchPlaceholder")}
|
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"
|
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
|
<button
|
||||||
onClick={() => setFilters({ ...filters, q: "" })}
|
onClick={() => (isPlex ? setPlexQ("") : setFilters({ ...filters, q: "" }))}
|
||||||
title={t("header.clearSearch")}
|
title={t("header.clearSearch")}
|
||||||
aria-label={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"
|
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"
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,14 @@ import { lazy, Suspense, useEffect, useLayoutEffect, useRef, useState, type CSSP
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { ArrowLeft, CheckCircle2, Info, ListPlus, Play } from "lucide-react";
|
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 { useDebounced } from "../lib/useDebounced";
|
||||||
import { useHistorySubview } from "../lib/history";
|
import { useHistorySubview } from "../lib/history";
|
||||||
import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
|
import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
|
||||||
|
|
@ -267,7 +274,21 @@ export default function PlexBrowse({
|
||||||
{browseQ.isLoading ? (
|
{browseQ.isLoading ? (
|
||||||
<p className="text-muted text-sm">{t("plex.loading")}</p>
|
<p className="text-muted text-sm">{t("plex.loading")}</p>
|
||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
<p className="text-muted text-sm">{dq ? t("plex.noMatches") : t("plex.empty")}</p>
|
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">
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
|
||||||
{items.map((c) => (
|
{items.map((c) => (
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -57,6 +57,8 @@
|
||||||
"count": "{{count}} Titel",
|
"count": "{{count}} Titel",
|
||||||
"searchCount": "{{count}} Treffer",
|
"searchCount": "{{count}} Treffer",
|
||||||
"noMatches": "Keine 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…",
|
"loading": "Wird geladen…",
|
||||||
"empty": "Noch nichts hier. Starte eine Plex-Synchronisierung auf der Admin-Konfigurationsseite.",
|
"empty": "Noch nichts hier. Starte eine Plex-Synchronisierung auf der Admin-Konfigurationsseite.",
|
||||||
"loadMore": "Mehr laden",
|
"loadMore": "Mehr laden",
|
||||||
|
|
@ -120,8 +122,36 @@
|
||||||
"fullscreen": "Vollbild",
|
"fullscreen": "Vollbild",
|
||||||
"info": "Medieninfo",
|
"info": "Medieninfo",
|
||||||
"back": "Stopp & zurück zum Feed",
|
"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": {
|
"info": {
|
||||||
"title": "Medieninfo",
|
"title": "Medieninfo",
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,8 @@
|
||||||
"count": "{{count}} titles",
|
"count": "{{count}} titles",
|
||||||
"searchCount": "{{count}} matches",
|
"searchCount": "{{count}} matches",
|
||||||
"noMatches": "No 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…",
|
"loading": "Loading…",
|
||||||
"empty": "Nothing here yet. Run a Plex sync from the admin Config page.",
|
"empty": "Nothing here yet. Run a Plex sync from the admin Config page.",
|
||||||
"loadMore": "Load more",
|
"loadMore": "Load more",
|
||||||
|
|
@ -120,8 +122,36 @@
|
||||||
"fullscreen": "Fullscreen",
|
"fullscreen": "Fullscreen",
|
||||||
"info": "Media info",
|
"info": "Media info",
|
||||||
"back": "Stop & back to feed",
|
"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": {
|
"info": {
|
||||||
"title": "Media info",
|
"title": "Media info",
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,8 @@
|
||||||
"count": "{{count}} cím",
|
"count": "{{count}} cím",
|
||||||
"searchCount": "{{count}} találat",
|
"searchCount": "{{count}} találat",
|
||||||
"noMatches": "Nincs 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…",
|
"loading": "Betöltés…",
|
||||||
"empty": "Még nincs itt semmi. Futtass egy Plex-szinkront az admin Konfiguráció oldalról.",
|
"empty": "Még nincs itt semmi. Futtass egy Plex-szinkront az admin Konfiguráció oldalról.",
|
||||||
"loadMore": "Több betöltése",
|
"loadMore": "Több betöltése",
|
||||||
|
|
@ -120,8 +122,36 @@
|
||||||
"fullscreen": "Teljes képernyő",
|
"fullscreen": "Teljes képernyő",
|
||||||
"info": "Média infó",
|
"info": "Média infó",
|
||||||
"back": "Leállítás és vissza a feedre",
|
"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": {
|
"info": {
|
||||||
"title": "Média infó",
|
"title": "Média infó",
|
||||||
|
|
|
||||||
|
|
@ -1218,19 +1218,44 @@ export const api = {
|
||||||
req(`/api/plex/show/${encodeURIComponent(id)}`),
|
req(`/api/plex/show/${encodeURIComponent(id)}`),
|
||||||
plexItem: (id: string): Promise<PlexItemDetail> =>
|
plexItem: (id: string): Promise<PlexItemDetail> =>
|
||||||
req(`/api/plex/item/${encodeURIComponent(id)}`),
|
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))) });
|
const p = new URLSearchParams({ start: String(Math.max(0, Math.floor(start))) });
|
||||||
if (audio != null) p.set("audio", String(audio));
|
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" });
|
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.
|
// WebVTT URL for a text subtitle track (used directly as a <track src>). Same-origin → cookie-authed.
|
||||||
plexSubtitleUrl: (id: string, ord: number): string =>
|
// `offset` = the session's real media start (0 for direct-play); the backend shifts the absolute
|
||||||
`/api/plex/subtitle/${encodeURIComponent(id)}/${ord}`,
|
// 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> =>
|
plexProgress: (id: string, position_seconds: number, duration_seconds: number): Promise<unknown> =>
|
||||||
req(`/api/plex/item/${encodeURIComponent(id)}/progress`, {
|
req(`/api/plex/item/${encodeURIComponent(id)}/progress`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ position_seconds, duration_seconds }),
|
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> =>
|
plexSetState: (id: string, status: "new" | "watched" | "hidden"): Promise<unknown> =>
|
||||||
req(`/api/plex/item/${encodeURIComponent(id)}/state`, {
|
req(`/api/plex/item/${encodeURIComponent(id)}/state`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,21 @@ export interface ReleaseEntry {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RELEASE_NOTES: 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",
|
version: "0.32.1",
|
||||||
date: "2026-07-07",
|
date: "2026-07-07",
|
||||||
|
|
|
||||||
|
|
@ -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
|
// 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
|
// safely, and avoid collisions — instead of `"siftlode.x"` string literals scattered across
|
||||||
|
|
@ -158,3 +158,24 @@ export function useAccountPersistedState(
|
||||||
};
|
};
|
||||||
return [value, set];
|
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];
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue