Installation guidée + intégration bibliothèque Plex/Sonarr
- scripts/install.sh : premier setup interactif (destination des épisodes, port, secrets auto-générés, montage, démarrage) — options --dir/--port/ --skip-login pour une install non interactive - Épisodes rangés par animé sur le disque : <Animé>/<Animé> - E12 (VOSTFR).mp4 (meilleur parsing Plex, suppression du dossier quand il se vide) - Entrypoint : chown récursif uniquement si le dossier de téléchargements est vide — une bibliothèque Plex/Sonarr existante n'est jamais modifiée - Scan disque récursif (sous-dossiers par animé restaurés) - README : installation guidée + section « Bibliothèque Plex / Sonarr »
This commit is contained in:
+46
-21
@@ -63,6 +63,19 @@ def sanitize_filename(name: str) -> str:
|
||||
return name[:150]
|
||||
|
||||
|
||||
_SERIES_RE = re.compile(r"^(.*?)[\s\-–—]*(?:épisode|episode|ep|e)\s*\d", re.IGNORECASE)
|
||||
|
||||
|
||||
def series_dirname(title: str) -> str | None:
|
||||
"""Nom du sous-dossier d'un titre (« One Piece - E12 » → « One Piece »).
|
||||
|
||||
Range les épisodes par animé sur le disque (bien meilleur parsing Plex).
|
||||
None si aucun marqueur d'épisode détecté → fichier à plat.
|
||||
"""
|
||||
match = _SERIES_RE.match(title)
|
||||
base = sanitize_filename(match.group(1).strip()) if match else ""
|
||||
return base or None
|
||||
|
||||
class DownloadManager:
|
||||
"""File d'attente de téléchargements, injectée dans les routes via app.state."""
|
||||
|
||||
@@ -117,25 +130,29 @@ class DownloadManager:
|
||||
download_dir = get_settings().download_dir
|
||||
rows = await db.fetchall("SELECT file_path FROM downloads WHERE file_path IS NOT NULL")
|
||||
known = {row["file_path"] for row in rows}
|
||||
for path in download_dir.iterdir():
|
||||
if path.is_file() and path.suffix != ".part" and path.name not in known:
|
||||
size = path.stat().st_size
|
||||
await db.execute(
|
||||
"INSERT INTO downloads "
|
||||
"(source_key, video_url, page_url, title, file_path, status, "
|
||||
" total_bytes, downloaded_bytes) "
|
||||
"VALUES (?, ?, ?, ?, ?, 'done', ?, ?)",
|
||||
(
|
||||
f"file:{path.name}",
|
||||
"",
|
||||
"",
|
||||
path.stem,
|
||||
path.name,
|
||||
size,
|
||||
size,
|
||||
),
|
||||
)
|
||||
logger.info("Fichier restauré depuis le disque : %s", path.name)
|
||||
for path in download_dir.rglob("*"):
|
||||
if not path.is_file() or path.suffix == ".part":
|
||||
continue
|
||||
rel_path = path.relative_to(download_dir).as_posix()
|
||||
if rel_path in known:
|
||||
continue
|
||||
size = path.stat().st_size
|
||||
await db.execute(
|
||||
"INSERT INTO downloads "
|
||||
"(source_key, video_url, page_url, title, file_path, status, "
|
||||
" total_bytes, downloaded_bytes) "
|
||||
"VALUES (?, ?, ?, ?, ?, 'done', ?, ?)",
|
||||
(
|
||||
f"file:{rel_path}",
|
||||
"",
|
||||
"",
|
||||
path.stem,
|
||||
rel_path,
|
||||
size,
|
||||
size,
|
||||
),
|
||||
)
|
||||
logger.info("Fichier restauré depuis le disque : %s", rel_path)
|
||||
|
||||
# ------------------------------------------------------------ API publique
|
||||
|
||||
@@ -152,10 +169,12 @@ class DownloadManager:
|
||||
return self._to_dict(existing, duplicate=True)
|
||||
|
||||
filename = sanitize_filename(title) + self._guess_extension(video_url)
|
||||
series = series_dirname(title)
|
||||
file_path = f"{series}/{filename}" if series else filename
|
||||
cursor = await db.execute(
|
||||
"INSERT INTO downloads (source_key, video_url, page_url, title, file_path) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(source_key, video_url, page_url, title, filename),
|
||||
(source_key, video_url, page_url, title, file_path),
|
||||
)
|
||||
download_id = cursor.lastrowid
|
||||
await self._queue.put(download_id)
|
||||
@@ -234,7 +253,11 @@ class DownloadManager:
|
||||
if row["status"] in ACTIVE_STATUSES:
|
||||
await self.cancel(download_id)
|
||||
if delete_file and row["file_path"]:
|
||||
(get_settings().download_dir / row["file_path"]).unlink(missing_ok=True)
|
||||
target = get_settings().download_dir / row["file_path"]
|
||||
target.unlink(missing_ok=True)
|
||||
if target.parent != get_settings().download_dir:
|
||||
with contextlib.suppress(OSError): # dossier de l'animé vide → retiré
|
||||
target.parent.rmdir()
|
||||
self._part_path(row["file_path"]).unlink(missing_ok=True)
|
||||
await db.execute("DELETE FROM downloads WHERE id = ?", (download_id,))
|
||||
await self._emit_removed(download_id)
|
||||
@@ -324,6 +347,7 @@ class DownloadManager:
|
||||
"""Téléchargement HTTP direct avec reprise via Range."""
|
||||
video_url, file_path = row["video_url"], row["file_path"]
|
||||
target = get_settings().download_dir / file_path
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
part = self._part_path(file_path)
|
||||
downloaded = part.stat().st_size if part.exists() else 0
|
||||
|
||||
@@ -377,6 +401,7 @@ class DownloadManager:
|
||||
"""
|
||||
video_url, file_path = row["video_url"], row["file_path"]
|
||||
target = get_settings().download_dir / file_path
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
part = self._part_path(file_path)
|
||||
part.unlink(missing_ok=True) # pas de reprise partielle en HLS
|
||||
|
||||
|
||||
Reference in New Issue
Block a user