diff --git a/backend/app/plex/models.py b/backend/app/plex/models.py index 7b39d04a..24808ff0 100644 --- a/backend/app/plex/models.py +++ b/backend/app/plex/models.py @@ -227,16 +227,17 @@ class PlexItem(Base, TimestampMixin, UpdatedAtMixin): playable: Mapped[str | None] = mapped_column(String(12), index=True) probed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # Transcode HW-capability facet (the playback-class filter). NULL for non-transcode items; for a - # `playable="transcode"` item: True = the prod iGPU can hardware-transcode it (codec is HW-decodable - # AND the source is 8-bit SDR), False = software libx264 only (AV1/XviD, or a 10-bit/HDR source that - # breaks the VAAPI nv12 pipeline — see plex/stream.py). Derived by `plex.sync.transcode_hw_capable`: - # set codec-only at sync (bit depth unknown from the cheap listing), then refined by the bit-depth - # backfill (`_probe_media_batch`) which downgrades a 10-bit/HDR HEVC to False. The one filterable - # home of the invariant `_want_hw`'s source gate computes at play time — kept in sync via the shared - # classifier so the catalog facet and the runtime encoder choice never disagree by codec. + # `playable="transcode"` item: True = the prod iGPU ENCODES it (a HW-decodable 8-bit SDR source, a + # true-HDR one through the OpenCL tonemap, or an 8-bit source in a codec the CPU decodes for it — + # VC-1, XviD, DivX), False = software libx264 (AV1, or a 10-bit SDR source — see plex/stream.py). + # Derived by `plex.sync.transcode_hw_capable`: set codec-only at sync (bit depth unknown from the + # cheap listing), then refined by the bit-depth backfill (`_probe_media_batch`), which downgrades a + # 10-bit SDR HEVC to False. The filterable twin of the rule `_select_encode` applies at play time; + # the two share their codec sets, not their code, so a change to one tree is a change to both. transcode_hw: Mapped[bool | None] = mapped_column(Boolean, index=True) # Video bit depth (8/10/12…) from the Plex media Stream, filled by the bit-depth backfill. NULL = - # not yet probed. Surfaced on the info page (e.g. "HEVC · 10-bit") and feeds `transcode_hw`. + # not yet probed. Surfaced on the info page (e.g. "HEVC · 10-bit") and feeds `transcode_hw` (a + # 10-bit SDR source drops to software; an HDR one keeps the GPU through the tonemap). video_bit_depth: Mapped[int | None] = mapped_column(Integer) # Watermark for the bit-depth backfill: when this item's media Stream was last probed for # bit-depth/HDR. NULL = never (the backfill's work-list). Only HW-decodable transcode items are diff --git a/backend/app/plex/stream.py b/backend/app/plex/stream.py index c220376d..42e31e67 100644 --- a/backend/app/plex/stream.py +++ b/backend/app/plex/stream.py @@ -4,9 +4,10 @@ Playback is from the LOCAL physical file. Browser-compatible files (playable="di raw with HTTP range requests. Everything else that is h264 video (playable="remux") is remuxed on the fly to HLS with **video stream-copy** (cheap, I/O-bound — no video re-encode) and audio to AAC when needed. Non-h264 video (playable="transcode": HEVC/VP9/AV1/MPEG-2/XviD…) needs a full re-encode -to H.264: on the prod host that runs on the Intel iGPU via VAAPI (`h264_vaapi`, near-zero CPU), with -software libx264 as the automatic fallback where the render node is absent or the source codec isn't -hardware-decodable. Same HLS session/seek machinery either way — only the video encoder differs. +to H.264: on the prod host that is encoded on the Intel iGPU via VAAPI (`h264_vaapi`) — decoded there +too when it can, else decoded on the CPU and uploaded (VC-1, XviD, DivX) — with software libx264 as +the fallback where the render node is absent, for a source neither GPU route takes (AV1, 10-bit SDR), +and when a GPU session produces nothing. Same HLS session/seek machinery either way. Seek model (Jellyfin-style): one ffmpeg session per item, started at a given offset via `-ss` (fast keyframe seek). A seek beyond the generated region restarts the session at the new offset, @@ -501,16 +502,17 @@ def _want_hw(db: DbSession, item: PlexItem, transcode: bool, src: Path) -> bool: return _select_encode(db, item, transcode, src)[0] -# CPU-nice increment for the SOFTWARE transcode child. The Plex ffmpeg is a Popen in the long-lived API -# process (which must NOT itself be niced), so — unlike the download worker's _deprioritize (run once in -# its own main()) — we lower priority per-child via preexec_fn in the fork. The GPU path needs none of -# this. Reuses the download child's nice default so all co-located re-encoders share one handicap. +# CPU-nice increment for a transcode child that works the CPU: a software encode, or a CPU decode +# feeding the GPU encoder. The Plex ffmpeg is a Popen in the long-lived API process (which must NOT +# itself be niced), so — unlike the download worker's _deprioritize (run once in its own main()) — we +# lower priority per-child via preexec_fn in the fork. The full-GPU path needs none of this. Reuses +# the download child's nice default so all co-located re-encoders share one handicap. _SW_TRANSCODE_NICE = settings.download_child_nice def _sw_child_preexec() -> None: - """Runs in the forked child (POSIX) before exec: renice this software-transcode ffmpeg down so it - can't starve the co-located API / Postgres / playback. Best-effort — a failed renice must not fail + """Runs in the forked child (POSIX) before exec: renice this CPU-bound transcode ffmpeg down so + it can't starve the co-located API / Postgres / playback. Best-effort — a failed renice must not fail playback.""" try: os.nice(_SW_TRANSCODE_NICE)