From 3359e3c111c7700e0bff0405bdaf483592fcd7c9 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Mon, 15 Jun 2026 13:13:26 -0700 Subject: [PATCH] video: owned-media tech specs on movie detail (Plex-grade) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We already scanned codec/audio/source/size but only showed resolution. The movie detail Details block now surfaces Quality / Video (HEVC, H.264…) / Audio / Source (Blu-ray, WEB-DL…) / Size, and lists every version/edition you own when there are multiple files. movie_detail now returns all media_files (not just the largest). --- database/video_database.py | 11 +++--- tests/test_video_database.py | 5 ++- webui/static/video/video-detail.js | 55 ++++++++++++++++++++++++++++-- webui/static/video/video-side.css | 9 +++++ 4 files changed, 72 insertions(+), 8 deletions(-) diff --git a/database/video_database.py b/database/video_database.py index 6758df3b..7f4dd110 100644 --- a/database/video_database.py +++ b/database/video_database.py @@ -1156,10 +1156,10 @@ class VideoDatabase: return None genres = self._genres_for(conn, "movie_genres", "movie_id", movie_id) credits = self._credits_for(conn, "movie_id", movie_id) - f = conn.execute( - "SELECT resolution, quality, video_codec, audio_codec, size_bytes " - "FROM media_files WHERE movie_id=? ORDER BY size_bytes DESC LIMIT 1", - (movie_id,)).fetchone() + files = conn.execute( + "SELECT resolution, quality, video_codec, audio_codec, release_source, size_bytes " + "FROM media_files WHERE movie_id=? ORDER BY size_bytes DESC", + (movie_id,)).fetchall() finally: conn.close() return { @@ -1174,7 +1174,8 @@ class VideoDatabase: "has_poster": bool(m["poster_url"]), "has_backdrop": bool(m["backdrop_url"]), "logo": m["logo_url"], "owned": bool(m["has_file"]), "monitored": bool(m["monitored"]), - "file": (dict(f) if f else None), + "file": (dict(files[0]) if files else None), # best version (compat) + "files": [dict(x) for x in files], # all versions/editions } # ── paged/filtered/sorted library query (server-side, like music) ───────── diff --git a/tests/test_video_database.py b/tests/test_video_database.py index ae48fb7e..695ad360 100644 --- a/tests/test_video_database.py +++ b/tests/test_video_database.py @@ -312,10 +312,13 @@ def test_movie_detail_includes_owned_and_file(db): d = db.movie_detail(mid) assert d["title"] == "Dune" and d["owned"] is True and d["tmdb_id"] == 438631 assert d["file"] and d["file"]["resolution"] == "2160p" + # Full media specs surface for the owned-media block. + assert "video_codec" in d["file"] and "release_source" in d["file"] + assert d["files"] == [d["file"]] # all versions (one here) # A wishlist movie with no file reports owned False, file None. mid2 = db.upsert_movie("plex", {"server_id": "m2", "title": "Wanted"}) d2 = db.movie_detail(mid2) - assert d2["owned"] is False and d2["file"] is None + assert d2["owned"] is False and d2["file"] is None and d2["files"] == [] def test_get_art_ref_poster_vs_backdrop(db): diff --git a/webui/static/video/video-detail.js b/webui/static/video/video-detail.js index 226dff0e..4166376f 100644 --- a/webui/static/video/video-detail.js +++ b/webui/static/video/video-detail.js @@ -348,6 +348,40 @@ a.innerHTML = html; } + function mediaRes(r) { + if (!r) return ''; + r = String(r).toLowerCase(); + if (r.indexOf('2160') > -1 || r === '4k') return '4K'; + if (r.indexOf('1080') > -1) return '1080p'; + if (r.indexOf('720') > -1) return '720p'; + if (r.indexOf('480') > -1 || r.indexOf('576') > -1) return 'SD'; + return r.toUpperCase(); + } + function prettyCodec(c) { + if (!c) return ''; + var l = String(c).toLowerCase(); + if (l.indexOf('hevc') > -1 || l.indexOf('265') > -1) return 'HEVC'; + if (l.indexOf('264') > -1 || l === 'avc') return 'H.264'; + if (l.indexOf('av1') > -1) return 'AV1'; + if (l.indexOf('vp9') > -1) return 'VP9'; + return String(c).toUpperCase(); + } + function prettySource(s) { + var map = { bluray: 'Blu-ray', 'web-dl': 'WEB-DL', webdl: 'WEB-DL', webrip: 'WEBRip', + hdtv: 'HDTV', youtube: 'YouTube', dvd: 'DVD', remux: 'Remux' }; + return map[String(s || '').toLowerCase()] || String(s || ''); + } + function fmtBytes(n) { + if (!n) return ''; + var gb = n / 1073741824; + return gb >= 1 ? (Math.round(gb * 10) / 10) + ' GB' : Math.round(n / 1048576) + ' MB'; + } + function fileSummary(v) { + return [mediaRes(v.resolution), prettyCodec(v.video_codec), + v.audio_codec ? String(v.audio_codec).toUpperCase() : '', fmtBytes(v.size_bytes), + v.release_source ? prettySource(v.release_source) : ''].filter(Boolean).join(' · '); + } + function renderDetails(d) { var host = q('[data-vd-details]'); if (!host) return; @@ -357,13 +391,30 @@ if (d.studio) rows.push(['Studio', d.studio]); if (d.status) rows.push(['Status', statusLabel(d.status)]); if (d.rating_critic) rows.push(['Critic score', Math.round(d.rating_critic) + '%']); - if (d.file && d.file.resolution) rows.push(['Quality', String(d.file.resolution).toUpperCase()]); - host.innerHTML = rows.length + // Your media — the technical specs we scanned (Plex-grade). + var f = d.file; + if (f) { + if (f.resolution) rows.push(['Quality', mediaRes(f.resolution)]); + if (f.video_codec) rows.push(['Video', prettyCodec(f.video_codec)]); + if (f.audio_codec) rows.push(['Audio', String(f.audio_codec).toUpperCase()]); + if (f.release_source) rows.push(['Source', prettySource(f.release_source)]); + if (f.size_bytes) rows.push(['Size', fmtBytes(f.size_bytes)]); + } + var html = rows.length ? '
' + rows.map(function (r) { return '
' + esc(r[0]) + '' + esc(r[1]) + '
'; }).join('') + '
' : ''; + // Multiple versions / editions you own. + var files = d.files || []; + if (files.length > 1) { + html += '
' + files.length + ' versions
' + + files.map(function (v) { + return '
' + esc(fileSummary(v)) + '
'; + }).join('') + '
'; + } + host.innerHTML = html; } // ── live TMDB extras (trailer / where-to-watch / similar) ───────────────── diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index 9e4dfed1..5dc3434b 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -1416,3 +1416,12 @@ a.vd-prov:hover img, a.vd-prov:hover .vd-prov-ph { border-color: rgba(var(--vd-a /* settings: detail-pages preference toggle */ .vid-pref-row { display: flex; align-items: center; gap: 10px; cursor: pointer; font-size: 14px; color: var(--text-primary, #fff); } .vid-pref-row input { width: 16px; height: 16px; cursor: pointer; accent-color: #38bdf8; } + +/* ── owned media: multiple versions/editions ─────────────────────────────── */ +.vd-versions { margin-top: 18px; } +.vd-versions-h { font-size: 11px; text-transform: uppercase; letter-spacing: 0.6px; color: rgba(255, 255, 255, 0.45); margin-bottom: 8px; } +.vd-version { + display: inline-block; margin: 0 8px 8px 0; padding: 7px 13px; border-radius: 8px; + font-size: 13px; font-weight: 600; color: rgba(255, 255, 255, 0.85); + background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.1); +}