Improve library identification and track inspection

This commit is contained in:
scoped
2026-05-15 17:04:26 +00:00
parent 1ffb68e74c
commit 79308a84b9
6 changed files with 530 additions and 51 deletions

View File

@@ -108,6 +108,7 @@ const state = {
libraryTab: "all",
libraryLimit: 120,
selectedMedia: null,
detailModalOpen: false,
};
const $ = (id) => document.getElementById(id);
@@ -257,7 +258,11 @@ function libraryCollections() {
const all = [
...collections.movies.map((item) => ({ ...item, library: "movie" })),
...collections.series.map((item) => ({ ...item, library: "tv" })),
];
].sort((a, b) => {
const aTitle = (a.metadata?.title || a.title || "").toLowerCase();
const bTitle = (b.metadata?.title || b.title || "").toLowerCase();
return aTitle.localeCompare(bTitle);
});
return all.filter((item) => {
const meta = item.metadata || {};
const matchesTab = state.libraryTab === "all" || item.library === state.libraryTab;
@@ -303,11 +308,6 @@ function renderLibrary() {
renderLibrary();
});
}
if (!state.selectedMedia && visible[0]) {
selectMedia(visible[0].key, false);
} else if (state.selectedMedia) {
renderMediaDetail(state.selectedMedia);
}
}
function mediaCard(item) {
@@ -334,39 +334,68 @@ function findMedia(key) {
return [...collections.movies, ...collections.series].find((item) => item.key === key);
}
function selectMedia(key, scroll = true) {
function selectMedia(key) {
const item = findMedia(key);
if (!item) return;
state.selectedMedia = item;
state.detailModalOpen = true;
document.querySelectorAll("[data-media-key]").forEach((button) => button.classList.toggle("active", button.dataset.mediaKey === key));
renderMediaDetail(item);
if (scroll) $("libraryDetail").scrollIntoView({ behavior: "smooth", block: "start" });
}
function renderMediaDetail(item) {
closeMediaModal();
const meta = item.metadata || {};
const files = item.files || [];
const title = meta.title || item.title;
const cover = meta.poster ? `<img src="${esc(meta.poster)}" alt="">` : `<span class="poster-placeholder">${esc(title.slice(0, 1) || "?")}</span>`;
const detail = item.library === "tv" ? renderSeriesDetail(item) : renderMovieDetail(item);
$("libraryDetail").innerHTML = `<article class="detail-shell">
<div class="poster detail-poster">${cover}</div>
<div class="detail-body">
<div class="section-head">
<div>
<h2>${esc(title)}</h2>
<p class="muted">${esc(item.library === "tv" ? "TV Series" : "Movie")} ${meta.source === "tmdb" ? "from TMDb metadata" : "from local filenames"}</p>
const modal = document.createElement("div");
modal.id = "mediaModal";
modal.className = "modal-backdrop";
modal.innerHTML = `<article class="media-modal" role="dialog" aria-modal="true" aria-label="${esc(title)}">
<button class="modal-close" type="button" data-modal-close aria-label="Close">x</button>
<div class="detail-shell">
<div class="poster detail-poster">${cover}</div>
<div class="detail-body">
<div class="section-head">
<div>
<h2>${esc(title)}</h2>
<p class="muted">${esc(item.library === "tv" ? "TV Series" : "Movie")} ${meta.source === "tmdb" ? "from TMDb metadata" : "from local filenames"}</p>
</div>
<div class="actions">
${files[0] ? `<button data-probe-path="${esc(files[0].path)}">Inspect media</button>` : ""}
<button data-identify-media type="button">Identify</button>
</div>
</div>
${files[0] ? `<button data-probe-path="${esc(files[0].path)}">Inspect media</button>` : ""}
${meta.overview ? `<p>${esc(meta.overview)}</p>` : ""}
<div id="identifyOutput" class="identify-output"></div>
<div id="probeOutput" class="probe-output"></div>
${detail}
</div>
${meta.overview ? `<p>${esc(meta.overview)}</p>` : ""}
${detail}
<div id="probeOutput" class="probe-output"></div>
</div>
</article>`;
document.body.appendChild(modal);
document.body.classList.add("modal-open");
state.detailModalOpen = true;
modal.addEventListener("click", (event) => {
if (event.target === modal || event.target.closest("[data-modal-close]")) {
closeMediaModal();
}
});
document.querySelectorAll("[data-probe-path]").forEach((button) => {
button.addEventListener("click", () => inspectMedia(button.dataset.probePath));
});
document.querySelectorAll("[data-identify-media]").forEach((button) => {
button.addEventListener("click", () => openIdentifyPanel(item));
});
}
function closeMediaModal() {
const modal = $("mediaModal");
if (modal) modal.remove();
document.body.classList.remove("modal-open");
state.detailModalOpen = false;
}
function renderMovieDetail(item) {
@@ -386,13 +415,100 @@ function renderMovieDetail(item) {
function versionRow(version) {
const tags = (version.tags || []).map((tag) => `<span>${esc(tag)}</span>`).join("");
return `<div class="download">
<strong>${esc(version.name || "")}</strong>
<div class="bundle-head">
<div>
<strong>${esc(version.name || "")}</strong>
<small class="muted">${esc(version.path || "")}</small>
</div>
${version.path ? `<button data-probe-path="${esc(version.path)}" type="button">Inspect</button>` : ""}
</div>
<div class="kv"><span>${esc(version.drive || "")}</span><span>${bytes(version.size)}</span></div>
${tags ? `<div class="subtitle-chips">${tags}</div>` : ""}
<small class="muted">${esc(version.path || "")}</small>
</div>`;
}
function identifyYear(item) {
const meta = item.metadata || {};
const raw = item.year || meta.release_date || meta.first_air_date || "";
const match = String(raw).match(/(19\d{2}|20\d{2})/);
return match ? Number(match[1]) : "";
}
function openIdentifyPanel(item) {
const output = $("identifyOutput");
if (!output) return;
const meta = item.metadata || {};
const title = meta.title || item.title || "";
const year = identifyYear(item);
output.innerHTML = `<div class="identify-panel">
<div class="section-head">
<div>
<h3>Identify ${item.library === "tv" ? "Series" : "Movie"}</h3>
<p class="muted">Search TMDb and apply the correct match to this library item.</p>
</div>
</div>
<div class="identify-search">
<input id="identifyQuery" value="${esc(title)}" placeholder="Title">
<input id="identifyYear" value="${esc(year)}" placeholder="Year" type="number" min="1900" max="2100">
<button id="identifySearchButton" type="button">Search</button>
</div>
<div id="identifyResults" class="identify-results"></div>
</div>`;
$("identifySearchButton").addEventListener("click", () => searchIdentify(item));
searchIdentify(item).catch((error) => {
$("identifyResults").innerHTML = `<p class="muted">Search failed: ${esc(error.message)}</p>`;
});
}
async function searchIdentify(item) {
const results = $("identifyResults");
const query = $("identifyQuery").value.trim();
const year = Number($("identifyYear").value) || null;
results.innerHTML = "<p class='muted'>Searching TMDb...</p>";
const payload = await api("/api/library/identify/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ library: item.library, query, year }),
});
results.innerHTML = (payload.results || []).map((result) => `
<article class="identify-result">
<div class="poster tiny">${result.poster ? `<img src="${esc(result.poster)}" alt="">` : `<span class="poster-placeholder">${esc((result.title || "?").slice(0, 1))}</span>`}</div>
<div>
<strong>${esc(result.title || "Untitled")}</strong>
<small class="muted">${esc(result.year || result.date || "")} ${result.vote_average ? `- ${esc(result.vote_average)}` : ""}</small>
${result.overview ? `<p class="muted">${esc(result.overview)}</p>` : ""}
</div>
<button data-apply-identify="${esc(result.tmdb_id)}" type="button">Apply</button>
</article>
`).join("") || "<p class='muted'>No TMDb matches found.</p>";
document.querySelectorAll("[data-apply-identify]").forEach((button) => {
button.addEventListener("click", () => applyIdentify(item, Number(button.dataset.applyIdentify)));
});
}
function replaceCollection(collection) {
const collections = activeLibrary().collections || {};
const group = collection.library === "tv" ? "series" : "movies";
const list = collections[group] || [];
const idx = list.findIndex((item) => item.key === collection.key);
if (idx >= 0) list[idx] = collection;
state.selectedMedia = collection;
}
async function applyIdentify(item, tmdbId) {
const results = $("identifyResults");
results.insertAdjacentHTML("afterbegin", "<p class='muted'>Applying match...</p>");
const payload = await api("/api/library/identify/apply", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key: item.key, library: item.library, tmdb_id: tmdbId }),
});
replaceCollection(payload.collection);
renderLibrary();
renderMediaDetail(payload.collection);
toast("Identification applied.", "success");
}
function renderSeriesDetail(item) {
return `<div class="season-list">${(item.seasons || []).map((season) => `
<details open>
@@ -423,51 +539,86 @@ function fileRow(file) {
async function inspectMedia(path) {
const output = $("probeOutput");
output.innerHTML = "<p class='muted'>Inspecting media streams...</p>";
output.innerHTML = "<div class='detail-block'><p class='muted'>Inspecting media streams...</p></div>";
const modal = document.querySelector(".media-modal");
if (modal) modal.scrollTo({ top: 0, behavior: "smooth" });
const payload = await api(`/api/media/probe?path=${encodeURIComponent(path)}`);
const media = payload.media;
state.currentProbePath = path;
output.innerHTML = `<div class="detail-block">
<h3>Media Info</h3>
<div class="stream-grid">
<section><strong>Video</strong>${streamRows(media.video)}</section>
<section><strong>Audio Tracks</strong>${streamRows(media.audio)}</section>
<section><strong>Subtitles</strong>${streamRows(media.subtitles)}</section>
const filename = path.split("/").pop();
const dryRun = state.dashboard?.dry_run;
output.innerHTML = `<div class="detail-block media-inspector">
<div class="section-head">
<div>
<h3>Media Tracks</h3>
<p class="muted">${esc(filename || path)}</p>
</div>
${dryRun ? "<span class='status-pill warn'>Dry-run</span>" : "<span class='status-pill good'>Edits enabled</span>"}
</div>
<p class="muted">Track edits remux the selected file. Dry-run mode reports the command without changing the file.</p>
${dryRun ? "<p class='muted'>Dry-run is enabled, so track changes will preview the ffmpeg command without modifying the file.</p>" : "<p class='muted'>Track changes remux this file in place. Use one action at a time.</p>"}
<div class="stream-grid">
<section><strong>Video</strong>${streamRows(media.video, "video")}</section>
<section><strong>Audio Tracks</strong>${streamRows(media.audio, "audio")}</section>
<section><strong>Subtitle Tracks</strong>${streamRows(media.subtitles, "subtitle")}</section>
</div>
<div id="trackEditStatus"></div>
</div>`;
document.querySelectorAll("[data-track-action]").forEach((button) => {
button.addEventListener("click", () => editTrack(path, button.dataset.trackAction, Number(button.dataset.streamIndex)));
});
}
function streamRows(streams = []) {
function streamTitle(stream, type) {
const tags = stream.tags || {};
if (type === "audio") {
return `${tags.language || "und"} ${stream.channels ? `${stream.channels} ch` : ""} ${stream.codec_name || ""}`.trim();
}
if (type === "subtitle") {
return `${tags.language || "und"} ${stream.codec_name || "subtitle"}`.trim();
}
return `${stream.codec_name || "video"} ${stream.width && stream.height ? `${stream.width}x${stream.height}` : ""}`.trim();
}
function streamMeta(stream) {
const tags = stream.tags || {};
return [tags.title, stream.disposition?.default ? "Default" : "", stream.bit_rate ? `${Math.round(Number(stream.bit_rate) / 1000)} kbps` : ""]
.filter(Boolean)
.join(" - ");
}
function streamRows(streams = [], type = "") {
return streams.map((stream) => {
const tags = stream.tags || {};
return `<div class="stream-row">
<span>${esc(stream.codec_name || stream.codec_type || "unknown")}</span>
<small>${esc(tags.language || "und")} ${esc(tags.title || "")} ${stream.channels ? `${stream.channels} ch` : ""}</small>
${stream.codec_type === "audio" || stream.codec_type === "subtitle" ? `<span class="track-actions">
<button data-track-action="set-default" data-stream-index="${stream.index}">Set default</button>
<button data-track-action="remove" data-stream-index="${stream.index}">Remove</button>
const isEditable = stream.codec_type === "audio" || stream.codec_type === "subtitle";
return `<div class="stream-row ${stream.disposition?.default ? "is-default" : ""}">
<div>
<strong>${esc(streamTitle(stream, type))}</strong>
<small>${esc(streamMeta(stream) || `Stream ${stream.index}`)}</small>
</div>
${tags.title ? `<small class="muted">${esc(tags.title)}</small>` : ""}
${isEditable ? `<span class="track-actions">
<button data-track-action="set-default" data-stream-index="${stream.index}">Make default</button>
<button class="danger" data-track-action="remove" data-stream-index="${stream.index}">Remove</button>
</span>` : ""}
</div>`;
}).join("") || "<p class='muted'>None detected.</p>";
}
async function editTrack(path, action, streamIndex) {
const output = $("probeOutput");
const output = $("trackEditStatus") || $("probeOutput");
output.innerHTML = "<div class='event'><strong>Applying track change...</strong></div>";
const payload = await api("/api/media/tracks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path, action, stream_index: streamIndex }),
});
const result = payload.media;
output.insertAdjacentHTML("afterbegin", `<div class="event ${result.status === "failed" ? "error" : ""}">
output.innerHTML = `<div class="event ${result.status === "failed" ? "error" : result.status === "updated" ? "success" : ""}">
<strong>Track edit: ${esc(result.status)}</strong>
${result.status === "dry-run" ? "<small class='muted'>Dry-run is enabled. Disable dry-run in Settings to apply track edits.</small>" : ""}
${result.command ? `<code>${esc(result.command.join(" "))}</code>` : ""}
${result.stderr ? `<pre>${esc(result.stderr)}</pre>` : ""}
</div>`);
</div>`;
if (result.status === "updated") {
await inspectMedia(path);
}
@@ -937,6 +1088,9 @@ function showStartupError(error) {
function init() {
setTheme(localStorage.getItem("sortarr-theme") || "slate");
window.addEventListener("hashchange", renderRoute);
window.addEventListener("keydown", (event) => {
if (event.key === "Escape") closeMediaModal();
});
renderRoute();
$("refreshButton").addEventListener("click", loadDashboard);
$("scanButton").addEventListener("click", runScan);

View File

@@ -146,6 +146,6 @@
</main>
</div>
<div id="toastHost" class="toast-host" aria-live="polite"></div>
<script src="/app.js?v=20260514-3"></script>
<script src="/app.js?v=20260515-4"></script>
</body>
</html>

View File

@@ -146,6 +146,14 @@ td:first-child {
height: 100%;
object-fit: cover;
}
.poster.tiny {
width: 54px;
min-width: 54px;
aspect-ratio: 2 / 3;
}
.poster.tiny .poster-placeholder {
font-size: 20px;
}
.poster-placeholder {
display: grid;
place-items: center;
@@ -182,6 +190,47 @@ td:first-child {
.media-detail {
margin-top: 22px;
}
.modal-open {
overflow: hidden;
}
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 1000;
display: grid;
align-items: start;
justify-items: center;
overflow: auto;
padding: 32px;
background: rgba(0, 0, 0, .62);
}
.media-modal {
position: relative;
width: min(1120px, 100%);
max-height: calc(100vh - 64px);
overflow: auto;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
box-shadow: 0 24px 80px rgba(0, 0, 0, .45);
padding: 22px;
}
.media-modal .detail-shell {
border-top: 0;
padding-top: 0;
}
.modal-close {
position: sticky;
top: 0;
float: right;
z-index: 2;
width: 34px;
height: 34px;
padding: 0;
border-radius: 999px;
font-size: 18px;
line-height: 1;
}
.detail-shell {
display: grid;
grid-template-columns: 190px minmax(0, 1fr);
@@ -198,10 +247,42 @@ td:first-child {
min-width: 0;
}
.detail-block,
.season-list {
.season-list,
.identify-output {
display: grid;
gap: 12px;
}
.identify-panel {
display: grid;
gap: 12px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface-2);
padding: 12px;
}
.identify-search {
display: grid;
grid-template-columns: minmax(0, 1fr) 110px auto;
gap: 10px;
}
.identify-results {
display: grid;
gap: 10px;
}
.identify-result {
display: grid;
grid-template-columns: 54px minmax(0, 1fr) auto;
gap: 12px;
align-items: start;
padding: 10px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
}
.identify-result p {
margin-top: 4px;
line-height: 1.35;
}
.season-list details {
border: 1px solid var(--border);
border-radius: var(--radius);
@@ -257,19 +338,60 @@ td:first-child {
}
.stream-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 6px;
padding-top: 8px;
border-top: 1px solid var(--border);
align-items: center;
padding: 10px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
}
.stream-row.is-default {
border-color: var(--good);
}
.stream-row strong,
.stream-row small {
display: block;
overflow-wrap: anywhere;
}
.track-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
justify-content: flex-end;
}
.track-actions button {
padding: 6px 8px;
font-size: 12px;
}
button.danger {
border-color: color-mix(in srgb, var(--bad) 60%, var(--border));
color: var(--bad);
}
.status-pill {
display: inline-grid;
place-items: center;
min-height: 28px;
padding: 4px 10px;
border: 1px solid var(--border);
border-radius: 999px;
font-size: 12px;
font-weight: 700;
}
.status-pill.good {
color: var(--good);
border-color: color-mix(in srgb, var(--good) 55%, var(--border));
}
.status-pill.warn {
color: var(--warn);
border-color: color-mix(in srgb, var(--warn) 55%, var(--border));
}
.media-inspector {
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface-2);
padding: 12px;
}
.downloads-layout {
display: grid;
grid-template-columns: minmax(320px, 1fr) minmax(0, 1.2fr) minmax(320px, .8fr);