feat: Import-Job loeschen, TVDB-Pflichtvalidierung, Staffel-Ordner-Erkennung

- Import-Jobs koennen geloescht werden (Uebersicht + Preview)
- TVDB-Validierung als Pflicht: Ohne Match wird Item als 'pending' markiert
- Erkennung von "Staffel X" / "Season X" Ordnernamen fuer Serien-Zuordnung
- Verhindert Ghost-Serien durch Scene-Release-Prefixes (z.B. jajunge-24)
- Import-Button gesperrt solange nicht alle Items zugeordnet sind
- Favicon in base.html eingebunden

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-02-25 17:48:09 +01:00
parent 7ba24a097a
commit 8fe00beaad
5 changed files with 134 additions and 10 deletions

View file

@ -1211,6 +1211,18 @@ def setup_library_routes(app: web.Application, config: Config,
result = await importer_service.get_job_status(job_id) result = await importer_service.get_job_status(job_id)
return web.json_response(result) return web.json_response(result)
async def delete_import_job(request: web.Request) -> web.Response:
"""DELETE /api/library/import/{job_id}"""
if not importer_service:
return web.json_response(
{"error": "Import-Service nicht verfuegbar"}, status=500
)
job_id = int(request.match_info["job_id"])
result = await importer_service.delete_job(job_id)
if "error" in result:
return web.json_response(result, status=400)
return web.json_response(result)
async def post_execute_import(request: web.Request) -> web.Response: async def post_execute_import(request: web.Request) -> web.Response:
"""POST /api/library/import/{job_id}/execute""" """POST /api/library/import/{job_id}/execute"""
if not importer_service: if not importer_service:
@ -1609,6 +1621,9 @@ def setup_library_routes(app: web.Application, config: Config,
# Import # Import
app.router.add_get("/api/library/import", get_import_jobs) app.router.add_get("/api/library/import", get_import_jobs)
app.router.add_post("/api/library/import", post_create_import) app.router.add_post("/api/library/import", post_create_import)
app.router.add_delete(
"/api/library/import/{job_id}", delete_import_job
)
app.router.add_post( app.router.add_post(
"/api/library/import/{job_id}/analyze", post_analyze_import "/api/library/import/{job_id}/analyze", post_analyze_import
) )

View file

@ -24,6 +24,10 @@ RE_SERIES_FROM_NAME = re.compile(
RE_SERIES_FROM_XXx = re.compile( RE_SERIES_FROM_XXx = re.compile(
r'^(.+?)[\s._-]+\d{1,2}x\d{2,3}', re.IGNORECASE r'^(.+?)[\s._-]+\d{1,2}x\d{2,3}', re.IGNORECASE
) )
# "Serienname - Staffel X" oder "Serienname Season X" in Ordnernamen
RE_STAFFEL_DIR = re.compile(
r'^(.+?)[\s._-]+(?:Staffel|Season)\s*(\d{1,2})\s*$', re.IGNORECASE
)
class ImporterService: class ImporterService:
@ -172,6 +176,32 @@ class ImporterService:
logging.error(f"Import-Job erstellen fehlgeschlagen: {e}") logging.error(f"Import-Job erstellen fehlgeschlagen: {e}")
return None return None
async def delete_job(self, job_id: int) -> dict:
"""Loescht einen Import-Job (nur wenn nicht gerade importiert wird)"""
if not self._db_pool:
return {"error": "Keine DB-Verbindung"}
try:
async with self._db_pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute(
"SELECT status FROM import_jobs WHERE id = %s",
(job_id,)
)
row = await cur.fetchone()
if not row:
return {"error": "Job nicht gefunden"}
if row[0] == 'importing':
return {"error": "Job laeuft gerade, kann nicht geloescht werden"}
# Items werden per ON DELETE CASCADE mitgeloescht
await cur.execute(
"DELETE FROM import_jobs WHERE id = %s", (job_id,)
)
logging.info(f"Import-Job {job_id} geloescht")
return {"message": f"Import-Job {job_id} geloescht"}
except Exception as e:
logging.error(f"Import-Job {job_id} loeschen fehlgeschlagen: {e}")
return {"error": str(e)}
async def analyze_job(self, job_id: int) -> dict: async def analyze_job(self, job_id: int) -> dict:
"""Analysiert alle Dateien: Erkennung + TVDB-Lookup + Konflikt-Check""" """Analysiert alle Dateien: Erkennung + TVDB-Lookup + Konflikt-Check"""
if not self._db_pool: if not self._db_pool:
@ -311,13 +341,25 @@ class ImporterService:
) )
target_path = os.path.join(target_dir, target_file) target_path = os.path.join(target_dir, target_file)
# 5. Konflikt-Check # 5. Status bestimmen: Nur matched wenn eindeutig identifiziert
status = "matched" if series_name and season and episode else "pending" if not (series_name and season and episode):
conflict = None status = "pending"
conflict = "Serie/Staffel/Episode nicht erkannt"
elif self.tvdb.is_configured and not tvdb_id:
# TVDB aktiv aber kein Match -> nicht eindeutig
status = "pending"
conflict = (
f"Serie '{series_name}' nicht in TVDB gefunden "
f"- bitte manuell zuordnen"
)
else:
status = "matched"
conflict = None
existing_path = None existing_path = None
existing_size = None existing_size = None
if os.path.exists(target_path): # 6. Datei-Konflikt pruefen (nur wenn Status matched)
if status == "matched" and os.path.exists(target_path):
existing_path = target_path existing_path = target_path
existing_size = os.path.getsize(target_path) existing_size = os.path.getsize(target_path)
source_size = item["source_size"] source_size = item["source_size"]
@ -340,7 +382,7 @@ class ImporterService:
conflict = "Datei existiert bereits" conflict = "Datei existiert bereits"
status = "conflict" status = "conflict"
# 6. In DB aktualisieren # 7. In DB aktualisieren
try: try:
async with self._db_pool.acquire() as conn: async with self._db_pool.acquire() as conn:
async with conn.cursor() as cur: async with conn.cursor() as cur:
@ -377,9 +419,13 @@ class ImporterService:
Ordnernamen als Fallback. Der Ordnername ist oft zuverlaessiger Ordnernamen als Fallback. Der Ordnername ist oft zuverlaessiger
bei Release-Gruppen-Prefixes (z.B. 'tlr-24.s07e01.mkv' vs bei Release-Gruppen-Prefixes (z.B. 'tlr-24.s07e01.mkv' vs
Ordner '24.S07E01.German.DL.1080p.Bluray.x264-TLR'). Ordner '24.S07E01.German.DL.1080p.Bluray.x264-TLR').
Erkennt auch 'Serienname - Staffel X'-Ordner (haeufig bei DE-Medien).
""" """
filename = os.path.basename(file_path) filename = os.path.basename(file_path)
parent_dir = os.path.basename(os.path.dirname(file_path)) parent_dir = os.path.basename(os.path.dirname(file_path))
grandparent_dir = os.path.basename(
os.path.dirname(os.path.dirname(file_path))
)
# Beide Quellen versuchen # Beide Quellen versuchen
info_file = self._parse_name(filename) info_file = self._parse_name(filename)
@ -399,6 +445,19 @@ class ImporterService:
info_dir["series"] = info_file["series"] info_dir["series"] = info_file["series"]
return info_dir return info_dir
# "Staffel X" / "Season X" Pattern im Ordnernamen
# z.B. "24 - Staffel 6" -> Serie="24", Staffel=6
# Episode kommt dann aus dem Dateinamen
staffel_info = self._parse_staffel_dir(parent_dir)
if not staffel_info:
staffel_info = self._parse_staffel_dir(grandparent_dir)
if staffel_info and info_file.get("episode"):
return {
"series": staffel_info["series"],
"season": staffel_info["season"],
"episode": info_file["episode"],
}
# Dateiname hat S/E # Dateiname hat S/E
if info_file.get("season") and info_file.get("episode"): if info_file.get("season") and info_file.get("episode"):
# Ordner-Serienname als Fallback wenn Datei keinen hat # Ordner-Serienname als Fallback wenn Datei keinen hat
@ -408,6 +467,17 @@ class ImporterService:
return info_file return info_file
@staticmethod
def _parse_staffel_dir(dir_name: str) -> Optional[dict]:
"""Erkennt 'Serienname - Staffel X' Pattern in Ordnernamen"""
m = RE_STAFFEL_DIR.match(dir_name)
if m:
series = m.group(1).replace(".", " ").replace("_", " ").strip()
series = re.sub(r'\s+', ' ', series).rstrip(" -")
if series:
return {"series": series, "season": int(m.group(2))}
return None
def _parse_name(self, name: str) -> dict: def _parse_name(self, name: str) -> dict:
"""Extrahiert Serienname, Staffel, Episode aus einem Namen""" """Extrahiert Serienname, Staffel, Episode aus einem Namen"""
result = {"series": "", "season": None, "episode": None} result = {"series": "", "season": None, "episode": None}

View file

@ -2063,12 +2063,15 @@ function loadExistingImportJobs() {
j.status === 'importing' ? 'Laeuft' : j.status === 'importing' ? 'Laeuft' :
j.status === 'error' ? 'Fehler' : j.status; j.status === 'error' ? 'Fehler' : j.status;
const sourceName = j.source_path.split('/').pop(); const sourceName = j.source_path.split('/').pop();
return `<button class="btn-small ${j.status === 'ready' ? 'btn-primary' : 'btn-secondary'}" return `<span style="display:inline-flex;align-items:center;gap:0.25rem">
<button class="btn-small ${j.status === 'ready' ? 'btn-primary' : 'btn-secondary'}"
onclick="loadImportJob(${j.id})" onclick="loadImportJob(${j.id})"
title="${escapeHtml(j.source_path)}"> title="${escapeHtml(j.source_path)}">
${escapeHtml(sourceName)} (${j.processed_files}/${j.total_files}) ${escapeHtml(sourceName)} (${j.processed_files}/${j.total_files})
<span class="tag ${statusClass}" style="margin-left:0.3rem;font-size:0.7rem">${statusText}</span> <span class="tag ${statusClass}" style="margin-left:0.3rem;font-size:0.7rem">${statusText}</span>
</button>`; </button>
${j.status !== 'importing' ? `<button class="btn-small btn-danger" onclick="deleteImportJob(${j.id}, event)" title="Job loeschen">&times;</button>` : ''}
</span>`;
}).join(""); }).join("");
}) })
.catch(() => { .catch(() => {
@ -2076,6 +2079,36 @@ function loadExistingImportJobs() {
}); });
} }
function deleteImportJob(jobId, ev) {
if (ev) ev.stopPropagation();
if (!confirm("Import-Job wirklich loeschen?")) return;
fetch(`/api/library/import/${jobId}`, { method: "DELETE" })
.then(r => r.json())
.then(data => {
if (data.error) {
alert("Fehler: " + data.error);
return;
}
loadExistingImportJobs();
})
.catch(() => alert("Loeschen fehlgeschlagen"));
}
function deleteCurrentImportJob() {
if (!currentImportJobId) return;
if (!confirm("Import-Job wirklich loeschen?")) return;
fetch(`/api/library/import/${currentImportJobId}`, { method: "DELETE" })
.then(r => r.json())
.then(data => {
if (data.error) {
alert("Fehler: " + data.error);
return;
}
resetImport();
})
.catch(() => alert("Loeschen fehlgeschlagen"));
}
function loadImportJob(jobId) { function loadImportJob(jobId) {
currentImportJobId = jobId; currentImportJobId = jobId;
document.getElementById("import-setup").style.display = "none"; document.getElementById("import-setup").style.display = "none";
@ -2282,7 +2315,11 @@ function renderImportItems(data) {
const hasUnresolved = items.some(i => const hasUnresolved = items.some(i =>
(i.status === "conflict" && !i.user_action) || i.status === "pending" (i.status === "conflict" && !i.user_action) || i.status === "pending"
); );
document.getElementById("btn-start-import").disabled = hasUnresolved; const btn = document.getElementById("btn-start-import");
btn.disabled = hasUnresolved;
btn.title = hasUnresolved
? `${pending} Dateien muessen erst zugeordnet werden`
: "Import starten";
if (!items.length) { if (!items.length) {
list.innerHTML = '<div class="loading-msg">Keine Dateien gefunden</div>'; list.innerHTML = '<div class="loading-msg">Keine Dateien gefunden</div>';

View file

@ -4,6 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}VideoKonverter{% endblock %}</title> <title>{% block title %}VideoKonverter{% endblock %}</title>
<link rel="icon" href="/static/icons/favicon.ico" type="image/x-icon">
<link rel="stylesheet" href="/static/css/style.css"> <link rel="stylesheet" href="/static/css/style.css">
<script src="https://unpkg.com/htmx.org@2.0.4"></script> <script src="https://unpkg.com/htmx.org@2.0.4"></script>
{% block head %}{% endblock %} {% block head %}{% endblock %}

View file

@ -387,6 +387,7 @@
<div class="import-actions" style="padding:0.6rem 1rem; display:flex; gap:0.5rem; align-items:center; border-bottom:1px solid #2a2a2a;"> <div class="import-actions" style="padding:0.6rem 1rem; display:flex; gap:0.5rem; align-items:center; border-bottom:1px solid #2a2a2a;">
<button class="btn-primary" id="btn-start-import" onclick="executeImport()">Import starten</button> <button class="btn-primary" id="btn-start-import" onclick="executeImport()">Import starten</button>
<button class="btn-secondary" onclick="resetImport()">Zurueck</button> <button class="btn-secondary" onclick="resetImport()">Zurueck</button>
<button class="btn-danger" onclick="deleteCurrentImportJob()" title="Job loeschen">Job loeschen</button>
<span id="import-info" class="text-muted" style="margin-left:auto"></span> <span id="import-info" class="text-muted" style="margin-left:auto"></span>
</div> </div>
<div id="import-items-list" class="import-items-list"></div> <div id="import-items-list" class="import-items-list"></div>