feat: Implement existing item lookup and overwrite handling for Audiobookshelf uploads

This commit is contained in:
JB
2025-11-02 06:25:14 -08:00
parent af90da0b99
commit 72b2ada9ac
8 changed files with 511 additions and 58 deletions
+22 -22
View File
@@ -8,6 +8,7 @@ import subprocess
import sys
import tempfile
import traceback
from datetime import datetime
from collections import defaultdict
from contextlib import ExitStack
from dataclasses import dataclass
@@ -107,6 +108,7 @@ _ACRONYM_ALLOWLIST = {
}
_ROMAN_NUMERAL_CHARS = frozenset("IVXLCDM")
_CAPS_WORD_RE = re.compile(r"[A-Z][A-Z0-9'\u2019-]*")
_OUTPUT_SANITIZE_RE = re.compile(r"[^\w\-_.]+")
def _simplify_heading_text(text: str) -> str:
@@ -1844,7 +1846,7 @@ def run_conversion_job(job: Job) -> None:
if audio_asset:
try:
epub_root = project_root if job.save_as_project else base_output_dir
epub_root = project_root
epub_output_path = _build_output_path(epub_root, job.original_filename, "epub")
job.add_log("Generating EPUB 3 package with synchronized narration…")
epub_path = build_epub3_package(
@@ -1989,21 +1991,19 @@ def _prepare_output_dir(job: Job) -> Path:
def _build_output_path(directory: Path, original_name: str, extension: str) -> Path:
base_name = Path(original_name).stem
sanitized = re.sub(r"[^\w\-_.]+", "_", base_name).strip("_") or "output"
candidate = directory / f"{sanitized}.{extension}"
counter = 1
while candidate.exists():
candidate = directory / f"{sanitized}_{counter}.{extension}"
counter += 1
return candidate
sanitized = _sanitize_output_stem(original_name)
directory.mkdir(parents=True, exist_ok=True)
return directory / f"{sanitized}.{extension}"
def _prepare_project_layout(job: Job, base_dir: Path) -> tuple[Path, Path, Path, Optional[Path]]:
base_dir.mkdir(parents=True, exist_ok=True)
stem = Path(job.original_filename).stem
sanitized = _sanitize_output_stem(job.original_filename)
folder_name = f"{_output_timestamp_token()}_{sanitized}"
project_root = base_dir / folder_name
project_root.mkdir(parents=True, exist_ok=True)
if job.save_as_project:
project_root = _ensure_unique_directory(base_dir, f"{stem}_project")
audio_dir = project_root / "audio"
subtitle_dir = project_root / "subtitles"
metadata_dir = project_root / "metadata"
@@ -2011,17 +2011,7 @@ def _prepare_project_layout(job: Job, base_dir: Path) -> tuple[Path, Path, Path,
directory.mkdir(parents=True, exist_ok=True)
return project_root, audio_dir, subtitle_dir, metadata_dir
return base_dir, base_dir, base_dir, None
def _ensure_unique_directory(parent: Path, name: str) -> Path:
candidate = parent / name
counter = 1
while candidate.exists():
candidate = parent / f"{name}_{counter}"
counter += 1
candidate.mkdir(parents=True, exist_ok=True)
return candidate
return project_root, project_root, project_root, None
def _apply_newline_policy(chapters: List[ExtractedChapter], replace_single_newlines: bool) -> None:
@@ -2039,6 +2029,16 @@ def _slugify(title: str, index: int) -> str:
return sanitized[:80]
def _sanitize_output_stem(name: str) -> str:
base = Path(name or "").stem
sanitized = _OUTPUT_SANITIZE_RE.sub("_", base).strip("_")
return sanitized or "output"
def _output_timestamp_token() -> str:
return datetime.now().strftime("%Y%m%d-%H%M%S")
def _open_audio_sink(
path: Path,
job: Job,
+47 -1
View File
@@ -5064,10 +5064,56 @@ def send_job_to_audiobookshelf(job_id: str) -> ResponseReturnValue:
subtitles = _existing_paths(job.result.subtitle_paths) if config.send_subtitles else None
chapters = _load_audiobookshelf_chapters(job) if config.send_chapters else None
metadata = _build_audiobookshelf_metadata(job)
display_title = metadata.get("title") or audio_path.stem
overwrite_requested = request.form.get("overwrite") == "true" or request.args.get("overwrite") == "true"
try:
client = AudiobookshelfClient(config)
except ValueError as exc: # pragma: no cover - defensive guard
job.add_log(f"Audiobookshelf configuration error: {exc}", level="error")
service._persist_state()
return _panel_response()
try:
existing_items = client.find_existing_items(display_title, folder_id=config.folder_id)
except AudiobookshelfUploadError as exc:
job.add_log(f"Audiobookshelf lookup failed: {exc}", level="error")
service._persist_state()
return _panel_response()
if existing_items and not overwrite_requested:
job.add_log(
f"Audiobookshelf already contains '{display_title}'. Awaiting overwrite confirmation.",
level="warning",
)
service._persist_state()
if request.headers.get("HX-Request"):
detail = {
"jobId": job.id,
"title": display_title,
"url": url_for("web.send_job_to_audiobookshelf", job_id=job.id),
"target": request.headers.get("HX-Target") or "#jobs-panel",
"message": f'Audiobookshelf already contains "{display_title}". Overwrite?',
}
headers = {"HX-Trigger": json.dumps({"audiobookshelf-overwrite-prompt": detail})}
return Response("", status=204, headers=headers)
return _panel_response()
if existing_items and overwrite_requested:
try:
client.delete_items(existing_items)
except AudiobookshelfUploadError as exc:
job.add_log(f"Audiobookshelf overwrite aborted: {exc}", level="error")
service._persist_state()
return _panel_response()
else:
job.add_log(
f"Removed {len(existing_items)} existing Audiobookshelf item(s) prior to overwrite.",
level="info",
)
job.add_log("Audiobookshelf upload triggered manually.", level="info")
try:
client = AudiobookshelfClient(config)
client.upload_audiobook(
audio_path,
metadata=metadata,
+15
View File
@@ -999,6 +999,21 @@ class ConversionService:
metadata = _build_audiobookshelf_metadata(job)
client = AudiobookshelfClient(config)
display_title = metadata.get("title") or audio_path.stem
try:
existing_items = client.find_existing_items(display_title, folder_id=config.folder_id)
except AudiobookshelfUploadError as exc:
job.add_log(f"Audiobookshelf lookup failed: {exc}", level="error")
return
if existing_items:
job.add_log(
f"Audiobookshelf upload skipped: '{display_title}' already exists in the configured folder.",
level="warning",
)
return
client.upload_audiobook(
audio_path,
metadata=metadata,
+34
View File
@@ -1,7 +1,41 @@
import { initReaderUI } from "./reader.js";
const queueState = (window.AbogenQueueState = window.AbogenQueueState || {
boundOverwritePrompt: false,
});
const handleOverwritePrompt = (event) => {
const detail = event?.detail || {};
const title = detail.title || "this item";
const message = detail.message || `Audiobookshelf already has "${title}". Overwrite?`;
if (!window.confirm(message)) {
return;
}
const url = detail.url;
if (!url || typeof htmx === "undefined") {
return;
}
const target = detail.target || "#jobs-panel";
const values = { overwrite: "true" };
if (detail.values && typeof detail.values === "object") {
Object.assign(values, detail.values);
}
htmx.ajax("POST", url, {
target,
swap: "innerHTML",
values,
});
};
const initQueuePage = () => {
initReaderUI();
if (!queueState.boundOverwritePrompt) {
queueState.boundOverwritePrompt = true;
document.addEventListener("audiobookshelf-overwrite-prompt", handleOverwritePrompt);
}
};
if (document.readyState === "loading") {