-
${esc(title)}
-
${esc(item.library === "tv" ? "TV Series" : "Movie")} ${meta.source === "tmdb" ? "from TMDb metadata" : "from local filenames"}
+ const modal = document.createElement("div");
+ modal.id = "mediaModal";
+ modal.className = "modal-backdrop";
+ modal.innerHTML = `
+
+
+
${cover}
+
+
+
+
${esc(title)}
+
${esc(item.library === "tv" ? "TV Series" : "Movie")} ${meta.source === "tmdb" ? "from TMDb metadata" : "from local filenames"}
+
+
+ ${files[0] ? `` : ""}
+
+
- ${files[0] ? `
` : ""}
+ ${meta.overview ? `
${esc(meta.overview)}
` : ""}
+
+
+ ${detail}
- ${meta.overview ? `
${esc(meta.overview)}
` : ""}
- ${detail}
-
`;
+ 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) => `
${esc(tag)}`).join("");
return `
-
${esc(version.name || "")}
+
+
+ ${esc(version.name || "")}
+ ${esc(version.path || "")}
+
+ ${version.path ? `
` : ""}
+
${esc(version.drive || "")}${bytes(version.size)}
${tags ? `
${tags}
` : ""}
-
${esc(version.path || "")}
`;
}
+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 = `
+
+
+
Identify ${item.library === "tv" ? "Series" : "Movie"}
+
Search TMDb and apply the correct match to this library item.
+
+
+
+
+
+
+
+
+
`;
+ $("identifySearchButton").addEventListener("click", () => searchIdentify(item));
+ searchIdentify(item).catch((error) => {
+ $("identifyResults").innerHTML = `
Search failed: ${esc(error.message)}
`;
+ });
+}
+
+async function searchIdentify(item) {
+ const results = $("identifyResults");
+ const query = $("identifyQuery").value.trim();
+ const year = Number($("identifyYear").value) || null;
+ results.innerHTML = "
Searching TMDb...
";
+ 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) => `
+
+ ${result.poster ? `
})
` : `
${esc((result.title || "?").slice(0, 1))}`}
+
+
${esc(result.title || "Untitled")}
+
${esc(result.year || result.date || "")} ${result.vote_average ? `- ${esc(result.vote_average)}` : ""}
+ ${result.overview ? `
${esc(result.overview)}
` : ""}
+
+
+
+ `).join("") || "
No TMDb matches found.
";
+ 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", "
Applying match...
");
+ 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 `
${(item.seasons || []).map((season) => `
@@ -423,51 +539,86 @@ function fileRow(file) {
async function inspectMedia(path) {
const output = $("probeOutput");
- output.innerHTML = "Inspecting media streams...
";
+ output.innerHTML = "Inspecting media streams...
";
+ 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 = `
-
Media Info
-
-
Video${streamRows(media.video)}
-
Audio Tracks${streamRows(media.audio)}
-
Subtitles${streamRows(media.subtitles)}
+ const filename = path.split("/").pop();
+ const dryRun = state.dashboard?.dry_run;
+ output.innerHTML = `
`;
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 `
-
${esc(stream.codec_name || stream.codec_type || "unknown")}
-
${esc(tags.language || "und")} ${esc(tags.title || "")} ${stream.channels ? `${stream.channels} ch` : ""}
- ${stream.codec_type === "audio" || stream.codec_type === "subtitle" ? `
-
-
+ const isEditable = stream.codec_type === "audio" || stream.codec_type === "subtitle";
+ return `
+
+ ${esc(streamTitle(stream, type))}
+ ${esc(streamMeta(stream) || `Stream ${stream.index}`)}
+
+ ${tags.title ? `
${esc(tags.title)}` : ""}
+ ${isEditable ? `
+
+
` : ""}
`;
}).join("") || "None detected.
";
}
async function editTrack(path, action, streamIndex) {
- const output = $("probeOutput");
+ const output = $("trackEditStatus") || $("probeOutput");
+ output.innerHTML = "Applying track change...
";
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", `
+ output.innerHTML = `
Track edit: ${esc(result.status)}
+ ${result.status === "dry-run" ? "
Dry-run is enabled. Disable dry-run in Settings to apply track edits." : ""}
${result.command ? `
${esc(result.command.join(" "))}` : ""}
${result.stderr ? `
${esc(result.stderr)}` : ""}
-
`);
+
`;
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);
diff --git a/web/src/index.html b/web/src/index.html
index d09d61b..5c045ae 100644
--- a/web/src/index.html
+++ b/web/src/index.html
@@ -146,6 +146,6 @@
-
+