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)
|
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
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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">×</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>';
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue