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:
parent
7ba24a097a
commit
8fe00beaad
5 changed files with 134 additions and 10 deletions
|
|
@ -1211,6 +1211,18 @@ def setup_library_routes(app: web.Application, config: Config,
|
|||
result = await importer_service.get_job_status(job_id)
|
||||
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:
|
||||
"""POST /api/library/import/{job_id}/execute"""
|
||||
if not importer_service:
|
||||
|
|
@ -1609,6 +1621,9 @@ def setup_library_routes(app: web.Application, config: Config,
|
|||
# Import
|
||||
app.router.add_get("/api/library/import", get_import_jobs)
|
||||
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(
|
||||
"/api/library/import/{job_id}/analyze", post_analyze_import
|
||||
)
|
||||
|
|
|
|||
|
|
@ -24,6 +24,10 @@ RE_SERIES_FROM_NAME = re.compile(
|
|||
RE_SERIES_FROM_XXx = re.compile(
|
||||
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:
|
||||
|
|
@ -172,6 +176,32 @@ class ImporterService:
|
|||
logging.error(f"Import-Job erstellen fehlgeschlagen: {e}")
|
||||
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:
|
||||
"""Analysiert alle Dateien: Erkennung + TVDB-Lookup + Konflikt-Check"""
|
||||
if not self._db_pool:
|
||||
|
|
@ -311,13 +341,25 @@ class ImporterService:
|
|||
)
|
||||
target_path = os.path.join(target_dir, target_file)
|
||||
|
||||
# 5. Konflikt-Check
|
||||
status = "matched" if series_name and season and episode else "pending"
|
||||
# 5. Status bestimmen: Nur matched wenn eindeutig identifiziert
|
||||
if not (series_name and season and episode):
|
||||
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_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_size = os.path.getsize(target_path)
|
||||
source_size = item["source_size"]
|
||||
|
|
@ -340,7 +382,7 @@ class ImporterService:
|
|||
conflict = "Datei existiert bereits"
|
||||
status = "conflict"
|
||||
|
||||
# 6. In DB aktualisieren
|
||||
# 7. In DB aktualisieren
|
||||
try:
|
||||
async with self._db_pool.acquire() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
|
|
@ -377,9 +419,13 @@ class ImporterService:
|
|||
Ordnernamen als Fallback. Der Ordnername ist oft zuverlaessiger
|
||||
bei Release-Gruppen-Prefixes (z.B. 'tlr-24.s07e01.mkv' vs
|
||||
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)
|
||||
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
|
||||
info_file = self._parse_name(filename)
|
||||
|
|
@ -399,6 +445,19 @@ class ImporterService:
|
|||
info_dir["series"] = info_file["series"]
|
||||
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
|
||||
if info_file.get("season") and info_file.get("episode"):
|
||||
# Ordner-Serienname als Fallback wenn Datei keinen hat
|
||||
|
|
@ -408,6 +467,17 @@ class ImporterService:
|
|||
|
||||
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:
|
||||
"""Extrahiert Serienname, Staffel, Episode aus einem Namen"""
|
||||
result = {"series": "", "season": None, "episode": None}
|
||||
|
|
|
|||
|
|
@ -2063,12 +2063,15 @@ function loadExistingImportJobs() {
|
|||
j.status === 'importing' ? 'Laeuft' :
|
||||
j.status === 'error' ? 'Fehler' : j.status;
|
||||
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})"
|
||||
title="${escapeHtml(j.source_path)}">
|
||||
${escapeHtml(sourceName)} (${j.processed_files}/${j.total_files})
|
||||
<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">×</button>` : ''}
|
||||
</span>`;
|
||||
}).join("");
|
||||
})
|
||||
.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) {
|
||||
currentImportJobId = jobId;
|
||||
document.getElementById("import-setup").style.display = "none";
|
||||
|
|
@ -2282,7 +2315,11 @@ function renderImportItems(data) {
|
|||
const hasUnresolved = items.some(i =>
|
||||
(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) {
|
||||
list.innerHTML = '<div class="loading-msg">Keine Dateien gefunden</div>';
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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">
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||
{% block head %}{% endblock %}
|
||||
|
|
|
|||
|
|
@ -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;">
|
||||
<button class="btn-primary" id="btn-start-import" onclick="executeImport()">Import starten</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>
|
||||
</div>
|
||||
<div id="import-items-list" class="import-items-list"></div>
|
||||
|
|
|
|||
Loading…
Reference in a new issue