Improve library identification and track inspection
This commit is contained in:
@@ -2,6 +2,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
@@ -9,10 +11,10 @@ from urllib.parse import parse_qs, unquote
|
|||||||
|
|
||||||
from .config import load_config, public_config
|
from .config import load_config, public_config
|
||||||
from .downloads import downloads_snapshot
|
from .downloads import downloads_snapshot
|
||||||
from .library import library_snapshot, normalize_library
|
from .library import enrich_library_metadata, library_snapshot, normalize_library
|
||||||
from .logging_setup import configure_logging
|
from .logging_setup import configure_logging
|
||||||
from .media_probe import edit_track, media_probe
|
from .media_probe import edit_track, media_probe
|
||||||
from .metadata import test_tmdb
|
from .metadata import movie_metadata_by_id, search_metadata, series_metadata_by_id, test_tmdb
|
||||||
from .organizer import execute_bundle_plan
|
from .organizer import execute_bundle_plan
|
||||||
from .releases import fetch_releases
|
from .releases import fetch_releases
|
||||||
from .scanner import Scanner
|
from .scanner import Scanner
|
||||||
@@ -155,6 +157,73 @@ configure_logging(CONFIG["paths"]["logs"], CONFIG["app"].get("log_level", "INFO"
|
|||||||
STORE = JsonStore(CONFIG["paths"]["data"])
|
STORE = JsonStore(CONFIG["paths"]["data"])
|
||||||
apply_settings(CONFIG, STORE.snapshot().get("settings", {}))
|
apply_settings(CONFIG, STORE.snapshot().get("settings", {}))
|
||||||
SCANNER = Scanner(CONFIG, STORE)
|
SCANNER = Scanner(CONFIG, STORE)
|
||||||
|
METADATA_REFRESH = {"running": False, "started_at": None, "finished_at": None, "error": None}
|
||||||
|
METADATA_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def start_metadata_refresh() -> bool:
|
||||||
|
with METADATA_LOCK:
|
||||||
|
if METADATA_REFRESH["running"]:
|
||||||
|
return False
|
||||||
|
METADATA_REFRESH.update({"running": True, "started_at": time.time(), "finished_at": None, "error": None})
|
||||||
|
|
||||||
|
def worker() -> None:
|
||||||
|
try:
|
||||||
|
snap = STORE.snapshot()
|
||||||
|
library = snap.get("library") or {}
|
||||||
|
if not library.get("items"):
|
||||||
|
library = library_snapshot(CONFIG, snap.get("library"))
|
||||||
|
enriched = enrich_library_metadata(CONFIG, library)
|
||||||
|
STORE.set_library(enriched)
|
||||||
|
with METADATA_LOCK:
|
||||||
|
METADATA_REFRESH.update({"running": False, "finished_at": time.time(), "error": None})
|
||||||
|
except Exception as exc:
|
||||||
|
with METADATA_LOCK:
|
||||||
|
METADATA_REFRESH.update({"running": False, "finished_at": time.time(), "error": str(exc)})
|
||||||
|
|
||||||
|
threading.Thread(target=worker, daemon=True).start()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def public_library_payload(library: dict) -> dict:
|
||||||
|
public = normalize_library(library)
|
||||||
|
public.pop("items", None)
|
||||||
|
return public
|
||||||
|
|
||||||
|
|
||||||
|
def find_collection(library: dict, key: str, media_library: str) -> dict | None:
|
||||||
|
collections = library.get("collections") or {}
|
||||||
|
group = "series" if media_library == "tv" else "movies"
|
||||||
|
return next((item for item in collections.get(group, []) if item.get("key") == key), None)
|
||||||
|
|
||||||
|
|
||||||
|
def identify_collection(payload: dict) -> dict:
|
||||||
|
media_library = "tv" if payload.get("library") == "tv" else "movie"
|
||||||
|
key = str(payload.get("key") or "")
|
||||||
|
tmdb_id = int(payload.get("tmdb_id") or 0)
|
||||||
|
snap = STORE.snapshot()
|
||||||
|
library = snap.get("library") or {}
|
||||||
|
collection = find_collection(library, key, media_library)
|
||||||
|
if not collection:
|
||||||
|
raise ValueError("library item was not found")
|
||||||
|
if media_library == "tv":
|
||||||
|
seasons = {int(season.get("season") or 0) for season in collection.get("seasons", []) if int(season.get("season") or 0) > 0}
|
||||||
|
metadata = series_metadata_by_id(CONFIG, tmdb_id, seasons)
|
||||||
|
else:
|
||||||
|
metadata = movie_metadata_by_id(CONFIG, tmdb_id)
|
||||||
|
if metadata.get("release_date"):
|
||||||
|
collection["year"] = int(metadata["release_date"][:4])
|
||||||
|
collection["metadata"] = metadata
|
||||||
|
collection["title"] = metadata.get("title") or collection.get("title")
|
||||||
|
identifications = library.setdefault("identifications", {})
|
||||||
|
identifications[key] = {
|
||||||
|
"library": media_library,
|
||||||
|
"tmdb_id": tmdb_id,
|
||||||
|
"metadata": metadata,
|
||||||
|
"updated_at": time.time(),
|
||||||
|
}
|
||||||
|
STORE.set_library(library)
|
||||||
|
return collection
|
||||||
|
|
||||||
|
|
||||||
class Handler(BaseHTTPRequestHandler):
|
class Handler(BaseHTTPRequestHandler):
|
||||||
@@ -222,9 +291,9 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
"truncated": False,
|
"truncated": False,
|
||||||
"cached": False,
|
"cached": False,
|
||||||
}
|
}
|
||||||
library = normalize_library(library)
|
self.send_json({"library": public_library_payload(library)})
|
||||||
library.pop("items", None)
|
elif path == "/api/library/metadata/status":
|
||||||
self.send_json({"library": library})
|
self.send_json({"metadata": METADATA_REFRESH})
|
||||||
elif path == "/api/downloads":
|
elif path == "/api/downloads":
|
||||||
self.send_json({"downloads": downloads_snapshot(CONFIG, STORE.snapshot())})
|
self.send_json({"downloads": downloads_snapshot(CONFIG, STORE.snapshot())})
|
||||||
elif path == "/api/releases":
|
elif path == "/api/releases":
|
||||||
@@ -294,9 +363,25 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
STORE.set_organizer_queue(updated)
|
STORE.set_organizer_queue(updated)
|
||||||
self.send_json({"ok": True})
|
self.send_json({"ok": True})
|
||||||
elif path == "/api/library/scan":
|
elif path == "/api/library/scan":
|
||||||
library = library_snapshot(CONFIG)
|
snap = STORE.snapshot()
|
||||||
|
library = library_snapshot(CONFIG, snap.get("library"))
|
||||||
STORE.set_library(library)
|
STORE.set_library(library)
|
||||||
self.send_json({"library": library})
|
self.send_json({"library": library})
|
||||||
|
elif path == "/api/library/metadata/refresh":
|
||||||
|
started = start_metadata_refresh()
|
||||||
|
self.send_json({"started": started, "metadata": METADATA_REFRESH}, HTTPStatus.ACCEPTED)
|
||||||
|
elif path == "/api/library/identify/search":
|
||||||
|
length = int(self.headers.get("Content-Length", "0") or "0")
|
||||||
|
body = self.rfile.read(length).decode() if length else "{}"
|
||||||
|
payload = json.loads(body)
|
||||||
|
results = search_metadata(CONFIG, "tv" if payload.get("library") == "tv" else "movie", str(payload.get("query") or ""), payload.get("year"))
|
||||||
|
self.send_json({"results": results})
|
||||||
|
elif path == "/api/library/identify/apply":
|
||||||
|
length = int(self.headers.get("Content-Length", "0") or "0")
|
||||||
|
body = self.rfile.read(length).decode() if length else "{}"
|
||||||
|
payload = json.loads(body)
|
||||||
|
collection = identify_collection(payload)
|
||||||
|
self.send_json({"collection": collection})
|
||||||
elif path == "/api/tools/transcoder/run-next":
|
elif path == "/api/tools/transcoder/run-next":
|
||||||
result = run_next_transcode(CONFIG, STORE.snapshot().get("library"))
|
result = run_next_transcode(CONFIG, STORE.snapshot().get("library"))
|
||||||
STORE.add_event("info", f"transcoder: {result.get('status')}")
|
STORE.add_event("info", f"transcoder: {result.get('status')}")
|
||||||
|
|||||||
@@ -266,7 +266,28 @@ def build_collections(config: dict, items: list[dict], enrich: bool = False) ->
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def library_snapshot(config: dict) -> dict:
|
def preserve_metadata(collections: dict, previous_library: dict | None) -> dict:
|
||||||
|
previous = (previous_library or {}).get("collections") or {}
|
||||||
|
previous_by_key = {
|
||||||
|
item.get("key"): item
|
||||||
|
for group in ("movies", "series")
|
||||||
|
for item in previous.get(group, [])
|
||||||
|
if item.get("key")
|
||||||
|
}
|
||||||
|
for group in ("movies", "series"):
|
||||||
|
for item in collections.get(group, []):
|
||||||
|
old = previous_by_key.get(item.get("key"))
|
||||||
|
old_meta = (old or {}).get("metadata") or {}
|
||||||
|
if old_meta.get("source") == "tmdb":
|
||||||
|
item["metadata"] = old_meta
|
||||||
|
if old_meta.get("manual"):
|
||||||
|
item["title"] = old_meta.get("title") or item.get("title")
|
||||||
|
if item.get("library") == "movie" and old_meta.get("release_date"):
|
||||||
|
item["year"] = int(old_meta["release_date"][:4])
|
||||||
|
return collections
|
||||||
|
|
||||||
|
|
||||||
|
def library_snapshot(config: dict, previous_library: dict | None = None) -> dict:
|
||||||
items = []
|
items = []
|
||||||
extensions = Counter()
|
extensions = Counter()
|
||||||
ignored_dirs = {"$RECYCLE.BIN", "System Volume Information", ".Trash-1000"}
|
ignored_dirs = {"$RECYCLE.BIN", "System Volume Information", ".Trash-1000"}
|
||||||
@@ -320,12 +341,27 @@ def library_snapshot(config: dict) -> dict:
|
|||||||
})
|
})
|
||||||
enrich_limit = int(app.get("library_metadata_enrich_max_items", 500))
|
enrich_limit = int(app.get("library_metadata_enrich_max_items", 500))
|
||||||
should_enrich = bool(config.get("metadata", {}).get("tmdb_enabled", True)) and len(items) <= enrich_limit
|
should_enrich = bool(config.get("metadata", {}).get("tmdb_enabled", True)) and len(items) <= enrich_limit
|
||||||
|
collections = build_collections(config, items, enrich=should_enrich)
|
||||||
|
if not should_enrich:
|
||||||
|
collections = preserve_metadata(collections, previous_library)
|
||||||
return normalize_library({
|
return normalize_library({
|
||||||
"drives": drive_stats(config),
|
"drives": drive_stats(config),
|
||||||
"items": sorted(items, key=lambda item: item["modified"], reverse=True),
|
"items": sorted(items, key=lambda item: item["modified"], reverse=True),
|
||||||
"collections": build_collections(config, items, enrich=should_enrich),
|
"collections": collections,
|
||||||
"extensions": dict(extensions.most_common()),
|
"extensions": dict(extensions.most_common()),
|
||||||
"scanned_files": scanned,
|
"scanned_files": scanned,
|
||||||
"truncated": truncated,
|
"truncated": truncated,
|
||||||
"metadata_enriched": should_enrich,
|
"metadata_enriched": should_enrich,
|
||||||
|
"identifications": (previous_library or {}).get("identifications", {}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def enrich_library_metadata(config: dict, library: dict) -> dict:
|
||||||
|
items = library.get("items") or []
|
||||||
|
enriched = {
|
||||||
|
**library,
|
||||||
|
"collections": build_collections(config, items, enrich=True),
|
||||||
|
"metadata_enriched": True,
|
||||||
|
"metadata_refreshed_at": time.time(),
|
||||||
|
}
|
||||||
|
return normalize_library(enriched)
|
||||||
|
|||||||
@@ -117,6 +117,88 @@ def movie_metadata(config: dict, title: str, year: int | None = None) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def search_metadata(config: dict, library: str, query: str, year: int | None = None) -> list[dict]:
|
||||||
|
media_type = "tv" if library == "tv" else "movie"
|
||||||
|
if not tmdb_available(config) or not query:
|
||||||
|
return []
|
||||||
|
params = {"query": query}
|
||||||
|
if year and media_type == "movie":
|
||||||
|
params["year"] = year
|
||||||
|
elif year:
|
||||||
|
params["first_air_date_year"] = year
|
||||||
|
payload = tmdb_get(config, f"/search/{media_type}", params)
|
||||||
|
results = payload.get("results") or []
|
||||||
|
normalized = []
|
||||||
|
for item in results[:12]:
|
||||||
|
title = item.get("name") if media_type == "tv" else item.get("title")
|
||||||
|
date = item.get("first_air_date") if media_type == "tv" else item.get("release_date")
|
||||||
|
normalized.append({
|
||||||
|
"tmdb_id": item.get("id"),
|
||||||
|
"library": library,
|
||||||
|
"title": title,
|
||||||
|
"date": date,
|
||||||
|
"year": int(date[:4]) if date and date[:4].isdigit() else None,
|
||||||
|
"overview": item.get("overview") or "",
|
||||||
|
"poster": poster_url(config, item.get("poster_path")),
|
||||||
|
"backdrop": poster_url(config, item.get("backdrop_path")),
|
||||||
|
"vote_average": item.get("vote_average"),
|
||||||
|
})
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def movie_metadata_by_id(config: dict, tmdb_id: int) -> dict:
|
||||||
|
payload = tmdb_get(config, f"/movie/{tmdb_id}")
|
||||||
|
return {
|
||||||
|
"source": "tmdb",
|
||||||
|
"manual": True,
|
||||||
|
"tmdb_id": payload.get("id"),
|
||||||
|
"title": payload.get("title") or payload.get("original_title") or "",
|
||||||
|
"overview": payload.get("overview") or "",
|
||||||
|
"poster": poster_url(config, payload.get("poster_path")),
|
||||||
|
"backdrop": poster_url(config, payload.get("backdrop_path")),
|
||||||
|
"release_date": payload.get("release_date"),
|
||||||
|
"vote_average": payload.get("vote_average"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def series_metadata_by_id(config: dict, tmdb_id: int, seasons: set[int]) -> dict:
|
||||||
|
payload = tmdb_get(config, f"/tv/{tmdb_id}")
|
||||||
|
metadata = {
|
||||||
|
"source": "tmdb",
|
||||||
|
"manual": True,
|
||||||
|
"tmdb_id": payload.get("id"),
|
||||||
|
"title": payload.get("name") or payload.get("original_name") or "",
|
||||||
|
"overview": payload.get("overview") or "",
|
||||||
|
"poster": poster_url(config, payload.get("poster_path")),
|
||||||
|
"backdrop": poster_url(config, payload.get("backdrop_path")),
|
||||||
|
"first_air_date": payload.get("first_air_date"),
|
||||||
|
"vote_average": payload.get("vote_average"),
|
||||||
|
"seasons": {},
|
||||||
|
}
|
||||||
|
for season in sorted(seasons):
|
||||||
|
try:
|
||||||
|
season_payload = tmdb_get(config, f"/tv/{tmdb_id}/season/{season}")
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
metadata["seasons"][str(season)] = {
|
||||||
|
"name": season_payload.get("name"),
|
||||||
|
"air_date": season_payload.get("air_date"),
|
||||||
|
"episode_count": len(season_payload.get("episodes") or []),
|
||||||
|
"episodes": [
|
||||||
|
{
|
||||||
|
"season": season,
|
||||||
|
"episode": episode.get("episode_number"),
|
||||||
|
"title": episode.get("name"),
|
||||||
|
"overview": episode.get("overview") or "",
|
||||||
|
"air_date": episode.get("air_date"),
|
||||||
|
"still": poster_url(config, episode.get("still_path")),
|
||||||
|
}
|
||||||
|
for episode in season_payload.get("episodes") or []
|
||||||
|
],
|
||||||
|
}
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
|
||||||
def series_metadata(config: dict, title: str, seasons: set[int]) -> dict:
|
def series_metadata(config: dict, title: str, seasons: set[int]) -> dict:
|
||||||
result = first_result(config, "tv", title)
|
result = first_result(config, "tv", title)
|
||||||
if not result:
|
if not result:
|
||||||
|
|||||||
232
web/src/app.js
232
web/src/app.js
@@ -108,6 +108,7 @@ const state = {
|
|||||||
libraryTab: "all",
|
libraryTab: "all",
|
||||||
libraryLimit: 120,
|
libraryLimit: 120,
|
||||||
selectedMedia: null,
|
selectedMedia: null,
|
||||||
|
detailModalOpen: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
@@ -257,7 +258,11 @@ function libraryCollections() {
|
|||||||
const all = [
|
const all = [
|
||||||
...collections.movies.map((item) => ({ ...item, library: "movie" })),
|
...collections.movies.map((item) => ({ ...item, library: "movie" })),
|
||||||
...collections.series.map((item) => ({ ...item, library: "tv" })),
|
...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) => {
|
return all.filter((item) => {
|
||||||
const meta = item.metadata || {};
|
const meta = item.metadata || {};
|
||||||
const matchesTab = state.libraryTab === "all" || item.library === state.libraryTab;
|
const matchesTab = state.libraryTab === "all" || item.library === state.libraryTab;
|
||||||
@@ -303,11 +308,6 @@ function renderLibrary() {
|
|||||||
renderLibrary();
|
renderLibrary();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!state.selectedMedia && visible[0]) {
|
|
||||||
selectMedia(visible[0].key, false);
|
|
||||||
} else if (state.selectedMedia) {
|
|
||||||
renderMediaDetail(state.selectedMedia);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function mediaCard(item) {
|
function mediaCard(item) {
|
||||||
@@ -334,39 +334,68 @@ function findMedia(key) {
|
|||||||
return [...collections.movies, ...collections.series].find((item) => item.key === key);
|
return [...collections.movies, ...collections.series].find((item) => item.key === key);
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectMedia(key, scroll = true) {
|
function selectMedia(key) {
|
||||||
const item = findMedia(key);
|
const item = findMedia(key);
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
state.selectedMedia = item;
|
state.selectedMedia = item;
|
||||||
|
state.detailModalOpen = true;
|
||||||
document.querySelectorAll("[data-media-key]").forEach((button) => button.classList.toggle("active", button.dataset.mediaKey === key));
|
document.querySelectorAll("[data-media-key]").forEach((button) => button.classList.toggle("active", button.dataset.mediaKey === key));
|
||||||
renderMediaDetail(item);
|
renderMediaDetail(item);
|
||||||
if (scroll) $("libraryDetail").scrollIntoView({ behavior: "smooth", block: "start" });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMediaDetail(item) {
|
function renderMediaDetail(item) {
|
||||||
|
closeMediaModal();
|
||||||
const meta = item.metadata || {};
|
const meta = item.metadata || {};
|
||||||
const files = item.files || [];
|
const files = item.files || [];
|
||||||
const title = meta.title || item.title;
|
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 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);
|
const detail = item.library === "tv" ? renderSeriesDetail(item) : renderMovieDetail(item);
|
||||||
$("libraryDetail").innerHTML = `<article class="detail-shell">
|
const modal = document.createElement("div");
|
||||||
<div class="poster detail-poster">${cover}</div>
|
modal.id = "mediaModal";
|
||||||
<div class="detail-body">
|
modal.className = "modal-backdrop";
|
||||||
<div class="section-head">
|
modal.innerHTML = `<article class="media-modal" role="dialog" aria-modal="true" aria-label="${esc(title)}">
|
||||||
<div>
|
<button class="modal-close" type="button" data-modal-close aria-label="Close">x</button>
|
||||||
<h2>${esc(title)}</h2>
|
<div class="detail-shell">
|
||||||
<p class="muted">${esc(item.library === "tv" ? "TV Series" : "Movie")} ${meta.source === "tmdb" ? "from TMDb metadata" : "from local filenames"}</p>
|
<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>
|
</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>
|
</div>
|
||||||
${meta.overview ? `<p>${esc(meta.overview)}</p>` : ""}
|
|
||||||
${detail}
|
|
||||||
<div id="probeOutput" class="probe-output"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</article>`;
|
</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) => {
|
document.querySelectorAll("[data-probe-path]").forEach((button) => {
|
||||||
button.addEventListener("click", () => inspectMedia(button.dataset.probePath));
|
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) {
|
function renderMovieDetail(item) {
|
||||||
@@ -386,13 +415,100 @@ function renderMovieDetail(item) {
|
|||||||
function versionRow(version) {
|
function versionRow(version) {
|
||||||
const tags = (version.tags || []).map((tag) => `<span>${esc(tag)}</span>`).join("");
|
const tags = (version.tags || []).map((tag) => `<span>${esc(tag)}</span>`).join("");
|
||||||
return `<div class="download">
|
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>
|
<div class="kv"><span>${esc(version.drive || "")}</span><span>${bytes(version.size)}</span></div>
|
||||||
${tags ? `<div class="subtitle-chips">${tags}</div>` : ""}
|
${tags ? `<div class="subtitle-chips">${tags}</div>` : ""}
|
||||||
<small class="muted">${esc(version.path || "")}</small>
|
|
||||||
</div>`;
|
</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) {
|
function renderSeriesDetail(item) {
|
||||||
return `<div class="season-list">${(item.seasons || []).map((season) => `
|
return `<div class="season-list">${(item.seasons || []).map((season) => `
|
||||||
<details open>
|
<details open>
|
||||||
@@ -423,51 +539,86 @@ function fileRow(file) {
|
|||||||
|
|
||||||
async function inspectMedia(path) {
|
async function inspectMedia(path) {
|
||||||
const output = $("probeOutput");
|
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 payload = await api(`/api/media/probe?path=${encodeURIComponent(path)}`);
|
||||||
const media = payload.media;
|
const media = payload.media;
|
||||||
state.currentProbePath = path;
|
state.currentProbePath = path;
|
||||||
output.innerHTML = `<div class="detail-block">
|
const filename = path.split("/").pop();
|
||||||
<h3>Media Info</h3>
|
const dryRun = state.dashboard?.dry_run;
|
||||||
<div class="stream-grid">
|
output.innerHTML = `<div class="detail-block media-inspector">
|
||||||
<section><strong>Video</strong>${streamRows(media.video)}</section>
|
<div class="section-head">
|
||||||
<section><strong>Audio Tracks</strong>${streamRows(media.audio)}</section>
|
<div>
|
||||||
<section><strong>Subtitles</strong>${streamRows(media.subtitles)}</section>
|
<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>
|
</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>`;
|
</div>`;
|
||||||
document.querySelectorAll("[data-track-action]").forEach((button) => {
|
document.querySelectorAll("[data-track-action]").forEach((button) => {
|
||||||
button.addEventListener("click", () => editTrack(path, button.dataset.trackAction, Number(button.dataset.streamIndex)));
|
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) => {
|
return streams.map((stream) => {
|
||||||
const tags = stream.tags || {};
|
const tags = stream.tags || {};
|
||||||
return `<div class="stream-row">
|
const isEditable = stream.codec_type === "audio" || stream.codec_type === "subtitle";
|
||||||
<span>${esc(stream.codec_name || stream.codec_type || "unknown")}</span>
|
return `<div class="stream-row ${stream.disposition?.default ? "is-default" : ""}">
|
||||||
<small>${esc(tags.language || "und")} ${esc(tags.title || "")} ${stream.channels ? `${stream.channels} ch` : ""}</small>
|
<div>
|
||||||
${stream.codec_type === "audio" || stream.codec_type === "subtitle" ? `<span class="track-actions">
|
<strong>${esc(streamTitle(stream, type))}</strong>
|
||||||
<button data-track-action="set-default" data-stream-index="${stream.index}">Set default</button>
|
<small>${esc(streamMeta(stream) || `Stream ${stream.index}`)}</small>
|
||||||
<button data-track-action="remove" data-stream-index="${stream.index}">Remove</button>
|
</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>` : ""}
|
</span>` : ""}
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join("") || "<p class='muted'>None detected.</p>";
|
}).join("") || "<p class='muted'>None detected.</p>";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function editTrack(path, action, streamIndex) {
|
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", {
|
const payload = await api("/api/media/tracks", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ path, action, stream_index: streamIndex }),
|
body: JSON.stringify({ path, action, stream_index: streamIndex }),
|
||||||
});
|
});
|
||||||
const result = payload.media;
|
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>
|
<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.command ? `<code>${esc(result.command.join(" "))}</code>` : ""}
|
||||||
${result.stderr ? `<pre>${esc(result.stderr)}</pre>` : ""}
|
${result.stderr ? `<pre>${esc(result.stderr)}</pre>` : ""}
|
||||||
</div>`);
|
</div>`;
|
||||||
if (result.status === "updated") {
|
if (result.status === "updated") {
|
||||||
await inspectMedia(path);
|
await inspectMedia(path);
|
||||||
}
|
}
|
||||||
@@ -937,6 +1088,9 @@ function showStartupError(error) {
|
|||||||
function init() {
|
function init() {
|
||||||
setTheme(localStorage.getItem("sortarr-theme") || "slate");
|
setTheme(localStorage.getItem("sortarr-theme") || "slate");
|
||||||
window.addEventListener("hashchange", renderRoute);
|
window.addEventListener("hashchange", renderRoute);
|
||||||
|
window.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "Escape") closeMediaModal();
|
||||||
|
});
|
||||||
renderRoute();
|
renderRoute();
|
||||||
$("refreshButton").addEventListener("click", loadDashboard);
|
$("refreshButton").addEventListener("click", loadDashboard);
|
||||||
$("scanButton").addEventListener("click", runScan);
|
$("scanButton").addEventListener("click", runScan);
|
||||||
|
|||||||
@@ -146,6 +146,6 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<div id="toastHost" class="toast-host" aria-live="polite"></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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -146,6 +146,14 @@ td:first-child {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
.poster.tiny {
|
||||||
|
width: 54px;
|
||||||
|
min-width: 54px;
|
||||||
|
aspect-ratio: 2 / 3;
|
||||||
|
}
|
||||||
|
.poster.tiny .poster-placeholder {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
.poster-placeholder {
|
.poster-placeholder {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
@@ -182,6 +190,47 @@ td:first-child {
|
|||||||
.media-detail {
|
.media-detail {
|
||||||
margin-top: 22px;
|
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 {
|
.detail-shell {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 190px minmax(0, 1fr);
|
grid-template-columns: 190px minmax(0, 1fr);
|
||||||
@@ -198,10 +247,42 @@ td:first-child {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.detail-block,
|
.detail-block,
|
||||||
.season-list {
|
.season-list,
|
||||||
|
.identify-output {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
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 {
|
.season-list details {
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
@@ -257,19 +338,60 @@ td:first-child {
|
|||||||
}
|
}
|
||||||
.stream-row {
|
.stream-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding-top: 8px;
|
align-items: center;
|
||||||
border-top: 1px solid var(--border);
|
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 {
|
.track-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
.track-actions button {
|
.track-actions button {
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
font-size: 12px;
|
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 {
|
.downloads-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(320px, 1fr) minmax(0, 1.2fr) minmax(320px, .8fr);
|
grid-template-columns: minmax(320px, 1fr) minmax(0, 1.2fr) minmax(320px, .8fr);
|
||||||
|
|||||||
Reference in New Issue
Block a user