Initial commit
This commit is contained in:
4
web/Dockerfile
Normal file
4
web/Dockerfile
Normal file
@@ -0,0 +1,4 @@
|
||||
FROM nginx:1.27-alpine
|
||||
COPY src /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
32
web/nginx.conf
Normal file
32
web/nginx.conf
Normal file
@@ -0,0 +1,32 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types application/json application/javascript text/css text/plain;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8099/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-store";
|
||||
}
|
||||
|
||||
location = /app.js {
|
||||
add_header Cache-Control "no-store";
|
||||
}
|
||||
|
||||
location / {
|
||||
add_header Cache-Control "no-cache";
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
969
web/src/app.js
Normal file
969
web/src/app.js
Normal file
@@ -0,0 +1,969 @@
|
||||
const themes = ["slate", "midnight", "graphite", "nord", "dracula", "solar", "forest", "marine", "ember", "paper"];
|
||||
const themeLabels = {
|
||||
slate: "Slate",
|
||||
midnight: "Midnight",
|
||||
graphite: "Graphite",
|
||||
nord: "Nord",
|
||||
dracula: "Dracula",
|
||||
solar: "Solar",
|
||||
forest: "Forest",
|
||||
marine: "Marine",
|
||||
ember: "Ember",
|
||||
paper: "Paper",
|
||||
};
|
||||
const settingsGroups = [
|
||||
{
|
||||
title: "Organizer",
|
||||
description: "Controls how Sortarr watches /downloads, decides what is safe to move, and handles uncertain matches.",
|
||||
fields: [
|
||||
["app.dry_run", "Dry-run mode", "checkbox", "Plan files without moving them. Disable only when destinations and confidence scores look correct."],
|
||||
["app.scan_interval_seconds", "Scan interval", "range", "How often the background scanner checks /downloads.", { min: 5, max: 300, step: 5, unit: "sec" }],
|
||||
["app.settle_seconds", "File settle time", "range", "How long a file must remain unchanged before Sortarr can plan or move it.", { min: 10, max: 1800, step: 10, unit: "sec" }],
|
||||
["app.stable_checks", "Stable checks", "range", "Number of matching size/mtime observations expected before a file is considered stable.", { min: 1, max: 8, step: 1, unit: "checks" }],
|
||||
["app.auto_move_min_confidence", "Auto-move confidence", "range", "Plans at or above this score can move automatically when dry-run is off.", { min: 50, max: 100, step: 1, unit: "%" }],
|
||||
["app.review_min_confidence", "Review confidence", "range", "Plans at or above this score stay in the review queue instead of being treated as low confidence.", { min: 0, max: 100, step: 1, unit: "%" }],
|
||||
["app.organization_metadata_budget_seconds", "Metadata budget", "range", "Maximum total TMDb lookup time per organizer pass before Sortarr falls back to filename-only planning.", { min: 0, max: 120, step: 5, unit: "sec" }],
|
||||
["app.organization_metadata_timeout_seconds", "Metadata timeout", "range", "Maximum time a single TMDb request can wait.", { min: 1, max: 15, step: 1, unit: "sec" }],
|
||||
["app.metadata_parallelism", "Metadata parallelism", "range", "How many TMDb lookups a library scan can run at the same time.", { min: 1, max: 12, step: 1, unit: "workers" }],
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Scanning",
|
||||
description: "Limits for library indexing and file classification.",
|
||||
fields: [
|
||||
["app.library_scan_max_files", "Library scan file limit", "range", "Maximum filesystem entries inspected by a manual library scan.", { min: 1000, max: 250000, step: 1000, unit: "files" }],
|
||||
["app.library_scan_timeout_seconds", "Library scan timeout", "range", "Maximum runtime for a manual library scan before returning a partial result.", { min: 3, max: 180, step: 1, unit: "sec" }],
|
||||
["app.cache_max_bytes", "Server cache limit", "range", "Maximum cache size for server-side metadata/probe data.", { min: 1073741824, max: 21474836480, step: 1073741824, unit: "bytes" }],
|
||||
["app.media_extensions", "Media extensions", "list", "Extensions treated as media files in /downloads."],
|
||||
["app.subtitle_extensions", "Subtitle extensions", "list", "Extensions packaged with matching movies and episodes."],
|
||||
["app.incomplete_suffixes", "Incomplete suffixes", "list", "Suffixes ignored while downloads are still active."],
|
||||
["app.extra_keywords", "Extra ignore keywords", "list", "Filename terms that identify extras rather than primary media."],
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Paths",
|
||||
description: "Container paths used by the backend. Host bind mounts are still controlled by Docker compose and .env.",
|
||||
fields: [
|
||||
["paths.downloads", "Downloads path", "text", "Container path Sortarr watches for new downloads."],
|
||||
["paths.data", "Data path", "text", "Container path for state and runtime data."],
|
||||
["paths.logs", "Logs path", "text", "Container path for backend logs."],
|
||||
["paths.cache", "Cache path", "text", "Container path for metadata and probe caches."],
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Library Naming",
|
||||
description: "Templates used when Sortarr creates destination folders and filenames.",
|
||||
fields: [
|
||||
["library.movie_folder", "Movie folder", "text", "Folder template for movies."],
|
||||
["library.series_folder", "Series folder", "text", "Folder template for TV episodes."],
|
||||
["library.movie_file", "Movie filename", "text", "Filename template for movies."],
|
||||
["library.episode_file", "Episode filename", "text", "Filename template for TV episodes."],
|
||||
["library.subtitle_file", "Subtitle filename", "text", "Filename template for packaged subtitles."],
|
||||
["library.unknown_folder", "Unknown folder", "text", "Fallback folder for media that cannot be confidently classified."],
|
||||
["library.collision", "File collision policy", "select", "What to do when the destination file already exists.", { options: ["keep-both", "skip", "replace"] }],
|
||||
["library.duplicate", "Duplicate policy", "select", "How duplicate titles should be handled.", { options: ["skip", "keep-both"] }],
|
||||
["library.permissions_mode", "File permissions", "text", "Octal mode applied to moved media files."],
|
||||
["library.directory_mode", "Directory permissions", "text", "Octal mode intended for created library folders."],
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Metadata",
|
||||
description: "TMDb and local metadata behavior.",
|
||||
fields: [
|
||||
["metadata.tmdb_enabled", "TMDb enabled", "checkbox", "Allow Sortarr to enrich plans and library items with TMDb data."],
|
||||
["metadata.write_nfo", "Write NFO files", "checkbox", "Write simple NFO metadata beside moved files."],
|
||||
["metadata.prefer_existing_nfo", "Prefer existing NFO", "checkbox", "Use existing local NFO data before online metadata when available."],
|
||||
["metadata.provider_order", "Provider order", "list", "Metadata providers in priority order."],
|
||||
["metadata.tmdb_api_key", "TMDb API key", "text", "TMDb v3 API key used for lookups. This is stored in /data/state.json when saved here."],
|
||||
["metadata.tmdb_bearer_token", "TMDb bearer token", "text", "Optional TMDb v4 bearer token. This is stored in /data/state.json when saved here."],
|
||||
["metadata.tmdb_language", "TMDb language", "text", "Language code used for TMDb requests, such as en-US."],
|
||||
["metadata.tmdb_image_base", "TMDb image base", "text", "Base URL used for poster and backdrop images."],
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Appearance",
|
||||
description: "Dashboard theme and custom CSS behavior.",
|
||||
fields: [
|
||||
["theme.default", "Default theme", "select", "Theme used when a browser has not chosen one locally.", { options: themes }],
|
||||
["theme.allow_custom_css", "Allow custom CSS", "checkbox", "Serve /config/custom-theme.css when present."],
|
||||
["theme.custom_css_path", "Custom CSS path", "text", "Container path for optional custom dashboard CSS."],
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Logging",
|
||||
description: "Backend diagnostics.",
|
||||
fields: [
|
||||
["app.log_level", "Log level", "select", "Controls backend verbosity.", { options: ["DEBUG", "INFO", "WARNING", "ERROR"] }],
|
||||
["app.name", "Application name", "text", "Display/runtime name for this Sortarr instance."],
|
||||
],
|
||||
},
|
||||
];
|
||||
const state = {
|
||||
dashboard: null,
|
||||
config: null,
|
||||
downloads: null,
|
||||
library: null,
|
||||
releases: [],
|
||||
route: "overview",
|
||||
libraryTab: "all",
|
||||
libraryLimit: 120,
|
||||
selectedMedia: null,
|
||||
};
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const bytes = (value = 0) => {
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let size = value;
|
||||
let idx = 0;
|
||||
while (size >= 1024 && idx < units.length - 1) {
|
||||
size /= 1024;
|
||||
idx += 1;
|
||||
}
|
||||
return `${size.toFixed(idx ? 1 : 0)} ${units[idx]}`;
|
||||
};
|
||||
const date = (seconds) => seconds ? new Date(seconds * 1000).toLocaleString() : "";
|
||||
const mediaLabel = (kind) => kind === "tv" ? "TV Shows" : kind === "movie" ? "Movies" : "Other";
|
||||
const esc = (value = "") => String(value).replace(/[&<>"']/g, (char) => ({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
"\"": """,
|
||||
"'": "'",
|
||||
})[char]);
|
||||
|
||||
function toast(message, type = "info") {
|
||||
const host = $("toastHost");
|
||||
if (!host) return;
|
||||
const item = document.createElement("div");
|
||||
item.className = `toast ${type}`;
|
||||
item.textContent = message;
|
||||
host.appendChild(item);
|
||||
setTimeout(() => item.classList.add("visible"), 10);
|
||||
setTimeout(() => {
|
||||
item.classList.remove("visible");
|
||||
setTimeout(() => item.remove(), 180);
|
||||
}, 4200);
|
||||
}
|
||||
|
||||
function setTheme(theme) {
|
||||
document.documentElement.dataset.theme = theme;
|
||||
localStorage.setItem("sortarr-theme", theme);
|
||||
renderThemeOptions();
|
||||
}
|
||||
|
||||
async function api(path, options) {
|
||||
const response = await fetch(path, options);
|
||||
if (!response.ok) throw new Error(`${path} returned ${response.status}`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function routeFromHash() {
|
||||
return (location.hash.replace("#/", "") || "overview").split("?")[0];
|
||||
}
|
||||
|
||||
function renderRoute() {
|
||||
state.route = routeFromHash();
|
||||
document.querySelectorAll(".page").forEach((page) => page.classList.remove("active"));
|
||||
document.querySelectorAll("nav a").forEach((link) => link.classList.toggle("active", link.dataset.route === state.route));
|
||||
const page = $(`page-${state.route}`);
|
||||
(page || $("page-overview")).classList.add("active");
|
||||
if (state.route === "library" && !state.library) {
|
||||
loadLibrary().catch((error) => toast(`Library load failed: ${error.message}`, "error"));
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDashboard() {
|
||||
state.dashboard = await api("/api/dashboard");
|
||||
renderDashboard();
|
||||
if (state.downloads) renderDownloads();
|
||||
}
|
||||
|
||||
async function loadLibrary() {
|
||||
const payload = await api("/api/library");
|
||||
state.library = payload.library;
|
||||
renderLibraryStatus();
|
||||
renderLibraryTabs();
|
||||
renderLibrary();
|
||||
}
|
||||
|
||||
async function loadConfig() {
|
||||
state.config = await api("/api/config");
|
||||
$("configView").textContent = JSON.stringify(state.config, null, 2);
|
||||
renderSettings();
|
||||
if (!localStorage.getItem("sortarr-theme") && state.config.theme?.default) {
|
||||
setTheme(state.config.theme.default);
|
||||
} else {
|
||||
renderThemeOptions();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDownloads() {
|
||||
const payload = await api("/api/downloads");
|
||||
state.downloads = payload.downloads;
|
||||
renderDownloads();
|
||||
}
|
||||
|
||||
async function loadReleases() {
|
||||
const payload = await api("/api/releases");
|
||||
state.releases = payload.releases || [];
|
||||
renderReleases();
|
||||
}
|
||||
|
||||
function renderDashboard() {
|
||||
const data = state.dashboard;
|
||||
$("statusLine").textContent = data.dry_run ? "Dry-run mode is active" : "Organizer is allowed to move files";
|
||||
$("storageCards").innerHTML = data.library.drives.map((drive) => {
|
||||
const pct = drive.total ? Math.round((drive.used / drive.total) * 100) : 0;
|
||||
return `<div class="storage-card">
|
||||
<strong>${drive.name}</strong>
|
||||
<div class="meter"><span style="width:${pct}%"></span></div>
|
||||
<div class="kv"><span>${bytes(drive.used)} used</span><span>${bytes(drive.free)} free</span></div>
|
||||
</div>`;
|
||||
}).join("");
|
||||
|
||||
const extensions = Object.entries(data.library.extensions);
|
||||
const max = Math.max(...extensions.map(([, count]) => count), 1);
|
||||
$("extensionBreakdown").innerHTML = extensions.slice(0, 12).map(([ext, count]) => `
|
||||
<div class="bar-row"><span>${ext}</span><div class="meter"><span style="width:${(count / max) * 100}%"></span></div><span>${count}</span></div>
|
||||
`).join("") || "<p class='muted'>No files indexed yet.</p>";
|
||||
|
||||
renderLibraryStatus();
|
||||
|
||||
$("events").innerHTML = data.state.events.slice(0, 12).map((event) => `
|
||||
<div class="event ${event.level}"><strong>${event.message}</strong><br><small>${date(event.time)}</small></div>
|
||||
`).join("") || "<p class='muted'>No organizer events yet.</p>";
|
||||
|
||||
if (state.route === "library" && state.library) {
|
||||
renderLibraryTabs();
|
||||
renderLibrary();
|
||||
}
|
||||
}
|
||||
|
||||
function activeLibrary() {
|
||||
return state.library || state.dashboard?.library || {};
|
||||
}
|
||||
|
||||
function renderLibraryStatus() {
|
||||
const library = activeLibrary();
|
||||
const counts = library.counts || {};
|
||||
$("libraryStatus").textContent = library.scanned_files
|
||||
? `Indexed ${counts.total || 0} media files across ${counts.movies || 0} movies and ${counts.tv || 0} TV items from ${library.scanned_files} scanned files${library.truncated ? " before the configured scan limit or timeout" : ""}.`
|
||||
: "Library has not been scanned yet. Use Scan library to index Movies, TV, and TV Shows folders.";
|
||||
}
|
||||
|
||||
function libraryCollections() {
|
||||
const filter = $("libraryFilter").value.toLowerCase();
|
||||
const collections = activeLibrary().collections || { movies: [], series: [] };
|
||||
const all = [
|
||||
...collections.movies.map((item) => ({ ...item, library: "movie" })),
|
||||
...collections.series.map((item) => ({ ...item, library: "tv" })),
|
||||
];
|
||||
return all.filter((item) => {
|
||||
const meta = item.metadata || {};
|
||||
const matchesTab = state.libraryTab === "all" || item.library === state.libraryTab;
|
||||
const matchesFilter = [item.title, meta.title, meta.overview, item.year, mediaLabel(item.library)].join(" ").toLowerCase().includes(filter);
|
||||
return matchesTab && matchesFilter;
|
||||
});
|
||||
}
|
||||
|
||||
function renderLibraryTabs() {
|
||||
const collectionCounts = activeLibrary().collections || {};
|
||||
const tabs = [
|
||||
["all", "All", (collectionCounts.movies?.length || 0) + (collectionCounts.series?.length || 0)],
|
||||
["movie", "Movies", collectionCounts.movies?.length || 0],
|
||||
["tv", "TV Shows", collectionCounts.series?.length || 0],
|
||||
];
|
||||
$("libraryTabs").innerHTML = tabs.map(([key, label, count]) => `
|
||||
<button class="${state.libraryTab === key ? "active" : ""}" data-library-tab="${key}">${label}<span>${count}</span></button>
|
||||
`).join("");
|
||||
document.querySelectorAll("[data-library-tab]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
state.libraryTab = button.dataset.libraryTab;
|
||||
state.libraryLimit = 120;
|
||||
renderLibraryTabs();
|
||||
renderLibrary();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderLibrary() {
|
||||
const rows = libraryCollections();
|
||||
const visible = rows.slice(0, state.libraryLimit);
|
||||
$("libraryGrid").innerHTML = visible.map((item) => mediaCard(item)).join("") || "<p class='muted'>No matching media.</p>";
|
||||
document.querySelectorAll("[data-media-key]").forEach((button) => {
|
||||
button.addEventListener("click", () => selectMedia(button.dataset.mediaKey));
|
||||
});
|
||||
$("libraryPager").innerHTML = rows.length > state.libraryLimit
|
||||
? `<span class="muted">Showing ${visible.length} of ${rows.length} matching titles.</span><button id="libraryMoreButton">Show 120 more</button>`
|
||||
: `<span class="muted">Showing ${visible.length} matching titles.</span>`;
|
||||
const more = $("libraryMoreButton");
|
||||
if (more) {
|
||||
more.addEventListener("click", () => {
|
||||
state.libraryLimit += 120;
|
||||
renderLibrary();
|
||||
});
|
||||
}
|
||||
if (!state.selectedMedia && visible[0]) {
|
||||
selectMedia(visible[0].key, false);
|
||||
} else if (state.selectedMedia) {
|
||||
renderMediaDetail(state.selectedMedia);
|
||||
}
|
||||
}
|
||||
|
||||
function mediaCard(item) {
|
||||
const meta = item.metadata || {};
|
||||
const title = meta.title || item.title;
|
||||
const subtitle = item.library === "tv"
|
||||
? `${item.seasons?.length || 0} seasons, ${item.files?.length || 0} files`
|
||||
: `${item.year || meta.release_date || ""} ${item.versions?.length > 1 ? `- ${item.versions.length} versions` : ""}`;
|
||||
const versionBadge = item.library === "movie" && (item.versions?.length || item.files?.length || 0) > 1
|
||||
? `<span class="version-badge" title="${esc((item.versions?.length || item.files?.length || 0) + " versions")}">${item.versions?.length || item.files?.length}</span>`
|
||||
: "";
|
||||
const cover = meta.poster
|
||||
? `<img src="${esc(meta.poster)}" alt="">`
|
||||
: `<span class="poster-placeholder">${esc(title.slice(0, 1) || "?")}</span>`;
|
||||
return `<button class="poster-card ${state.selectedMedia?.key === item.key ? "active" : ""}" data-media-key="${esc(item.key)}">
|
||||
<span class="poster">${cover}${versionBadge}</span>
|
||||
<strong>${esc(title)}</strong>
|
||||
<small>${esc(subtitle)}</small>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
function findMedia(key) {
|
||||
const collections = activeLibrary().collections || { movies: [], series: [] };
|
||||
return [...collections.movies, ...collections.series].find((item) => item.key === key);
|
||||
}
|
||||
|
||||
function selectMedia(key, scroll = true) {
|
||||
const item = findMedia(key);
|
||||
if (!item) return;
|
||||
state.selectedMedia = item;
|
||||
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) {
|
||||
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>
|
||||
</div>
|
||||
${files[0] ? `<button data-probe-path="${esc(files[0].path)}">Inspect media</button>` : ""}
|
||||
</div>
|
||||
${meta.overview ? `<p>${esc(meta.overview)}</p>` : ""}
|
||||
${detail}
|
||||
<div id="probeOutput" class="probe-output"></div>
|
||||
</div>
|
||||
</article>`;
|
||||
document.querySelectorAll("[data-probe-path]").forEach((button) => {
|
||||
button.addEventListener("click", () => inspectMedia(button.dataset.probePath));
|
||||
});
|
||||
}
|
||||
|
||||
function renderMovieDetail(item) {
|
||||
const versions = item.versions || (item.files || []).map((file) => ({
|
||||
name: file.name,
|
||||
path: file.path,
|
||||
drive: file.drive,
|
||||
size: file.size,
|
||||
tags: [],
|
||||
}));
|
||||
return `<div class="detail-block">
|
||||
<h3>${versions.length > 1 ? `${versions.length} Versions` : "Local File"}</h3>
|
||||
<div class="download-list">${versions.map(versionRow).join("")}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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="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 renderSeriesDetail(item) {
|
||||
return `<div class="season-list">${(item.seasons || []).map((season) => `
|
||||
<details open>
|
||||
<summary>Season ${season.season || "Unknown"} <span class="muted">${season.episodes.length} episodes</span></summary>
|
||||
<div class="episode-list">${season.episodes.map((episode) => `
|
||||
<article class="episode ${episode.status}">
|
||||
<div>
|
||||
<strong>${episode.episode ? `E${String(episode.episode).padStart(2, "0")} - ` : ""}${esc(episode.title || "Episode")}</strong>
|
||||
<small class="muted">${episode.air_date || ""} ${episode.status !== "present" ? episode.status : ""}</small>
|
||||
${episode.overview ? `<p class="muted">${esc(episode.overview)}</p>` : ""}
|
||||
</div>
|
||||
<div class="episode-actions">
|
||||
${(episode.files || []).map((file) => `<button data-probe-path="${esc(file.path)}">Inspect</button>`).join("") || "<span class='muted'>No file</span>"}
|
||||
</div>
|
||||
</article>
|
||||
`).join("")}</div>
|
||||
</details>
|
||||
`).join("")}</div>`;
|
||||
}
|
||||
|
||||
function fileRow(file) {
|
||||
return `<div class="download">
|
||||
<strong>${esc(file.name)}</strong>
|
||||
<div class="kv"><span>${esc(file.drive || "")}</span><span>${bytes(file.size)}</span></div>
|
||||
<small class="muted">${esc(file.path)}</small>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function inspectMedia(path) {
|
||||
const output = $("probeOutput");
|
||||
output.innerHTML = "<p class='muted'>Inspecting media streams...</p>";
|
||||
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>
|
||||
</div>
|
||||
<p class="muted">Track edits remux the selected file. Dry-run mode reports the command without changing the file.</p>
|
||||
</div>`;
|
||||
document.querySelectorAll("[data-track-action]").forEach((button) => {
|
||||
button.addEventListener("click", () => editTrack(path, button.dataset.trackAction, Number(button.dataset.streamIndex)));
|
||||
});
|
||||
}
|
||||
|
||||
function streamRows(streams = []) {
|
||||
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>
|
||||
</span>` : ""}
|
||||
</div>`;
|
||||
}).join("") || "<p class='muted'>None detected.</p>";
|
||||
}
|
||||
|
||||
async function editTrack(path, action, streamIndex) {
|
||||
const output = $("probeOutput");
|
||||
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" : ""}">
|
||||
<strong>Track edit: ${esc(result.status)}</strong>
|
||||
${result.command ? `<code>${esc(result.command.join(" "))}</code>` : ""}
|
||||
${result.stderr ? `<pre>${esc(result.stderr)}</pre>` : ""}
|
||||
</div>`);
|
||||
if (result.status === "updated") {
|
||||
await inspectMedia(path);
|
||||
}
|
||||
}
|
||||
|
||||
function renderDownloads() {
|
||||
const downloads = state.downloads;
|
||||
if (!downloads) return;
|
||||
$("downloadsStatus").textContent = downloads.error
|
||||
? `Cannot read ${downloads.path}: ${downloads.error}`
|
||||
: `${downloads.counts.current} files in ${downloads.path}, ${downloads.counts.media} media files, ${downloads.counts.subtitles || 0} subtitle files, ${downloads.counts.incomplete} incomplete files. Total size: ${bytes(downloads.total_size)}.`;
|
||||
const queue = state.dashboard?.state?.organizer?.queue || [];
|
||||
const queueCounts = queue.reduce((acc, plan) => {
|
||||
const key = plan.status || plan.result || "planned";
|
||||
acc[key] = (acc[key] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
$("organizerSummary").innerHTML = [
|
||||
["ready", queueCounts.ready || 0],
|
||||
["review", queueCounts["needs-review"] || 0],
|
||||
["held", queueCounts.held || 0],
|
||||
["dry-run", queueCounts["dry-run"] || 0],
|
||||
["moved", queueCounts.moved || 0],
|
||||
].map(([label, count]) => `<span><strong>${count}</strong>${label}</span>`).join("");
|
||||
$("organizerRows").innerHTML = queue.slice(0, 100).map(organizerCard).join("") || "<p class='muted'>No organizer plans yet. Run scan or wait for the background scanner.</p>";
|
||||
document.querySelectorAll("[data-plan-action]").forEach((button) => {
|
||||
button.addEventListener("click", () => updateOrganizerPlan(button.dataset.planAction, button.dataset.planId));
|
||||
});
|
||||
$("downloadRows").innerHTML = [
|
||||
...(downloads.bundles || []).slice(0, 150).map((bundle) => downloadBundle(bundle)),
|
||||
...(downloads.loose || []).slice(0, 80).map((item) => `
|
||||
<div class="download loose ${item.is_incomplete ? "warning" : ""}">
|
||||
<strong>${esc(item.relative_path)}</strong>
|
||||
<div class="kv"><span>${item.is_incomplete ? "Incomplete" : item.is_subtitle ? "Loose subtitle" : "Sidecar file"}</span><span>${bytes(item.size)}</span></div>
|
||||
<small class="muted">Modified ${date(item.modified)}</small>
|
||||
</div>
|
||||
`),
|
||||
].join("") || "<p class='muted'>/downloads is currently empty.</p>";
|
||||
$("recentDownloadRows").innerHTML = downloads.recent.slice(0, 100).map((item) => `
|
||||
<div class="download">
|
||||
<strong>${item.title || item.source}</strong>
|
||||
<span class="muted">${item.status} ${item.type || "item"}${item.drive ? ` to ${item.drive}` : ""}</span>
|
||||
<small>${item.destination || item.source}</small>
|
||||
<small class="muted">${date(item.updated_at)}</small>
|
||||
</div>
|
||||
`).join("") || "<p class='muted'>No recent Sortarr plans or moves from /downloads yet.</p>";
|
||||
}
|
||||
|
||||
function organizerCard(plan) {
|
||||
const status = plan.status || plan.result;
|
||||
const result = plan.result && plan.result !== plan.status ? ` (${plan.result})` : "";
|
||||
const confidenceClass = plan.confidence >= 90 ? "good" : plan.confidence >= 60 ? "warn" : "bad";
|
||||
const metaSource = plan.metadata?.source === "tmdb" ? "TMDb matched" : "Filename parsed";
|
||||
const label = plan.media?.type === "episode" && plan.media?.season
|
||||
? `TV episode S${String(plan.media.season).padStart(2, "0")}E${String(plan.media.episode).padStart(2, "0")}`
|
||||
: plan.media?.type === "movie" ? `Movie ${plan.media?.year || ""}` : esc(plan.media?.type || "");
|
||||
return `<div class="download organizer-card ${status}">
|
||||
<div class="bundle-head">
|
||||
<div>
|
||||
<strong>${esc(plan.media?.title || plan.source)}</strong>
|
||||
<small class="muted">${esc(label)} - ${esc(metaSource)}</small>
|
||||
${plan.media?.episode_title ? `<small>${esc(plan.media.episode_title)}</small>` : ""}
|
||||
</div>
|
||||
<span class="confidence ${confidenceClass}">${plan.confidence || 0}%</span>
|
||||
</div>
|
||||
<div class="plan-paths">
|
||||
<small><b>From</b>${esc(plan.source || "")}</small>
|
||||
<small><b>To</b>${esc(plan.destination || "No destination planned")}</small>
|
||||
</div>
|
||||
<div class="subtitle-chips">${(plan.reasons || []).map((reason) => `<span>${esc(reason)}</span>`).join("")}</div>
|
||||
<div class="kv"><span>${esc(`${status || ""}${result}`)}</span><span>${(plan.subtitles || []).length} subtitles</span></div>
|
||||
${(plan.subtitles || []).length ? `<div class="subtitle-list">${plan.subtitles.map((subtitle) => `<small>${esc(subtitle.language || "und")} -> ${esc(subtitle.destination || "not planned")}</small>`).join("")}</div>` : ""}
|
||||
<div class="plan-actions">
|
||||
<button data-plan-action="approve" data-plan-id="${esc(plan.id)}">Approve</button>
|
||||
<button data-plan-action="skip" data-plan-id="${esc(plan.id)}">Skip</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function updateOrganizerPlan(action, id) {
|
||||
await api(`/api/organizer/${action}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id }),
|
||||
});
|
||||
await Promise.all([loadDashboard(), loadDownloads()]);
|
||||
}
|
||||
|
||||
function downloadBundle(bundle) {
|
||||
const media = bundle.media;
|
||||
return `<div class="download bundle ${media.is_incomplete ? "warning" : ""}">
|
||||
<div class="bundle-head">
|
||||
<div>
|
||||
<strong>${esc(media.name)}</strong>
|
||||
<small class="muted">${esc(media.folder || "/downloads")}</small>
|
||||
</div>
|
||||
<span>${bytes(bundle.size)}</span>
|
||||
</div>
|
||||
<div class="kv"><span>Media file</span><span>${date(media.modified)}</span></div>
|
||||
<div class="subtitle-chips">
|
||||
${(bundle.subtitles || []).map((subtitle) => `<span>${esc(subtitle.name)}</span>`).join("") || "<small class='muted'>No matching subtitles found</small>"}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderReleases() {
|
||||
$("releaseRows").innerHTML = state.releases.map((item) => `
|
||||
<div class="release ${item.status || ""}">
|
||||
${item.poster ? `<img src="${esc(item.poster)}" alt="">` : ""}
|
||||
<strong>${esc(item.title || item.error || "Unknown")}</strong>
|
||||
${item.episode_title ? `<span>${esc(`S${String(item.season).padStart(2, "0")}E${String(item.episode).padStart(2, "0")} - ${item.episode_title}`)}</span>` : ""}
|
||||
<span class="muted">${esc(item.status || item.provider || "")}</span>
|
||||
<span>${esc(item.date || item.type || "")}</span>
|
||||
${item.library_key ? `<a href="#/library" data-release-key="${esc(item.library_key)}">Open in library</a>` : ""}
|
||||
</div>
|
||||
`).join("") || "<p class='muted'>No release providers returned data.</p>";
|
||||
document.querySelectorAll("[data-release-key]").forEach((link) => {
|
||||
link.addEventListener("click", () => setTimeout(() => selectMedia(link.dataset.releaseKey), 50));
|
||||
});
|
||||
}
|
||||
|
||||
function renderThemeOptions() {
|
||||
const wrap = $("themeOptions");
|
||||
if (!wrap) return;
|
||||
const current = localStorage.getItem("sortarr-theme") || state.config?.theme?.default || "slate";
|
||||
wrap.innerHTML = themes.map((theme) => `
|
||||
<button class="theme-option ${current === theme ? "active" : ""}" data-theme-choice="${theme}">
|
||||
<span class="theme-swatch" data-theme="${theme}"><i></i><b></b><em></em></span>
|
||||
<strong>${themeLabels[theme]}</strong>
|
||||
</button>
|
||||
`).join("");
|
||||
document.querySelectorAll("[data-theme-choice]").forEach((button) => {
|
||||
button.addEventListener("click", () => setTheme(button.dataset.themeChoice));
|
||||
});
|
||||
}
|
||||
|
||||
function getPath(root, path) {
|
||||
return path.split(".").reduce((value, key) => value?.[key], root);
|
||||
}
|
||||
|
||||
function setPath(root, path, value) {
|
||||
const keys = path.split(".");
|
||||
let target = root;
|
||||
keys.slice(0, -1).forEach((key) => {
|
||||
target[key] = target[key] || {};
|
||||
target = target[key];
|
||||
});
|
||||
target[keys[keys.length - 1]] = value;
|
||||
}
|
||||
|
||||
function fieldMeta(tuple) {
|
||||
const [path, label, type, help, options = {}] = tuple;
|
||||
return { path, label, type, help, ...options };
|
||||
}
|
||||
|
||||
function settingField(meta) {
|
||||
const value = getPath(state.config, meta.path);
|
||||
const id = meta.path.replaceAll(".", "__");
|
||||
const body = `<div class="setting-copy">
|
||||
<div>
|
||||
<strong>${meta.label}</strong>
|
||||
<small class="setting-path">${esc(meta.path)}</small>
|
||||
</div>
|
||||
<small>${meta.help}</small>
|
||||
</div>`;
|
||||
if (meta.type === "checkbox") {
|
||||
return `<article class="setting-row setting-rich">${body}<label class="switch"><input data-setting="${esc(meta.path)}" type="checkbox" ${value ? "checked" : ""}><span></span></label></article>`;
|
||||
}
|
||||
if (meta.type === "select") {
|
||||
const options = (meta.options || []).map((option) => `<option value="${esc(option)}" ${value === option ? "selected" : ""}>${esc(themeLabels[option] || option)}</option>`);
|
||||
return `<article class="setting-row setting-rich">${body}<div class="setting-control"><select data-setting="${esc(meta.path)}">${options.join("")}</select></div></article>`;
|
||||
}
|
||||
if (meta.type === "list") {
|
||||
return `<article class="setting-row setting-rich">${body}<div class="setting-control wide"><textarea data-setting="${esc(meta.path)}" data-setting-type="list" rows="2">${esc((value || []).join(", "))}</textarea><small>Comma separated</small></div></article>`;
|
||||
}
|
||||
if (meta.type === "text") {
|
||||
return `<article class="setting-row setting-rich">${body}<div class="setting-control wide"><input data-setting="${esc(meta.path)}" type="text" value="${esc(value ?? "")}"></div></article>`;
|
||||
}
|
||||
return `<article class="setting-row setting-rich">${body}<div class="range-control">
|
||||
<input data-setting="${esc(meta.path)}" data-range-for="${id}" type="range" min="${meta.min}" max="${meta.max}" step="${meta.step}" value="${value ?? meta.min}">
|
||||
<span><input data-setting="${esc(meta.path)}" data-number-for="${id}" type="number" min="${meta.min}" max="${meta.max}" step="${meta.step}" value="${value ?? meta.min}"><small>${meta.unit}</small></span>
|
||||
</div></article>`;
|
||||
}
|
||||
|
||||
function syncSettingControls() {
|
||||
document.querySelectorAll("[data-range-for]").forEach((range) => {
|
||||
range.addEventListener("input", () => {
|
||||
const number = document.querySelector(`[data-number-for="${range.dataset.rangeFor}"]`);
|
||||
if (number) number.value = range.value;
|
||||
});
|
||||
});
|
||||
document.querySelectorAll("[data-number-for]").forEach((number) => {
|
||||
number.addEventListener("input", () => {
|
||||
const range = document.querySelector(`[data-range-for="${number.dataset.numberFor}"]`);
|
||||
if (range) range.value = number.value;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderDriveSettings() {
|
||||
const drives = state.config?.drives || [];
|
||||
return `<details class="settings-card" open>
|
||||
<summary class="settings-card-head">
|
||||
<div>
|
||||
<h3>Storage Drives</h3>
|
||||
<p class="muted">Destination drives Sortarr can choose when moving organized media.</p>
|
||||
</div>
|
||||
<span>${drives.length} drives</span>
|
||||
</summary>
|
||||
<div class="settings-grid">${drives.map((drive, idx) => `
|
||||
<div class="setting-row setting-rich drive-setting" data-drive-index="${idx}">
|
||||
<div class="setting-copy">
|
||||
<div>
|
||||
<strong>${esc(drive.name || drive.id || `Drive ${idx + 1}`)}</strong>
|
||||
<small class="setting-path">drives[${idx}]</small>
|
||||
</div>
|
||||
<small>Drive identity, container path, and minimum free-space reserve.</small>
|
||||
</div>
|
||||
<span class="compound-control">
|
||||
<label><small>ID</small><input data-drive-field="id" type="text" value="${esc(drive.id || "")}"></label>
|
||||
<label><small>Name</small><input data-drive-field="name" type="text" value="${esc(drive.name || "")}"></label>
|
||||
<label class="span-2"><small>Container path</small><input data-drive-field="path" type="text" value="${esc(drive.path || "")}"></label>
|
||||
<label><small>Reserve</small><input data-drive-field="min_free_gb" type="number" min="0" step="1" value="${drive.min_free_gb ?? 20}"></label>
|
||||
</span>
|
||||
</div>
|
||||
`).join("")}</div>
|
||||
</details>`;
|
||||
}
|
||||
|
||||
function collectDriveSettings() {
|
||||
return [...document.querySelectorAll("[data-drive-index]")].map((row) => ({
|
||||
id: row.querySelector('[data-drive-field="id"]').value,
|
||||
name: row.querySelector('[data-drive-field="name"]').value,
|
||||
path: row.querySelector('[data-drive-field="path"]').value,
|
||||
min_free_gb: Number(row.querySelector('[data-drive-field="min_free_gb"]').value),
|
||||
}));
|
||||
}
|
||||
|
||||
function renderReleaseProviderSettings() {
|
||||
const providers = state.config?.release_providers || [];
|
||||
return `<details class="settings-card" open>
|
||||
<summary class="settings-card-head">
|
||||
<div>
|
||||
<h3>Release Providers</h3>
|
||||
<p class="muted">Sources used by the Releases tab for upcoming or missing media context.</p>
|
||||
</div>
|
||||
<span>${providers.length} providers</span>
|
||||
</summary>
|
||||
<div class="settings-grid">${providers.map((provider, idx) => `
|
||||
<div class="setting-row setting-rich provider-setting" data-provider-index="${idx}">
|
||||
<div class="setting-copy">
|
||||
<div>
|
||||
<strong>${esc(provider.name || provider.id || `Provider ${idx + 1}`)}</strong>
|
||||
<small class="setting-path">release_providers[${idx}]</small>
|
||||
</div>
|
||||
<small>Enable status, provider type, and feed URL.</small>
|
||||
</div>
|
||||
<span class="compound-control">
|
||||
<label class="inline-check"><input data-provider-field="enabled" type="checkbox" ${provider.enabled ? "checked" : ""}>Enabled</label>
|
||||
<label><small>ID</small><input data-provider-field="id" type="text" value="${esc(provider.id || "")}"></label>
|
||||
<label><small>Name</small><input data-provider-field="name" type="text" value="${esc(provider.name || "")}"></label>
|
||||
<label><small>Type</small><select data-provider-field="type">
|
||||
${["rss", "json"].map((type) => `<option value="${type}" ${provider.type === type ? "selected" : ""}>${type}</option>`).join("")}
|
||||
</select></label>
|
||||
<label class="span-2"><small>URL</small><input data-provider-field="url" type="text" value="${esc(provider.url || "")}"></label>
|
||||
</span>
|
||||
</div>
|
||||
`).join("")}</div>
|
||||
</details>`;
|
||||
}
|
||||
|
||||
function collectReleaseProviderSettings() {
|
||||
return [...document.querySelectorAll("[data-provider-index]")].map((row) => ({
|
||||
id: row.querySelector('[data-provider-field="id"]').value,
|
||||
name: row.querySelector('[data-provider-field="name"]').value,
|
||||
enabled: row.querySelector('[data-provider-field="enabled"]').checked,
|
||||
type: row.querySelector('[data-provider-field="type"]').value,
|
||||
url: row.querySelector('[data-provider-field="url"]').value,
|
||||
}));
|
||||
}
|
||||
|
||||
function renderSettings() {
|
||||
if (!state.config) return;
|
||||
$("settingsForm").innerHTML = settingsGroups.map((group) => `
|
||||
<details class="settings-card" open>
|
||||
<summary class="settings-card-head">
|
||||
<div>
|
||||
<h3>${esc(group.title)}</h3>
|
||||
<p class="muted">${esc(group.description)}</p>
|
||||
</div>
|
||||
<span>${group.fields.length} settings</span>
|
||||
</summary>
|
||||
<div class="settings-grid">${group.fields.map((field) => settingField(fieldMeta(field))).join("")}</div>
|
||||
</details>
|
||||
`).join("") + renderDriveSettings() + renderReleaseProviderSettings();
|
||||
syncSettingControls();
|
||||
renderThemeOptions();
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
const button = $("settingsSaveButton");
|
||||
const notice = $("settingsNotice");
|
||||
button.disabled = true;
|
||||
button.textContent = "Saving...";
|
||||
if (notice) notice.textContent = "";
|
||||
const updates = {};
|
||||
try {
|
||||
document.querySelectorAll("[data-setting]").forEach((field) => {
|
||||
const path = field.dataset.setting;
|
||||
if (!path) return;
|
||||
if (field.type === "checkbox") {
|
||||
setPath(updates, path, field.checked);
|
||||
} else if (field.type === "range" || field.type === "number") {
|
||||
setPath(updates, path, Number(field.value));
|
||||
} else if (field.dataset.settingType === "list") {
|
||||
setPath(updates, path, field.value.split(",").map((item) => item.trim()).filter(Boolean));
|
||||
} else {
|
||||
if ((path === "metadata.tmdb_api_key" || path === "metadata.tmdb_bearer_token") && field.value === "********") {
|
||||
return;
|
||||
}
|
||||
setPath(updates, path, field.value);
|
||||
}
|
||||
});
|
||||
updates.drives = collectDriveSettings();
|
||||
updates.release_providers = collectReleaseProviderSettings();
|
||||
const payload = await api("/api/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
state.config = payload.config;
|
||||
$("configView").textContent = JSON.stringify(state.config, null, 2);
|
||||
renderSettings();
|
||||
await loadDashboard();
|
||||
const message = "Settings saved. Run a library scan to refresh TMDb covers and episode metadata.";
|
||||
if (notice) notice.textContent = message;
|
||||
toast(message, "success");
|
||||
} catch (error) {
|
||||
const message = `Settings save failed: ${error.message}`;
|
||||
if (notice) notice.textContent = message;
|
||||
toast(message, "error");
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.textContent = "Save settings";
|
||||
}
|
||||
}
|
||||
|
||||
async function testTmdb() {
|
||||
const button = $("tmdbTestButton");
|
||||
const notice = $("settingsNotice");
|
||||
button.disabled = true;
|
||||
button.textContent = "Testing...";
|
||||
if (notice) notice.textContent = "Testing TMDb API credentials...";
|
||||
try {
|
||||
const payload = await api("/api/metadata/tmdb/test", { method: "POST" });
|
||||
const result = payload.tmdb || {};
|
||||
const details = result.ok && result.image_base
|
||||
? ` Poster images available from ${result.image_base}.`
|
||||
: "";
|
||||
const message = `${result.ok ? "TMDb API test passed." : "TMDb API test failed."} ${result.message || ""}${details}`;
|
||||
if (notice) notice.textContent = message;
|
||||
toast(message, result.ok ? "success" : "error");
|
||||
} catch (error) {
|
||||
const message = `TMDb API test failed: ${error.message}`;
|
||||
if (notice) notice.textContent = message;
|
||||
toast(message, "error");
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.textContent = "TMDb API Test";
|
||||
}
|
||||
}
|
||||
|
||||
function renderToolOutput(title, rows) {
|
||||
$("toolOutput").innerHTML = `<h3>${esc(title)}</h3>${rows}`;
|
||||
}
|
||||
|
||||
async function loadTranscoder() {
|
||||
const payload = await api("/api/tools/transcoder");
|
||||
const plan = payload.transcoder;
|
||||
renderToolOutput("Transcode Queue", `
|
||||
<p class="muted">${plan.count} conversion candidates. ffmpeg ${plan.ffmpeg_available ? "is available" : "is not available"}.</p>
|
||||
<div class="download-list">${plan.targets.slice(0, 20).map((item) => `
|
||||
<div class="download"><strong>${esc(item.name)}</strong><span class="muted">${esc(item.output)}</span><code>${esc(item.command.join(" "))}</code></div>
|
||||
`).join("") || "<p class='muted'>No transcode candidates found.</p>"}</div>
|
||||
`);
|
||||
}
|
||||
|
||||
async function runNextTranscode() {
|
||||
const payload = await api("/api/tools/transcoder/run-next", { method: "POST" });
|
||||
const result = payload.transcoder;
|
||||
renderToolOutput("Transcoder Result", `
|
||||
<p class="muted">Status: ${result.status}. ${result.count || 0} candidates in queue.</p>
|
||||
${result.ran ? `<div class="download"><strong>${esc(result.ran.name)}</strong><span>${esc(result.ran.output)}</span></div>` : ""}
|
||||
${result.stderr ? `<pre>${esc(result.stderr)}</pre>` : ""}
|
||||
`);
|
||||
}
|
||||
|
||||
async function runSubtitleAudit() {
|
||||
const payload = await api("/api/tools/subtitles");
|
||||
const audit = payload.audit;
|
||||
renderToolOutput("Subtitle Audit", `
|
||||
<p class="muted">Checked ${audit.checked} indexed media files. ${audit.missing_count} missing subtitles. ${audit.unknown_count || 0} need a fresh library scan.</p>
|
||||
<div class="download-list">${audit.missing.slice(0, 50).map((item) => `
|
||||
<div class="download"><strong>${esc(item.name)}</strong><span class="muted">${esc(item.path)}</span><small>Expected: ${esc(item.expected.join(", "))}</small></div>
|
||||
`).join("") || "<p class='muted'>Every indexed media file has a sidecar subtitle.</p>"}</div>
|
||||
`);
|
||||
}
|
||||
|
||||
async function runDuplicateFinder() {
|
||||
const payload = await api("/api/tools/duplicates");
|
||||
const result = payload.duplicates;
|
||||
renderToolOutput("Duplicate Finder", `
|
||||
<p class="muted">${result.count} duplicate title groups found in the cached library index.</p>
|
||||
<div class="download-list">${result.duplicates.slice(0, 50).map((group) => `
|
||||
<div class="download">
|
||||
<strong>${esc(group.title || group.key || "Unknown")}</strong>
|
||||
<span class="muted">${group.count} files, ${bytes(group.total_size)}</span>
|
||||
${(group.files || []).map((file) => `<small>${esc(file.path)} (${bytes(file.size)})</small>`).join("")}
|
||||
</div>
|
||||
`).join("") || "<p class='muted'>No duplicate title groups found.</p>"}</div>
|
||||
`);
|
||||
}
|
||||
|
||||
async function runScan() {
|
||||
$("scanButton").disabled = true;
|
||||
try {
|
||||
const scan = await api("/api/scan", { method: "POST" });
|
||||
$("downloadsStatus").textContent = scan.started ? "Scan started. Organizer queue will update as files are parsed." : "A scan is already running. Showing the latest queue.";
|
||||
await Promise.all([loadDashboard(), loadDownloads()]);
|
||||
setTimeout(() => Promise.all([loadDashboard(), loadDownloads()]).catch(() => {}), 2500);
|
||||
} finally {
|
||||
$("scanButton").disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function scanLibrary() {
|
||||
$("libraryScanButton").disabled = true;
|
||||
$("libraryStatus").textContent = "Scanning Movies, TV, and TV Shows folders...";
|
||||
try {
|
||||
const payload = await api("/api/library/scan", { method: "POST" });
|
||||
state.library = payload.library;
|
||||
state.dashboard.library = {
|
||||
...state.dashboard.library,
|
||||
counts: payload.library.counts,
|
||||
drives: payload.library.drives,
|
||||
extensions: payload.library.extensions,
|
||||
scanned_files: payload.library.scanned_files,
|
||||
truncated: payload.library.truncated,
|
||||
};
|
||||
state.libraryLimit = 120;
|
||||
state.selectedMedia = null;
|
||||
renderDashboard();
|
||||
await loadReleases();
|
||||
} finally {
|
||||
$("libraryScanButton").disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function showStartupError(error) {
|
||||
const message = error?.message || String(error);
|
||||
const status = $("statusLine");
|
||||
if (status) status.textContent = `Frontend startup failed: ${message}`;
|
||||
toast(`Frontend startup failed: ${message}`, "error");
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
function init() {
|
||||
setTheme(localStorage.getItem("sortarr-theme") || "slate");
|
||||
window.addEventListener("hashchange", renderRoute);
|
||||
renderRoute();
|
||||
$("refreshButton").addEventListener("click", loadDashboard);
|
||||
$("scanButton").addEventListener("click", runScan);
|
||||
$("libraryScanButton").addEventListener("click", scanLibrary);
|
||||
$("downloadsRefresh").addEventListener("click", loadDownloads);
|
||||
$("releaseRefresh").addEventListener("click", loadReleases);
|
||||
$("libraryFilter").addEventListener("input", () => {
|
||||
state.libraryLimit = 500;
|
||||
renderLibrary();
|
||||
});
|
||||
$("settingsSaveButton").addEventListener("click", saveSettings);
|
||||
$("tmdbTestButton").addEventListener("click", testTmdb);
|
||||
$("transcoderPlanButton").addEventListener("click", loadTranscoder);
|
||||
$("transcoderRunButton").addEventListener("click", runNextTranscode);
|
||||
$("subtitleAuditButton").addEventListener("click", runSubtitleAudit);
|
||||
$("duplicateButton").addEventListener("click", runDuplicateFinder);
|
||||
Promise.allSettled([loadConfig(), loadDashboard(), loadDownloads(), loadReleases()]).then((results) => {
|
||||
const failed = results.find((result) => result.status === "rejected");
|
||||
if (failed && !state.dashboard) {
|
||||
showStartupError(failed.reason);
|
||||
}
|
||||
});
|
||||
setInterval(loadDashboard, 30000);
|
||||
}
|
||||
|
||||
try {
|
||||
init();
|
||||
} catch (error) {
|
||||
showStartupError(error);
|
||||
}
|
||||
151
web/src/index.html
Normal file
151
web/src/index.html
Normal file
@@ -0,0 +1,151 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Sortarr</title>
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
<link rel="stylesheet" href="/themes.css">
|
||||
<link rel="stylesheet" href="/api/theme/custom.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<span class="brand-mark">S</span>
|
||||
<div>
|
||||
<strong>Sortarr</strong>
|
||||
</div>
|
||||
</div>
|
||||
<nav>
|
||||
<a href="#/overview" data-route="overview" class="active">Overview</a>
|
||||
<a href="#/library" data-route="library">Library</a>
|
||||
<a href="#/downloads" data-route="downloads">Downloads</a>
|
||||
<a href="#/releases" data-route="releases">Releases</a>
|
||||
<a href="#/tools" data-route="tools">Tools</a>
|
||||
<a href="#/settings" data-route="settings">Settings</a>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main>
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<h1>Media Dashboard</h1>
|
||||
<p id="statusLine">Connecting to backend...</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button id="scanButton">Run scan</button>
|
||||
<button id="refreshButton">Refresh</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section id="page-overview" class="page active">
|
||||
<div class="grid overview-grid">
|
||||
<article class="panel">
|
||||
<h2>Storage</h2>
|
||||
<div id="storageCards" class="storage-list"></div>
|
||||
</article>
|
||||
<article class="panel">
|
||||
<h2>File Types</h2>
|
||||
<div id="extensionBreakdown" class="bars"></div>
|
||||
</article>
|
||||
<article class="panel">
|
||||
<h2>Activity</h2>
|
||||
<div id="events" class="event-list"></div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="page-library" class="page panel">
|
||||
<div class="section-head">
|
||||
<h2>Library Contents</h2>
|
||||
<div class="actions">
|
||||
<input id="libraryFilter" placeholder="Filter library">
|
||||
<button id="libraryScanButton">Scan library</button>
|
||||
</div>
|
||||
</div>
|
||||
<p id="libraryStatus" class="muted"></p>
|
||||
<div id="libraryTabs" class="segmented"></div>
|
||||
<div id="libraryGrid" class="poster-grid"></div>
|
||||
<div id="libraryPager" class="pager"></div>
|
||||
<div id="libraryDetail" class="media-detail"></div>
|
||||
</section>
|
||||
|
||||
<section id="page-downloads" class="page panel">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2>Downloads</h2>
|
||||
<p id="downloadsStatus" class="muted"></p>
|
||||
</div>
|
||||
<button id="downloadsRefresh">Refresh downloads</button>
|
||||
</div>
|
||||
<div class="downloads-layout">
|
||||
<article>
|
||||
<h3>Organizer Queue</h3>
|
||||
<div id="organizerSummary" class="queue-summary"></div>
|
||||
<div id="organizerRows" class="download-list"></div>
|
||||
</article>
|
||||
<article>
|
||||
<h3>Current /downloads Files</h3>
|
||||
<div id="downloadRows" class="download-list"></div>
|
||||
</article>
|
||||
<article>
|
||||
<h3>Recently Planned or Moved</h3>
|
||||
<div id="recentDownloadRows" class="download-list"></div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="page-releases" class="page panel">
|
||||
<div class="section-head">
|
||||
<h2>Missing & Upcoming</h2>
|
||||
<button id="releaseRefresh">Refresh releases</button>
|
||||
</div>
|
||||
<div id="releaseRows" class="release-grid"></div>
|
||||
</section>
|
||||
|
||||
<section id="page-tools" class="page panel">
|
||||
<div class="section-head">
|
||||
<h2>Library Tools</h2>
|
||||
<span class="muted">Uses the cached library index. Run a library scan first if results look stale.</span>
|
||||
</div>
|
||||
<div class="tool-grid">
|
||||
<button id="transcoderPlanButton">Build transcode queue</button>
|
||||
<button id="transcoderRunButton">Run next transcode</button>
|
||||
<button id="subtitleAuditButton">Run subtitle audit</button>
|
||||
<button id="duplicateButton">Duplicate finder</button>
|
||||
</div>
|
||||
<div id="toolOutput" class="tool-output"></div>
|
||||
</section>
|
||||
|
||||
<section id="page-settings" class="page panel">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2>Settings</h2>
|
||||
<p class="muted">Runtime settings are saved in /data/state.json and override TOML/env values for this backend process.</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button id="tmdbTestButton" type="button">TMDb API Test</button>
|
||||
<button id="settingsSaveButton" type="button">Save settings</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="settingsNotice" class="settings-notice" role="status" aria-live="polite"></div>
|
||||
<section class="settings-hero">
|
||||
<div>
|
||||
<h3>Dashboard Theme</h3>
|
||||
<p class="muted">Choose the local dashboard theme here. The default theme below is also configurable and saved on the server.</p>
|
||||
</div>
|
||||
<div id="themeOptions" class="theme-options"></div>
|
||||
</section>
|
||||
<div id="settingsForm" class="settings-stack"></div>
|
||||
<details open>
|
||||
<summary>Raw config</summary>
|
||||
<pre id="configView"></pre>
|
||||
</details>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
<div id="toastHost" class="toast-host" aria-live="polite"></div>
|
||||
<script src="/app.js?v=20260514-3"></script>
|
||||
</body>
|
||||
</html>
|
||||
729
web/src/styles.css
Normal file
729
web/src/styles.css
Normal file
@@ -0,0 +1,729 @@
|
||||
* { box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font);
|
||||
font-size: calc(15px - (var(--compact, 0) * 1px));
|
||||
}
|
||||
.app-shell { display: grid; grid-template-columns: 260px 1fr; min-height: 100vh; }
|
||||
.sidebar {
|
||||
border-right: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
padding: calc(20px * var(--density));
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
}
|
||||
.brand { display: flex; gap: 12px; align-items: center; margin-bottom: 28px; }
|
||||
.brand-mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--accent);
|
||||
color: var(--bg);
|
||||
font-weight: 800;
|
||||
}
|
||||
.brand small, #statusLine, .muted { color: var(--muted); }
|
||||
nav { display: grid; gap: 6px; }
|
||||
nav a {
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
nav a.active, nav a:hover { background: var(--surface-2); color: var(--text); }
|
||||
.page { display: none; }
|
||||
.page.active { display: block; }
|
||||
select, input, button {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
button { cursor: pointer; }
|
||||
button:hover { border-color: var(--accent); }
|
||||
button:disabled { cursor: wait; opacity: .62; }
|
||||
main { padding: 24px; display: grid; gap: 24px; align-content: start; }
|
||||
.topbar, .section-head { display: flex; justify-content: space-between; gap: 16px; align-items: center; }
|
||||
h1, h2, h3, p { margin: 0; }
|
||||
h1 { font-size: 28px; }
|
||||
h2 { font-size: 17px; }
|
||||
h3 { font-size: 14px; color: var(--muted); font-weight: 700; }
|
||||
.actions { display: flex; gap: 10px; }
|
||||
.grid { display: grid; gap: 16px; }
|
||||
.overview-grid { grid-template-columns: 1.3fr 1fr 1fr; }
|
||||
.panel {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: calc(18px * var(--density));
|
||||
}
|
||||
.storage-list, .event-list, .download-list, .bars { display: grid; gap: 12px; margin-top: 16px; }
|
||||
.storage-card { display: grid; gap: 8px; }
|
||||
.meter { height: 10px; background: var(--surface-2); border-radius: 999px; overflow: hidden; }
|
||||
.meter span { display: block; height: 100%; background: var(--accent); }
|
||||
.kv { display: flex; justify-content: space-between; color: var(--muted); font-size: 13px; }
|
||||
.bar-row { display: grid; grid-template-columns: 72px 1fr 44px; gap: 10px; align-items: center; }
|
||||
.event { border-left: 3px solid var(--accent); padding-left: 10px; color: var(--muted); }
|
||||
.event.error { border-color: var(--bad); }
|
||||
.segmented {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.segmented button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
.segmented button.active {
|
||||
border-color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 18%, var(--surface-2));
|
||||
}
|
||||
.segmented span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.table-wrap { overflow: auto; margin-top: 16px; max-height: 68vh; }
|
||||
table { width: 100%; border-collapse: collapse; min-width: 720px; }
|
||||
th, td { border-bottom: 1px solid var(--border); padding: 10px; text-align: left; }
|
||||
th { color: var(--muted); font-weight: 600; }
|
||||
td:first-child {
|
||||
max-width: 520px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.download, .release {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.download.warning { border-color: var(--warn); }
|
||||
.poster-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 16px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
.poster-card {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
}
|
||||
.poster-card.active .poster,
|
||||
.poster-card:hover .poster {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.poster {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
aspect-ratio: 2 / 3;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
position: relative;
|
||||
}
|
||||
.poster img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.poster-placeholder {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, var(--surface-2), var(--surface));
|
||||
color: var(--accent);
|
||||
font-size: 42px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.poster-card strong,
|
||||
.poster-card small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.poster-card small { color: var(--muted); }
|
||||
.version-badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
color: var(--accent-contrast, #08111f);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, .28);
|
||||
}
|
||||
.media-detail {
|
||||
margin-top: 22px;
|
||||
}
|
||||
.detail-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 190px minmax(0, 1fr);
|
||||
gap: 20px;
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 22px;
|
||||
}
|
||||
.detail-poster {
|
||||
align-self: start;
|
||||
}
|
||||
.detail-body {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
.detail-block,
|
||||
.season-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
.season-list details {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-2);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.season-list summary {
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
}
|
||||
.episode-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.episode {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
padding: 10px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
border-left: 3px solid var(--good);
|
||||
}
|
||||
.episode.missing { border-left-color: var(--bad); }
|
||||
.episode.upcoming { border-left-color: var(--warn); }
|
||||
.episode p {
|
||||
margin-top: 5px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.episode-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.probe-output {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
.stream-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.stream-grid section {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-2);
|
||||
padding: 12px;
|
||||
}
|
||||
.stream-row {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.track-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.track-actions button {
|
||||
padding: 6px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.downloads-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 1fr) minmax(0, 1.2fr) minmax(320px, .8fr);
|
||||
gap: 18px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
.downloads-layout article { min-width: 0; }
|
||||
.queue-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(78px, 1fr));
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.queue-summary span {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
padding: 9px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.queue-summary strong {
|
||||
color: var(--text);
|
||||
font-size: 18px;
|
||||
}
|
||||
.download small, .download span {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.download.bundle {
|
||||
background: var(--surface);
|
||||
}
|
||||
.bundle-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
.bundle-head div {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
.subtitle-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.subtitle-chips span {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: var(--surface-2);
|
||||
color: var(--muted);
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.download.loose {
|
||||
opacity: .82;
|
||||
}
|
||||
.organizer-card {
|
||||
border-left: 3px solid var(--accent);
|
||||
}
|
||||
.organizer-card.needs-review,
|
||||
.organizer-card.dry-run {
|
||||
border-left-color: var(--warn);
|
||||
}
|
||||
.organizer-card.low-confidence,
|
||||
.organizer-card.skipped {
|
||||
border-left-color: var(--bad);
|
||||
}
|
||||
.organizer-card.moved {
|
||||
border-left-color: var(--good);
|
||||
}
|
||||
.confidence {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.confidence.good { color: var(--good); }
|
||||
.confidence.warn { color: var(--warn); }
|
||||
.confidence.bad { color: var(--bad); }
|
||||
.plan-paths {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
.plan-paths small {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
.plan-paths b {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.subtitle-list {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.plan-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.plan-actions button {
|
||||
padding: 7px 10px;
|
||||
}
|
||||
.release-grid, .tool-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-top: 16px; }
|
||||
.release img {
|
||||
width: 100%;
|
||||
aspect-ratio: 2 / 3;
|
||||
object-fit: cover;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.release.missing { border-color: var(--bad); }
|
||||
.release.upcoming { border-color: var(--warn); }
|
||||
.release a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
.pager {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.tool-output { margin-top: 18px; display: grid; gap: 12px; }
|
||||
.tool-output h3 { margin: 0; font-size: 15px; }
|
||||
code {
|
||||
display: block;
|
||||
overflow: auto;
|
||||
padding: 10px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg);
|
||||
color: var(--muted);
|
||||
}
|
||||
.settings-hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, .45fr) minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
align-items: start;
|
||||
margin-top: 18px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
}
|
||||
.settings-hero h3 { margin: 0 0 6px; }
|
||||
.settings-notice {
|
||||
display: none;
|
||||
margin-top: 14px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-2);
|
||||
color: var(--muted);
|
||||
}
|
||||
.settings-notice:not(:empty) { display: block; }
|
||||
.settings-stack {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
margin-top: 18px;
|
||||
max-width: 1180px;
|
||||
}
|
||||
.settings-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
.settings-card-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface-2);
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
.settings-card-head::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
.settings-card-head h3 {
|
||||
margin: 0 0 5px;
|
||||
}
|
||||
.settings-card-head p {
|
||||
margin: 0;
|
||||
}
|
||||
.settings-card-head > span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: var(--surface);
|
||||
}
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0;
|
||||
}
|
||||
.setting-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 1fr) minmax(260px, 520px);
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-radius: 0;
|
||||
background: var(--surface);
|
||||
padding: 16px;
|
||||
}
|
||||
.setting-row:last-child { border-bottom: 0; }
|
||||
.setting-rich {
|
||||
align-items: start;
|
||||
min-height: 0;
|
||||
}
|
||||
.setting-copy {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
max-width: 620px;
|
||||
}
|
||||
.setting-copy > div {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
.setting-copy small,
|
||||
.setting-rich small {
|
||||
color: var(--muted);
|
||||
line-height: 1.35;
|
||||
}
|
||||
.setting-path {
|
||||
font-size: 12px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
}
|
||||
.setting-control {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
.setting-control.wide {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
justify-content: stretch;
|
||||
}
|
||||
.setting-row input[type="number"], .setting-row select { width: 132px; }
|
||||
.setting-row input[type="text"],
|
||||
.setting-row input[type="password"],
|
||||
.setting-row textarea {
|
||||
width: 100%;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
padding: 10px;
|
||||
}
|
||||
.setting-row textarea {
|
||||
resize: vertical;
|
||||
min-height: 70px;
|
||||
}
|
||||
.setting-row input[type="checkbox"] {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
align-self: center;
|
||||
}
|
||||
.switch {
|
||||
justify-self: end;
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
width: 48px;
|
||||
height: 28px;
|
||||
}
|
||||
.switch input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
}
|
||||
.switch span {
|
||||
width: 100%;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
transition: background .15s ease, border-color .15s ease;
|
||||
}
|
||||
.switch span::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
border-radius: 50%;
|
||||
background: var(--muted);
|
||||
transition: transform .15s ease, background .15s ease;
|
||||
}
|
||||
.switch input:checked + span {
|
||||
border-color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 20%, var(--surface-2));
|
||||
}
|
||||
.switch input:checked + span::after {
|
||||
transform: translateX(20px);
|
||||
background: var(--accent);
|
||||
}
|
||||
.range-control {
|
||||
display: grid;
|
||||
align-content: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.range-control input[type="range"] {
|
||||
width: 100%;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
.range-control span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
.compound-control {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(160px, 1fr));
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
.compound-control input,
|
||||
.compound-control select {
|
||||
min-width: 0;
|
||||
}
|
||||
.compound-control label {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
.compound-control .inline-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.compound-control .span-2 {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.theme-options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||
gap: 10px;
|
||||
max-width: 980px;
|
||||
}
|
||||
.theme-option {
|
||||
display: grid;
|
||||
grid-template-columns: 42px 1fr;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
text-align: left;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.theme-option.active {
|
||||
border-color: var(--accent);
|
||||
box-shadow: inset 0 0 0 1px var(--accent);
|
||||
}
|
||||
.theme-swatch {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
width: 42px;
|
||||
height: 32px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg);
|
||||
}
|
||||
.theme-swatch i,
|
||||
.theme-swatch b,
|
||||
.theme-swatch em {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
}
|
||||
.theme-swatch i { background: var(--surface); }
|
||||
.theme-swatch b { background: var(--surface-2); }
|
||||
.theme-swatch em {
|
||||
grid-column: 1 / -1;
|
||||
background: var(--accent);
|
||||
}
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
overflow: auto;
|
||||
background: var(--surface-2);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.toast-host {
|
||||
position: fixed;
|
||||
right: 18px;
|
||||
bottom: 18px;
|
||||
z-index: 20;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
width: min(420px, calc(100vw - 36px));
|
||||
}
|
||||
.toast {
|
||||
transform: translateY(8px);
|
||||
opacity: 0;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-left: 4px solid var(--accent);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
box-shadow: 0 14px 34px rgba(0, 0, 0, .22);
|
||||
transition: opacity .18s ease, transform .18s ease;
|
||||
}
|
||||
.toast.visible {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
.toast.success { border-left-color: var(--good); }
|
||||
.toast.error { border-left-color: var(--bad); }
|
||||
@media (max-width: 900px) {
|
||||
.app-shell { grid-template-columns: 1fr; }
|
||||
.sidebar { position: static; height: auto; }
|
||||
.overview-grid { grid-template-columns: 1fr; }
|
||||
.downloads-layout { grid-template-columns: 1fr; }
|
||||
.detail-shell { grid-template-columns: 1fr; }
|
||||
.detail-poster { max-width: 220px; }
|
||||
.episode { grid-template-columns: 1fr; }
|
||||
.topbar, .section-head { align-items: stretch; flex-direction: column; }
|
||||
.actions, .pager { flex-wrap: wrap; }
|
||||
.settings-hero { grid-template-columns: 1fr; }
|
||||
.settings-card-head { flex-direction: column; }
|
||||
.setting-row { grid-template-columns: 1fr; gap: 14px; }
|
||||
.range-control { min-width: 0; }
|
||||
.setting-row input[type="text"],
|
||||
.setting-row input[type="password"],
|
||||
.setting-row textarea,
|
||||
.compound-control {
|
||||
width: 100%;
|
||||
}
|
||||
.compound-control { grid-template-columns: 1fr; }
|
||||
.bundle-head { flex-direction: column; }
|
||||
}
|
||||
134
web/src/themes.css
Normal file
134
web/src/themes.css
Normal file
@@ -0,0 +1,134 @@
|
||||
:root,
|
||||
[data-theme="slate"] {
|
||||
--bg: #111318;
|
||||
--surface: #191d24;
|
||||
--surface-2: #222833;
|
||||
--text: #eef2f7;
|
||||
--muted: #96a1af;
|
||||
--border: #303846;
|
||||
--accent: #60a5fa;
|
||||
--good: #34d399;
|
||||
--warn: #fbbf24;
|
||||
--bad: #f87171;
|
||||
--radius: 8px;
|
||||
--density: 1;
|
||||
--font: Inter, ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
[data-theme="midnight"] {
|
||||
--bg: #080b12;
|
||||
--surface: #121826;
|
||||
--surface-2: #1b2740;
|
||||
--text: #f8fafc;
|
||||
--muted: #93a4bd;
|
||||
--border: #293550;
|
||||
--accent: #22d3ee;
|
||||
--good: #4ade80;
|
||||
--warn: #facc15;
|
||||
--bad: #fb7185;
|
||||
}
|
||||
|
||||
[data-theme="graphite"] {
|
||||
--bg: #151515;
|
||||
--surface: #202020;
|
||||
--surface-2: #2b2b2b;
|
||||
--text: #f5f5f5;
|
||||
--muted: #b2b2b2;
|
||||
--border: #3a3a3a;
|
||||
--accent: #a3e635;
|
||||
--good: #86efac;
|
||||
--warn: #fde047;
|
||||
--bad: #fca5a5;
|
||||
}
|
||||
|
||||
[data-theme="nord"] {
|
||||
--bg: #202632;
|
||||
--surface: #2c3444;
|
||||
--surface-2: #374155;
|
||||
--text: #eceff4;
|
||||
--muted: #c0c9d8;
|
||||
--border: #4c566a;
|
||||
--accent: #88c0d0;
|
||||
--good: #a3be8c;
|
||||
--warn: #ebcb8b;
|
||||
--bad: #bf616a;
|
||||
}
|
||||
|
||||
[data-theme="dracula"] {
|
||||
--bg: #1d1b26;
|
||||
--surface: #282a36;
|
||||
--surface-2: #343746;
|
||||
--text: #f8f8f2;
|
||||
--muted: #c7bfdc;
|
||||
--border: #44475a;
|
||||
--accent: #bd93f9;
|
||||
--good: #50fa7b;
|
||||
--warn: #f1fa8c;
|
||||
--bad: #ff5555;
|
||||
}
|
||||
|
||||
[data-theme="solar"] {
|
||||
--bg: #f4f0df;
|
||||
--surface: #fffaf0;
|
||||
--surface-2: #eee8d5;
|
||||
--text: #273238;
|
||||
--muted: #657b83;
|
||||
--border: #d5cdb6;
|
||||
--accent: #268bd2;
|
||||
--good: #2aa198;
|
||||
--warn: #b58900;
|
||||
--bad: #dc322f;
|
||||
}
|
||||
|
||||
[data-theme="forest"] {
|
||||
--bg: #101812;
|
||||
--surface: #18251b;
|
||||
--surface-2: #213326;
|
||||
--text: #eef7ed;
|
||||
--muted: #a7b9a6;
|
||||
--border: #314638;
|
||||
--accent: #7ddf64;
|
||||
--good: #22c55e;
|
||||
--warn: #eab308;
|
||||
--bad: #ef4444;
|
||||
}
|
||||
|
||||
[data-theme="marine"] {
|
||||
--bg: #081417;
|
||||
--surface: #102225;
|
||||
--surface-2: #183236;
|
||||
--text: #edfdfd;
|
||||
--muted: #9fc5c7;
|
||||
--border: #28494e;
|
||||
--accent: #2dd4bf;
|
||||
--good: #5eead4;
|
||||
--warn: #fcd34d;
|
||||
--bad: #f97373;
|
||||
}
|
||||
|
||||
[data-theme="ember"] {
|
||||
--bg: #171111;
|
||||
--surface: #241818;
|
||||
--surface-2: #362221;
|
||||
--text: #fff7ed;
|
||||
--muted: #d7b6a2;
|
||||
--border: #513530;
|
||||
--accent: #fb923c;
|
||||
--good: #84cc16;
|
||||
--warn: #facc15;
|
||||
--bad: #f43f5e;
|
||||
}
|
||||
|
||||
[data-theme="paper"] {
|
||||
--bg: #f7f8fa;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #eef1f5;
|
||||
--text: #151a22;
|
||||
--muted: #5f6b7a;
|
||||
--border: #d6dce5;
|
||||
--accent: #2563eb;
|
||||
--good: #16a34a;
|
||||
--warn: #ca8a04;
|
||||
--bad: #dc2626;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user