Initial version of arte-dl

Wrapper around yt-dlp to download whole arte.tv series into
Series (year)/Season XX/Series - SxxEyy - Title.mkv:

- resolve series / season / episode URLs through the Arte API
- pick video, audio (VO, VF, AD...) and subtitle tracks from a TOML config
- download with yt-dlp, convert WebVTT to SRT (ffmpeg 4.4 yields empty
  subtitles from Arte's CRLF files), remux with track languages, titles,
  default/forced flags and episode tags
- optional TMDB matching: series name and year, numbering (including
  multi-episode files when Arte merges two episodes), localized titles
  and synopses following a language priority list

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-30 20:32:17 +02:00
co-authored by Claude Opus 5.5
commit 8145e5e985
16 changed files with 1863 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
.venv/
__pycache__/
*.egg-info/
.pytest_cache/
dist/
.arte-dl-tmp/
+133
View File
@@ -0,0 +1,133 @@
# arte-dl
Wrapper autour de [yt-dlp](https://github.com/yt-dlp/yt-dlp) pour télécharger une série
arte.tv complète (toutes ses saisons) en MKV, selon des préférences de qualité, de pistes
audio et de sous-titres, et la ranger dans une arborescence de vidéothèque (Plex / Jellyfin / Kodi) :
```
Meurtres à Sandhamn (2010)/
├── Season 01/
│ ├── Meurtres à Sandhamn - S01E01 - Enquête 1 - La reine de la Baltique.mkv
│ └── …
└── Season 06/
└── Meurtres à Sandhamn - S06E01-E02 - Le prix à payer.mkv
```
Avec une clé [TMDB](https://www.themoviedb.org/), le nom de la série, l'année, la numérotation
et les titres d'épisodes sont alignés sur TMDB (la référence de Jellyfin, et une source de Plex).
## Installation
Nécessite Python ≥ 3.10 et `ffmpeg` dans le `PATH`.
```sh
uv tool install . # ou : pipx install .
arte-dl --version
```
Pour mettre à jour yt-dlp (Arte change régulièrement son API) : `uv tool upgrade arte-dl`.
## Utilisation
```sh
# Toute la série
arte-dl https://www.arte.tv/fr/videos/RC-027900/the-hack-sur-ecoute/
# Voir l'arborescence prévue sans rien télécharger
arte-dl --list https://www.arte.tv/fr/videos/RC-022391/meurtres-a-sandhamn/
# Voir aussi les pistes retenues pour chaque épisode
arte-dl --dry-run -s 4 https://www.arte.tv/fr/videos/RC-022391/meurtres-a-sandhamn/
# Certaines saisons / certains épisodes, dans un autre dossier
arte-dl -s 1,3-5 -e 1-2 -o ~/Vidéos/Séries https://www.arte.tv/fr/videos/RC-022391/meurtres-a-sandhamn/
# Corriger l'identification TMDB (mémorisée pour les fois suivantes)
arte-dl --list --tmdb-id 55270 https://www.arte.tv/fr/videos/RC-022391/meurtres-a-sandhamn/
```
`-s` / `-e` portent sur la numérotation finale (celle de TMDB quand elle est utilisée),
celle qu'affiche `--list`.
Types d'URL acceptés :
| URL | Téléchargé |
|---------------------------------------|-------------------------------------|
| série `…/videos/RC-xxxxxx/…` | toutes les saisons disponibles |
| saison `…/videos/RC-xxxxxx/…` | cette saison |
| épisode `…/videos/059534-001-A/…` | cet épisode, bien numéroté/rangé |
Les fichiers déjà présents sont ignorés (`--force` pour les retélécharger) : relancer la
commande reprend simplement là où elle s'était arrêtée, ou récupère les nouveaux épisodes.
## Configuration
Fichier TOML : `~/.config/arte-dl/config.toml` par défaut, ou `-c fichier.toml`.
Voir [`config.example.toml`](config.example.toml) pour toutes les options. Exemple :
```toml
[output]
directory = "~/Vidéos/Séries"
[video]
max_height = 1080
codecs = ["hevc", "avc"]
[audio]
tracks = ["original", "fr"] # VO par défaut + VF
[subtitles]
tracks = ["fr", "fr-forced"]
default = "auto"
[metadata]
api_key = "…" # ou variable d'environnement TMDB_API_KEY
languages = ["fr-FR", "arte", "en-US"] # priorité pour les noms, titres et résumés
```
## Métadonnées TMDB
Clé gratuite : créer un compte sur themoviedb.org, puis *Paramètres → API*. La clé API (v3)
comme le jeton d'accès en lecture (v4) conviennent.
- **Identification de la série** : recherche par titre original et titre Arte, départagée
par l'année de production et la langue originale fournies par Arte. En cas de doute
(score faible ou deux candidats proches), rien n'est deviné : les données Arte sont gardées
et `--tmdb-id` permet de trancher. L'association est mémorisée dans
`~/.local/share/arte-dl/tmdb-ids.json`.
- **Numérotation** : saison par saison, les épisodes Arte sont associés aux épisodes TMDB :
- même nombre d'épisodes : correspondance directe ;
- Arte fusionne des épisodes (ex. Meurtres à Sandhamn saison 6 : 4 × 88 min sur Arte,
8 × 45 min sur TMDB) : le fichier est nommé `S06E01-E02`, format multi-épisode reconnu
par Plex et Jellyfin. Les durées sont vérifiées ;
- sinon, alignement par durée si toute la saison est disponible ;
- à défaut, ou si la saison n'existe pas sur TMDB, la numérotation Arte est conservée
(et signalée).
- **Titres et résumés** : pris dans l'ordre de `languages`. Une langue qui n'a pas le titre
(ou seulement « Épisode 3 ») passe à la suivante. Pour deux épisodes fusionnés,
« X (part 1) » + « X (part 2) » donnent « X ».
## Fonctionnement
1. **Structure** : la série, ses saisons et épisodes sont lus via l'API Arte (celle
qu'utilise yt-dlp). Le numéro de saison vient du titre (« Saison 4 »), le numéro d'épisode
du « (1/3) », le titre de l'épisode du sous-titre Arte (à défaut « Épisode N »), puis
tout cela est corrigé par TMDB si une clé est configurée.
2. **Sélection** : pour chaque épisode, yt-dlp extrait les formats ; arte-dl choisit la vidéo
et les pistes audio d'après la configuration. Les identifiants de format Arte
(`VF-STF-audio_0-suédois__VO_`…) varient d'un épisode à l'autre, d'où la sélection
par langue et par type plutôt que par identifiant.
3. **Téléchargement** : yt-dlp télécharge et fusionne vidéo + audios, et récupère les
sous-titres WebVTT.
4. **Remux final** (ffmpeg) : sous-titres convertis en SRT, langue et titre de chaque piste,
pistes par défaut / forcées / SDH, tags série / saison / épisode. Le fichier est écrit en
`.part.mkv` puis renommé, donc un fichier `.mkv` présent est toujours complet.
La conversion VTT → SRT est faite en Python : les VTT d'Arte (fins de ligne CRLF) donnent
des sous-titres **vides** avec ffmpeg 4.4 (celui d'Ubuntu 22.04), y compris via `yt-dlp --convert-subs srt`.
## Tests
```sh
uv venv && uv pip install -e '.[dev]' && .venv/bin/pytest
```
+3
View File
@@ -0,0 +1,3 @@
"""arte-dl: download whole arte.tv series with yt-dlp."""
__version__ = '0.1.0'
+3
View File
@@ -0,0 +1,3 @@
from .cli import main
raise SystemExit(main())
+242
View File
@@ -0,0 +1,242 @@
"""Resolve arte.tv URLs into a Series -> Season -> Episode structure.
yt-dlp can flatten an RC-xxxxxx collection, but it loses the season structure
and episode numbering, so we query the same Arte APIs it uses ourselves.
"""
from __future__ import annotations
import json
import re
import time
import urllib.error
import urllib.request
from dataclasses import dataclass, field
API_PLAYER = 'https://api.arte.tv/api/player/v2'
API_OPA = 'https://api.arte.tv/api/opa/v3'
try: # keep the public token in sync with yt-dlp when it rotates
from yt_dlp.extractor.arte import ArteTVPlaylistIE
_OPA_TOKEN = ArteTVPlaylistIE._API_TOKEN
except (ImportError, AttributeError):
_OPA_TOKEN = 'Nzc1Yjc1ZjJkYjk1NWFhN2I2MWEwMmRlMzAzNjI5NmU3NWU3ODg4ODJjOWMxNTMxYzEzZGRjYjg2ZGE4MmIwOA'
URL_RE = re.compile(
r'arte\.tv/(?P<lang>fr|de|en|es|it|pl)/videos/(?P<id>RC-\d{6}|\d{6}-\d{3}-[AF])')
EPISODE_ID_RE = re.compile(r'^\d{6}-\d{3}-[AF]$')
COLLECTION_ID_RE = re.compile(r'RC-\d{6}')
SEASON_RE = re.compile(r'\b(?:saison|staffel|season|temporada|stagione|sezon)\s*(\d+)', re.I)
EPISODE_NUMBER_RE = re.compile(r'\((\d+)\s*/\s*(\d+)\)\s*$')
class ArteError(Exception):
pass
@dataclass
class Episode:
id: str
url: str
series: str
season: int
number: int
title: str
description: str | None = None
duration: int | None = None # seconds
total: int | None = None # episodes in the season, from Arte's "(n/m)"
last_number: int | None = None # set when the file holds several episodes (S06E01-E02)
def __post_init__(self):
# Arte's own numbering, kept when metadata matching renumbers the episode
self.arte_season, self.arte_number, self.arte_title = self.season, self.number, self.title
@property
def numbers(self) -> list[int]:
return list(range(self.number, (self.last_number or self.number) + 1))
@property
def label(self) -> str:
return f'S{self.season:02d}' + '-'.join(f'E{n:02d}' for n in (
[self.number, self.last_number] if self.last_number else [self.number]))
@dataclass
class Season:
id: str
number: int
title: str
episodes: list[Episode] = field(default_factory=list)
@dataclass
class Series:
id: str
title: str
lang: str
seasons: list[Season] = field(default_factory=list)
unavailable: list[str] = field(default_factory=list) # season ids with nothing online
original_title: str | None = None
original_language: str | None = None # ISO 639-1
year: int | None = None
tmdb_id: int | None = None
tvdb_id: int | None = None
@property
def episodes(self) -> list[Episode]:
return [e for s in self.seasons for e in s.episodes]
def parse_url(url: str) -> tuple[str, str]:
m = URL_RE.search(url)
if not m:
raise ArteError(f'Not an arte.tv series or episode URL: {url}')
return m['lang'], m['id']
class ArteClient:
def __init__(self, lang: str, retries: int = 6):
self.lang = lang
self.retries = retries
def _get(self, url: str, headers: dict[str, str]) -> dict | None:
"""GET a JSON document; None on 404. Retries on rate limiting / server errors."""
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0', **headers})
for attempt in range(self.retries + 1):
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return json.load(resp)
except urllib.error.HTTPError as e:
if e.code == 404:
return None
if (e.code == 429 or e.code >= 500) and attempt < self.retries:
delay = int(e.headers.get('Retry-After') or 0) or 2 ** attempt
time.sleep(min(delay, 60))
continue
raise ArteError(f'HTTP {e.code} for {url}') from e
except urllib.error.URLError as e:
if attempt < self.retries:
time.sleep(2 ** attempt)
continue
raise ArteError(f'{e.reason} for {url}') from e
raise AssertionError('unreachable')
def program(self, program_id: str) -> dict:
data = self._get(f'{API_OPA}/programs/{self.lang}/{program_id}',
{'Authorization': f'Bearer {_OPA_TOKEN}'})
programs = (data or {}).get('programs') or []
if not programs:
raise ArteError(f'Unknown Arte program {program_id}')
return programs[0]
def playlist(self, collection_id: str) -> dict | None:
data = self._get(f'{API_PLAYER}/playlist/{self.lang}/{collection_id}',
{'x-validated-age': '18'})
return ((data or {}).get('data') or {}).get('attributes')
# --- resolution -------------------------------------------------------
def resolve(self, url: str) -> Series:
"""Series URL -> every season; season URL -> that season; episode URL -> that episode."""
_, pid = parse_url(url)
if EPISODE_ID_RE.match(pid):
return self._resolve_episode(pid)
prog = self.program(pid)
if prog.get('catalogType') == 'SEASON':
series_id = next((p for p in prog.get('parents') or []
if COLLECTION_ID_RE.fullmatch(p) and p != pid), None)
if series_id:
return self._series(series_id, only_season=pid)
return self._series(pid) # orphan season: treat as its own series
return self._series(pid, prog=prog)
def _resolve_episode(self, episode_id: str) -> Series:
prog = self.program(episode_id)
collections = prog.get('collections') or []
season = next((c for c in collections if c.get('catalogType') == 'SEASON'), None)
coll = season or (collections[0] if collections else None)
series_id = None
if coll:
m = COLLECTION_ID_RE.search(coll.get('url') or '')
series_id = m[0] if m else coll.get('collectionId')
if series_id:
series = self._series(series_id, only_season=season and season.get('collectionId'),
only_episode=episode_id)
if any(s.episodes for s in series.seasons):
return series
# Standalone program, or not found in its collection: single episode, season 1.
title = prog.get('title') or episode_id
ep = Episode(id=episode_id, url=f'https://www.arte.tv/{self.lang}/videos/{episode_id}/',
series=title, season=1, number=1, title=prog.get('subtitle') or title,
description=prog.get('shortDescription'))
return Series(id=episode_id, title=title, lang=self.lang,
seasons=[Season(id=episode_id, number=1, title=title, episodes=[ep])],
original_title=(prog.get('originalTitle') or '').strip(' ()') or None,
original_language=(prog.get('originalLanguage') or {}).get('iso6391Code'),
year=prog.get('productionYear'))
def _series(self, series_id: str, *, prog: dict | None = None,
only_season: str | None = None, only_episode: str | None = None) -> Series:
prog = prog or self.program(series_id)
series = Series(id=series_id, title=(prog.get('title') or series_id).strip(), lang=self.lang,
original_title=(prog.get('originalTitle') or '').strip(' ()') or None,
original_language=(prog.get('originalLanguage') or {}).get('iso6391Code'),
year=prog.get('productionYear'))
refs = [c for c in prog.get('children') or [] if c.get('catalogType') == 'SEASON']
refs.sort(key=lambda c: c.get('order') or 0)
if not refs: # mini-series: the collection itself holds the episodes
refs = [{'programId': series_id}]
for idx, ref in enumerate(refs, 1):
sid = ref['programId']
if only_season and sid != only_season:
continue
attrs = self.playlist(sid)
items = (attrs or {}).get('items') or []
if not items and sid == series_id:
items = self._fallback_items(prog)
if not items:
series.unavailable.append(sid)
continue
stitle = ((attrs or {}).get('metadata') or {}).get('title') or series.title
m = SEASON_RE.search(stitle)
number = int(m[1]) if m else (ref.get('order') or idx)
season = Season(id=sid, number=number, title=stitle)
season.episodes = self._episodes(items, series.title, number, only_episode)
if season.episodes:
series.seasons.append(season)
series.seasons.sort(key=lambda s: s.number)
return series
def _fallback_items(self, prog: dict) -> list[dict]:
"""Build playlist-like items from the OPA 'videos' list."""
return [{'providerId': v.get('programId'), 'title': v.get('title'),
'subtitle': v.get('subtitle'), 'link': {'url': v.get('url')},
'description': v.get('shortDescription')}
for v in prog.get('videos') or [] if v.get('kind') == 'SHOW']
def _episodes(self, items: list[dict], series_title: str, season: int,
only_episode: str | None) -> list[Episode]:
episodes = []
position = 0
for it in items:
pid = it.get('providerId') or ''
if not EPISODE_ID_RE.match(pid):
continue
position += 1
raw_title = (it.get('title') or '').strip()
m = EPISODE_NUMBER_RE.search(raw_title)
number = int(m[1]) if m else position
total = int(m[2]) if m else None
title = (it.get('subtitle') or '').strip() or (f'Épisode {number}' if m else raw_title)
if only_episode and pid != only_episode:
continue
episodes.append(Episode(
id=pid,
url=((it.get('link') or {}).get('url')
or f'https://www.arte.tv/{self.lang}/videos/{pid}/'),
series=series_title, season=season, number=number, title=title,
description=it.get('description'),
duration=(it.get('duration') or {}).get('seconds'), total=total))
return episodes
+154
View File
@@ -0,0 +1,154 @@
"""Command-line entry point."""
from __future__ import annotations
import argparse
import shutil
import subprocess
import sys
from pathlib import Path
from . import __version__
from .arte_api import ArteClient, ArteError, Series, parse_url
from .config import ConfigError, default_config_path, load_config
from .download import DownloadError, destination, download, plan
from .metadata import TMDBError
from .metadata import apply as apply_metadata
from .selection import SelectionError
def parse_ranges(spec: str) -> set[int]:
""""1,3-5" -> {1, 3, 4, 5}"""
numbers = set()
for part in spec.split(','):
part = part.strip()
if not part:
continue
lo, sep, hi = part.partition('-')
try:
numbers.update(range(int(lo), int(hi) + 1) if sep else {int(lo)})
except ValueError:
raise argparse.ArgumentTypeError(f'invalid range "{part}"') from None
return numbers
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog='arte-dl',
description='Download arte.tv series (every season) into Series/Season XX/*.mkv')
p.add_argument('urls', nargs='+', metavar='URL',
help='arte.tv series (RC-xxxxxx), season or episode URL')
p.add_argument('-c', '--config', type=Path,
help=f'TOML config file (default: {default_config_path()})')
p.add_argument('-o', '--output', help='output root directory (overrides [output] directory)')
p.add_argument('-s', '--seasons', type=parse_ranges, help='only these seasons, e.g. "1,3-5"')
p.add_argument('-e', '--episodes', type=parse_ranges, help='only these episode numbers')
p.add_argument('-l', '--list', action='store_true',
help='list seasons / episodes and destination paths, then exit')
p.add_argument('-n', '--dry-run', action='store_true',
help='also show the selected tracks for each episode, without downloading')
p.add_argument('-f', '--force', action='store_true', help='re-download existing files')
p.add_argument('--tmdb-id', type=int, metavar='ID',
help='TMDB show id to use instead of searching, with a single URL (remembered for next runs)')
p.add_argument('--no-metadata', action='store_true', help="don't query TMDB, use Arte data only")
p.add_argument('-V', '--version', action='version', version=f'%(prog)s {__version__}')
return p
def _filter(series: Series, args) -> None:
for season in series.seasons:
season.episodes = [e for e in season.episodes
if not args.episodes or args.episodes & set(e.numbers)]
series.seasons = [s for s in series.seasons
if s.episodes and (not args.seasons or s.number in args.seasons)]
def process(url: str, cfg, args) -> tuple[int, int]:
"""Returns (ok, failed) episode counts."""
lang, _ = parse_url(url)
series = ArteClient(lang).resolve(url)
total = len(series.episodes)
print(f'== {series.title} ({series.id}) — {len(series.seasons)} season(s), {total} episode(s)')
if series.unavailable:
print(f' not available online: {", ".join(series.unavailable)}')
meta = cfg.metadata
if meta.provider == 'tmdb' and not args.no_metadata:
if meta.key:
try:
apply_metadata(series, meta, forced_id=args.tmdb_id)
except TMDBError as e:
print(f' TMDB: {e} — keeping Arte metadata', file=sys.stderr)
else:
print(' TMDB: no API key ([metadata] api_key or TMDB_API_KEY) — using Arte metadata')
_filter(series, args)
ok = failed = 0
for season in series.seasons:
print(f'-- Season {season.number}: {season.title}')
for ep in season.episodes:
dest = destination(series, ep, cfg)
exists = dest.exists()
arte_label = f'S{ep.arte_season:02d}E{ep.arte_number:02d}'
renumbered = f' (Arte {arte_label})' if arte_label != ep.label else ''
print(f' {ep.label} [{ep.id}] {ep.title}{renumbered}')
print(f' -> {dest}{" (exists)" if exists else ""}')
if args.list or (exists and not args.force):
ok += 1
continue
try:
info, sel = plan(ep, cfg)
h = sel.video
print(f' video : {h["format_id"]} {h.get("height")}p {h.get("vcodec")}')
print(f' audio : {", ".join(a.title for a in sel.audio) or "(muxed)"}')
print(' subs : ' + (', '.join(
s.title + (' *' if i == sel.default_subtitle else '')
for i, s in enumerate(sel.subtitles)) or '-'))
for w in sel.warnings:
print(f' warning: {w}')
if not args.dry_run:
download(series, ep, info, sel, dest, cfg)
print(' done')
ok += 1
except KeyboardInterrupt:
raise
except (DownloadError, SelectionError, subprocess.CalledProcessError) as e:
failed += 1
print(f' FAILED: {e}', file=sys.stderr)
except Exception as e: # keep going with the next episode
failed += 1
print(f' FAILED: {type(e).__name__}: {e}', file=sys.stderr)
return ok, failed
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
if args.tmdb_id and len(args.urls) > 1:
parser.error('--tmdb-id only makes sense with a single URL')
sys.stdout.reconfigure(line_buffering=True) # keep our lines in order with yt-dlp's stderr
try:
cfg = load_config(args.config)
except ConfigError as e:
print(f'config error: {e}', file=sys.stderr)
return 2
if args.output:
cfg.output.directory = args.output
if not (args.list or args.dry_run) and not shutil.which('ffmpeg'):
print('ffmpeg not found in PATH', file=sys.stderr)
return 2
ok = failed = 0
for url in args.urls:
try:
o, f = process(url, cfg, args)
except ArteError as e:
print(f'error: {e}', file=sys.stderr)
failed += 1
continue
except KeyboardInterrupt:
print('\ninterrupted', file=sys.stderr)
return 130
ok, failed = ok + o, failed + f
if failed:
print(f'\n{failed} failure(s), {ok} ok', file=sys.stderr)
return 1 if failed else 0
+131
View File
@@ -0,0 +1,131 @@
"""Configuration: TOML file merged over built-in defaults."""
from __future__ import annotations
import os
import re
import sys
from dataclasses import dataclass, field, fields
from pathlib import Path
if sys.version_info >= (3, 11):
import tomllib
else:
import tomli as tomllib
class ConfigError(Exception):
pass
@dataclass
class OutputConfig:
directory: str = '.'
series_dir: str = '{series} ({year})'
season_dir: str = 'Season {season:02d}'
filename: str = '{series} - S{season:02d}E{episode:02d} - {title}'
@dataclass
class VideoConfig:
max_height: int = 1080
# Preference order when several codecs exist at the same height: "hevc" (H.265), "avc" (H.264)
codecs: list[str] = field(default_factory=lambda: ['hevc', 'avc'])
@dataclass
class AudioConfig:
# Tracks to include, in order; the first one found becomes the default track.
# "original" -> original version (VO), whatever its language
# "fr", "de" -> that language (excluding audio description / "confort audio")
# "fr-ad" -> audio description, "fr-comfort" -> "confort audio" (clearer dialogue)
tracks: list[str] = field(default_factory=lambda: ['original', 'fr'])
@dataclass
class SubtitlesConfig:
# "fr" -> full subtitles, "fr-forced" -> forced (foreign lines only), "fr-acc" -> SDH
tracks: list[str] = field(default_factory=lambda: ['fr', 'fr-forced'])
# Default subtitle track: a key from `tracks`, "none", or "auto"
# ("auto": forced subs when the default audio is in the subtitle language, full subs otherwise)
default: str = 'auto'
@dataclass
class MetadataConfig:
# "tmdb": series name / year / numbering / titles from themoviedb.org; "none": Arte data only
provider: str = 'tmdb'
# TMDB API key (v3) or read access token (v4); the TMDB_API_KEY environment variable also works
api_key: str = ''
# Priority order for series name, episode titles and synopses. TMDB languages ("fr-FR",
# "en-US", "fr"...), "arte" (Arte's own titles) and "original" (the show's original language)
languages: list[str] = field(default_factory=lambda: ['fr-FR', 'arte', 'en-US'])
@property
def key(self) -> str:
return self.api_key or os.environ.get('TMDB_API_KEY', '')
@dataclass
class Config:
output: OutputConfig = field(default_factory=OutputConfig)
video: VideoConfig = field(default_factory=VideoConfig)
audio: AudioConfig = field(default_factory=AudioConfig)
subtitles: SubtitlesConfig = field(default_factory=SubtitlesConfig)
metadata: MetadataConfig = field(default_factory=MetadataConfig)
@property
def output_dir(self) -> Path:
return Path(os.path.expandvars(self.output.directory)).expanduser()
def default_config_path() -> Path:
base = os.environ.get('XDG_CONFIG_HOME') or Path.home() / '.config'
return Path(base) / 'arte-dl' / 'config.toml'
def _apply(section_obj, data: dict, section: str) -> None:
known = {f.name: f for f in fields(section_obj)}
for key, value in data.items():
if key not in known:
raise ConfigError(f'Unknown option [{section}] {key}')
default = getattr(section_obj, key)
if isinstance(default, list) and not (
isinstance(value, list) and all(isinstance(v, str) for v in value)):
raise ConfigError(f'[{section}] {key} must be a list of strings')
if isinstance(default, (str, int)) and type(value) is not type(default):
raise ConfigError(f'[{section}] {key} must be a {type(default).__name__}')
setattr(section_obj, key, value)
def load_config(path: Path | None) -> Config:
"""Load `path`, or the default location if it exists, or built-in defaults."""
cfg = Config()
if path is None:
path = default_config_path()
if not path.exists():
return cfg
try:
with open(path, 'rb') as f:
data = tomllib.load(f)
except FileNotFoundError:
raise ConfigError(f'Config file not found: {path}') from None
except tomllib.TOMLDecodeError as e:
raise ConfigError(f'{path}: {e}') from None
for section, values in data.items():
if not hasattr(cfg, section) or not isinstance(values, dict):
raise ConfigError(f'Unknown section [{section}]')
_apply(getattr(cfg, section), values, section)
_validate(cfg)
return cfg
LANGUAGE_RE = re.compile(r'^(?:[a-z]{2}(?:-[A-Z]{2})?|arte|original)$')
def _validate(cfg: Config) -> None:
if cfg.metadata.provider not in ('tmdb', 'none'):
raise ConfigError('[metadata] provider must be "tmdb" or "none"')
bad = [lang for lang in cfg.metadata.languages if not LANGUAGE_RE.match(lang)]
if bad:
raise ConfigError(f'[metadata] languages: invalid {bad} (expected "fr-FR", "fr", "arte", "original")')
+177
View File
@@ -0,0 +1,177 @@
"""Download one episode with yt-dlp, then remux it into the final MKV with ffmpeg.
yt-dlp downloads and merges the selected video + audio formats and the VTT
subtitles into a temporary directory; a final ffmpeg pass adds the subtitles,
track languages / titles / default & forced flags and episode tags.
"""
from __future__ import annotations
import re
import shutil
import subprocess
from pathlib import Path
from yt_dlp import YoutubeDL
from yt_dlp.utils import ISO639Utils
from .arte_api import Episode, Series
from .config import Config
from .selection import Selection, select
from .subtitles import vtt_to_srt
_FORBIDDEN = re.compile(r'[\x00-\x1f"*<>?|\\]')
class DownloadError(Exception):
pass
def sanitize(name: str) -> str:
name = name.replace('/', '-').replace(':', ' -')
name = _FORBIDDEN.sub('', name)
return re.sub(r'\s+', ' ', name).strip().rstrip('.')
class EpisodeNumbers:
"""{episode:02d} -> "01", or "01-E02" for a file holding several episodes."""
def __init__(self, numbers: list[int]):
self.numbers = numbers
def __format__(self, spec: str) -> str:
first, last = self.numbers[0], self.numbers[-1]
return format(first, spec) + (f'-E{format(last, spec)}' if last != first else '')
# Groups left empty by a missing field: "Series ()", "[tmdbid-]", "{tmdb-}"
_EMPTY_GROUP_RE = re.compile(r'\s*[(\[{]\s*(?:[a-z]+-)?\s*[)\]}]')
def destination(series: Series, ep: Episode, cfg: Config) -> Path:
fields = {'series': sanitize(ep.series), 'year': series.year or '',
'tmdb_id': series.tmdb_id or '', 'tvdb_id': series.tvdb_id or '',
'season': ep.season, 'episode': EpisodeNumbers(ep.numbers),
'title': sanitize(ep.title), 'id': ep.id}
try:
parts = [_EMPTY_GROUP_RE.sub('', tpl.format(**fields)).strip() for tpl in (
cfg.output.series_dir, cfg.output.season_dir, cfg.output.filename)]
except (KeyError, ValueError, AttributeError) as e:
raise DownloadError(f'Invalid output template: {e!r}') from None
series_dir, season_dir, filename = parts
return cfg.output_dir / series_dir / season_dir / f'{filename}.mkv'
def _ydl_params(**extra) -> dict:
return {
'quiet': True,
'no_warnings': False,
'noprogress': False,
'retries': 10,
'fragment_retries': 10,
'extractor_retries': 5,
**extra,
}
def extract(ep: Episode) -> dict:
"""Extract and process formats (yt-dlp rewrites format ids while processing:
"suédois (VO)" -> "suédois__VO_"), without downloading."""
with YoutubeDL(_ydl_params()) as ydl:
info = ydl.extract_info(ep.url, download=False)
if not info or not info.get('formats'):
raise DownloadError('No formats found (not available in this country / expired?)')
# Same cleanup as --load-info-json, so the info dict can be processed again
return YoutubeDL.sanitize_info(info, remove_private_keys=True)
def plan(ep: Episode, cfg: Config) -> tuple[dict, Selection]:
info = extract(ep)
return info, select(info, cfg)
def _lang3(lang: str | None) -> str:
return (lang and ISO639Utils.short2long(lang)) or 'und'
def download(series: Series, ep: Episode, info: dict, sel: Selection, dest: Path, cfg: Config) -> None:
work = cfg.output_dir / '.arte-dl-tmp' / ep.id
shutil.rmtree(work, ignore_errors=True)
work.mkdir(parents=True)
params = _ydl_params(
format=sel.format_spec,
allow_multiple_audio_streams=len(sel.audio) > 1,
merge_output_format='mkv',
outtmpl={'default': str(work / 'media.%(ext)s'),
'subtitle': str(work / 'sub.%(ext)s')},
writesubtitles=bool(sel.subtitles),
subtitleslangs=[re.escape(s.key) for s in sel.subtitles],
subtitlesformat='vtt',
)
with YoutubeDL(params) as ydl:
ydl.process_ie_result(info, download=True)
media = next((p for p in work.glob('media.*')
if p.suffix not in ('.part', '.ytdl') and '.f' not in p.stem), None)
if media is None:
raise DownloadError(f'yt-dlp produced no media file in {work}')
sub_files = []
for s in sel.subtitles:
vtt = work / f'sub.{s.key}.vtt'
if not vtt.exists():
raise DownloadError(f'subtitle file missing: {vtt.name}')
srt_text = vtt_to_srt(vtt.read_text(encoding='utf-8-sig', errors='replace'))
if not srt_text:
raise DownloadError(f'subtitle file {vtt.name} has no cues')
srt = vtt.with_suffix('.srt')
srt.write_text(srt_text, encoding='utf-8')
sub_files.append(srt)
dest.parent.mkdir(parents=True, exist_ok=True)
part = dest.with_name(dest.stem + '.part.mkv')
subprocess.run(_mux_command(media, sub_files, series, ep, sel, part), check=True)
part.replace(dest)
shutil.rmtree(work, ignore_errors=True)
try:
work.parent.rmdir() # .arte-dl-tmp, if no other download is in progress
except OSError:
pass
def _mux_command(media: Path, subs: list[Path], series: Series, ep: Episode, sel: Selection,
out: Path) -> list[str]:
cmd = ['ffmpeg', '-hide_banner', '-loglevel', 'error', '-nostdin', '-y', '-i', str(media)]
for path in subs:
cmd += ['-i', str(path)]
cmd += ['-map', '0:v:0', '-map', '0:a?']
for i in range(len(subs)):
cmd += ['-map', f'{i + 1}:0']
cmd += ['-c', 'copy', '-c:s', 'srt', '-map_metadata', '-1']
tags = {
'title': ep.title,
'show': ep.series,
'season_number': str(ep.season),
'episode_sort': str(ep.number),
'episode_id': ep.label,
'description': ep.description or '',
'comment': ep.url,
'tmdb': f'tv/{series.tmdb_id}' if series.tmdb_id else '',
'date': str(series.year or ''),
}
for k, v in tags.items():
if v:
cmd += ['-metadata', f'{k}={v}']
for i, a in enumerate(sel.audio):
cmd += [f'-metadata:s:a:{i}', f'language={_lang3(a.lang)}',
f'-metadata:s:a:{i}', f'title={a.title}',
f'-disposition:a:{i}', 'default' if i == 0 else '0']
for i, s in enumerate(sel.subtitles):
flags = [f for f, on in (('default', i == sel.default_subtitle),
('forced', s.kind == 'forced'),
('hearing_impaired', s.kind == 'sdh')) if on]
cmd += [f'-metadata:s:s:{i}', f'language={_lang3(s.lang)}',
f'-metadata:s:s:{i}', f'title={s.title}',
f'-disposition:s:{i}', '+'.join(flags) or '0']
return cmd + [str(out)]
+330
View File
@@ -0,0 +1,330 @@
"""TMDB matching: reliable series name / year, episode numbering and titles.
Arte's numbering doesn't always follow the reference one: e.g. from season 6,
"Meurtres à Sandhamn" episodes are 88 min on Arte but two 45 min episodes on
TMDB, so Arte's S06E01 becomes S06E01-E02 (multi-episode file, understood by
Plex and Jellyfin).
"""
from __future__ import annotations
import json
import os
import re
import time
import unicodedata
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from difflib import SequenceMatcher
from functools import lru_cache
from pathlib import Path
from .arte_api import Episode, Series
from .config import MetadataConfig
API = 'https://api.themoviedb.org/3'
GENERIC_TITLE_RE = re.compile(
r'^\s*(?:episode|épisode|episodio|folge|odcinek|avsnitt|afsnit|jakso|aflevering|épisodio)'
r'\s*\d+\s*$', re.I)
_PART_WORD = r'(?:part(?:ie)?|teil|del|deel|parte|osa|część)'
# "X part 1", "X (part1)", "X - Partie 2", "X (2)"
PART_SUFFIX_RE = re.compile(
rf'\s*[-,:]?\s*(?:[(\[]\s*(?:{_PART_WORD}\s*)?\d+\s*[)\]]|{_PART_WORD}\s*\d+)\s*$', re.I)
# Arte duration vs TMDB runtime(s): accepted ratio range
DURATION_TOLERANCE = (0.75, 1.3)
class TMDBError(Exception):
pass
class TMDBClient:
def __init__(self, key: str, retries: int = 5):
self.key = key
self.retries = retries
def _get(self, path: str, **params) -> dict | None:
headers = {'Accept': 'application/json', 'User-Agent': 'arte-dl'}
if self.key.startswith('eyJ'): # v4 read access token (JWT)
headers['Authorization'] = f'Bearer {self.key}'
else:
params['api_key'] = self.key
url = f'{API}{path}?{urllib.parse.urlencode(params)}'
req = urllib.request.Request(url, headers=headers)
for attempt in range(self.retries + 1):
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return json.load(resp)
except urllib.error.HTTPError as e:
if e.code == 404:
return None
if e.code == 401:
raise TMDBError('TMDB rejected the API key (HTTP 401)') from e
if (e.code == 429 or e.code >= 500) and attempt < self.retries:
time.sleep(min(int(e.headers.get('Retry-After') or 0) or 2 ** attempt, 30))
continue
raise TMDBError(f'HTTP {e.code} for {path}') from e
except urllib.error.URLError as e:
if attempt < self.retries:
time.sleep(2 ** attempt)
continue
raise TMDBError(f'{e.reason} for {path}') from e
raise AssertionError('unreachable')
@lru_cache(maxsize=None)
def search(self, query: str, language: str) -> tuple[dict, ...]:
data = self._get('/search/tv', query=query, language=language, include_adult='false')
return tuple((data or {}).get('results') or ())
@lru_cache(maxsize=None)
def show(self, show_id: int) -> dict:
data = self._get(f'/tv/{show_id}', append_to_response='external_ids,translations')
if not data:
raise TMDBError(f'Unknown TMDB show {show_id}')
return data
@lru_cache(maxsize=None)
def season(self, show_id: int, number: int, language: str) -> dict | None:
return self._get(f'/tv/{show_id}/season/{number}', language=language)
# --- persisted Arte collection -> TMDB id mapping -----------------------------
def _ids_path() -> Path:
base = os.environ.get('XDG_DATA_HOME') or Path.home() / '.local' / 'share'
return Path(base) / 'arte-dl' / 'tmdb-ids.json'
def load_ids() -> dict[str, int]:
try:
return json.loads(_ids_path().read_text())
except (FileNotFoundError, ValueError):
return {}
def save_id(arte_id: str, tmdb_id: int) -> None:
ids = load_ids()
if ids.get(arte_id) == tmdb_id:
return
ids[arte_id] = tmdb_id
path = _ids_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(ids, indent=2, sort_keys=True) + '\n')
# --- series matching ------------------------------------------------------------
def normalize(text: str) -> str:
text = unicodedata.normalize('NFKD', text or '')
text = ''.join(c for c in text if not unicodedata.combining(c)).lower()
return ' '.join(re.sub(r'[^\w]+', ' ', text).split())
def _similarity(a: str, b: str) -> float:
return SequenceMatcher(None, normalize(a), normalize(b)).ratio()
def score_candidate(series: Series, queries: list[str], result: dict) -> tuple[float, float]:
"""Returns (score, name similarity)."""
sim = max((_similarity(q, name) for q in queries
for name in (result.get('name'), result.get('original_name')) if name), default=0)
score = sim
year = (result.get('first_air_date') or '')[:4]
if series.year and year.isdigit():
diff = abs(int(year) - series.year)
score += 0.2 if diff == 0 else 0.1 if diff == 1 else -0.5 if diff > 2 else 0
if series.original_language and result.get('original_language') == series.original_language:
score += 0.1
return score, sim
def find_show(client: TMDBClient, series: Series, language: str) -> tuple[int | None, str]:
"""Search TMDB for the series. Returns (id or None, explanation)."""
queries = list(dict.fromkeys(q for q in (series.original_title, series.title) if q))
# "The Hack : sur écoute" -> also try "The Hack"
queries += [q.split(' : ')[0] for q in queries if ' : ' in q and q.split(' : ')[0] not in queries]
candidates = {}
for q in queries:
for r in client.search(q, language)[:10]:
candidates[r['id']] = r
scored = sorted(((score_candidate(series, queries, r), r) for r in candidates.values()),
key=lambda x: x[0][0], reverse=True)
if not scored:
return None, f'no TMDB result for {queries}'
def describe(r):
return f'{r.get("name")} ({(r.get("first_air_date") or "?")[:4]}) id={r["id"]}'
(score, sim), best = scored[0]
if sim < 0.8 or score < 0.95:
return None, f'no confident match (best: {describe(best)}, score {score:.2f})'
if len(scored) > 1 and scored[1][0][0] > score - 0.05:
return None, f'ambiguous: {describe(best)} / {describe(scored[1][1])}'
return best['id'], f'matched {describe(best)}'
# --- episode alignment -------------------------------------------------------------
def _duration_ok(arte_seconds: int | None, runtimes: list[int | None]) -> bool | None:
"""None when it can't be checked."""
if not arte_seconds or not runtimes or not all(runtimes):
return None
ratio = arte_seconds / (60 * sum(runtimes))
return DURATION_TOLERANCE[0] <= ratio <= DURATION_TOLERANCE[1]
def align(arte: list[Episode], tmdb: list[dict]) -> tuple[dict[str, list[int]] | None, str]:
"""Map each Arte episode of a season to one or several consecutive TMDB episode numbers."""
tmdb = sorted(tmdb, key=lambda e: e['episode_number'])
numbers = [e['episode_number'] for e in tmdb]
runtime = {e['episode_number']: e.get('runtime') for e in tmdb}
arte = sorted(arte, key=lambda e: e.arte_number)
c = len(numbers)
m = max([e.total or 0 for e in arte] + [e.arte_number for e in arte])
if not c:
return None, 'TMDB season has no episodes'
def check(mapping):
results = [_duration_ok(e.duration, [runtime[n] for n in mapping[e.id]]) for e in arte]
return False not in results
if c % m == 0:
k = c // m
mapping = {e.id: numbers[(e.arte_number - 1) * k: e.arte_number * k] for e in arte}
if check(mapping):
return mapping, '1:1' if k == 1 else f'1 Arte episode = {k} TMDB episodes'
if k == 1: # same count but durations disagree: still the most likely mapping
return mapping, '1:1 (durations differ)'
# Uneven split: walk the whole season by duration (needs every Arte episode and runtime).
if len(arte) == m and all(e.duration for e in arte) and all(runtime.values()):
mapping, i = {}, 0
for e in arte:
group, acc = [], 0
while i < c and (not group or abs(acc + 60 * runtime[numbers[i]] - e.duration)
< abs(acc - e.duration)):
acc += 60 * runtime[numbers[i]]
group.append(numbers[i])
i += 1
if not group:
break
mapping[e.id] = group
if i == c and len(mapping) == len(arte) and check(mapping):
return mapping, 'aligned by duration'
return None, f'Arte has {m} episode(s), TMDB {c}: no reliable mapping'
# --- localized titles ---------------------------------------------------------------
def _is_generic(title: str | None) -> bool:
return not title or not title.strip() or bool(GENERIC_TITLE_RE.match(title))
def merge_titles(titles: list[str]) -> str:
"""Titles of the episodes held in one file: "X (part 1)", "X (part 2)" -> "X"."""
if len(titles) == 1:
return titles[0]
stripped = list(dict.fromkeys(PART_SUFFIX_RE.sub('', t).strip() for t in titles))
return stripped[0] if len(stripped) == 1 else ' / '.join(stripped)
@dataclass
class Metadata:
client: TMDBClient
show: dict
languages: list[str]
@property
def show_id(self) -> int:
return self.show['id']
def tmdb_language(self, lang: str) -> str:
return (self.show.get('original_language') or 'en') if lang == 'original' else lang
def series_name(self, arte_title: str) -> str:
translations = (self.show.get('translations') or {}).get('translations') or []
for lang in self.languages:
if lang == 'arte':
return arte_title
if lang == 'original':
name = self.show.get('original_name')
else:
iso, _, region = lang.partition('-')
matches = [t for t in translations if t.get('iso_639_1') == iso
and (not region or t.get('iso_3166_1') == region)]
# Untranslated: TMDB (and Plex / Jellyfin) show the original name in that language
name = next((t['data'].get('name') for t in matches if t['data'].get('name')),
self.show.get('original_name'))
if name:
return name
return self.show.get('name') or arte_title
def episodes(self, season: int, lang: str) -> dict[int, dict]:
data = self.client.season(self.show_id, season, self.tmdb_language(lang)) or {}
return {e['episode_number']: e for e in data.get('episodes') or []}
def localize(self, ep: Episode) -> None:
"""Pick the episode title and synopsis following the language priority."""
title = overview = None
for lang in self.languages:
if lang == 'arte':
t, o = (None if _is_generic(ep.arte_title) else ep.arte_title), ep.description
else:
eps = self.episodes(ep.season, lang)
found = [eps.get(n) or {} for n in ep.numbers]
names = [f.get('name') for f in found]
t = None if any(_is_generic(n) for n in names) else merge_titles(names)
o = '\n\n'.join(f['overview'] for f in found if f.get('overview')) or None
title = title or t
overview = overview or o
if title and overview:
break
ep.title = title or ep.arte_title
ep.description = overview or ep.description
def apply(series: Series, cfg: MetadataConfig, forced_id: int | None = None,
log=print) -> None:
"""Rename / renumber the series' episodes from TMDB. Keeps Arte data when unsure."""
client = TMDBClient(cfg.key)
first_lang = next((l for l in cfg.languages if l not in ('arte', 'original')), 'en-US')
show_id = forced_id or load_ids().get(series.id)
if show_id:
how = 'forced' if forced_id else 'remembered'
else:
show_id, how = find_show(client, series, first_lang)
if not show_id:
log(f' TMDB: {how} — keeping Arte metadata (use --tmdb-id to set it)')
return
show = client.show(show_id)
save_id(series.id, show_id)
meta = Metadata(client, show, cfg.languages)
series.tmdb_id = show_id
series.tvdb_id = (show.get('external_ids') or {}).get('tvdb_id')
series.year = int(show['first_air_date'][:4]) if show.get('first_air_date') else series.year
name = meta.series_name(series.title)
log(f' TMDB: {how} — "{name}" ({series.year}) https://www.themoviedb.org/tv/{show_id}')
tmdb_seasons = {s['season_number'] for s in show.get('seasons') or [] if s.get('episode_count')}
for season in series.seasons:
for ep in season.episodes:
ep.series = name
if season.number not in tmdb_seasons:
log(f' TMDB: season {season.number} not found — keeping Arte numbering')
continue
tmdb_eps = list(meta.episodes(season.number, first_lang).values())
mapping, how = align(season.episodes, tmdb_eps)
if mapping is None:
log(f' TMDB: season {season.number}: {how} — keeping Arte numbering')
continue
if how != '1:1':
log(f' TMDB: season {season.number}: {how}')
for ep in season.episodes:
nums = mapping[ep.id]
ep.number, ep.last_number = nums[0], (nums[-1] if len(nums) > 1 else None)
meta.localize(ep)
+195
View File
@@ -0,0 +1,195 @@
"""Pick video / audio / subtitle tracks from a yt-dlp info dict according to the config.
Arte HLS masters expose one video ladder plus several audio renditions whose
format ids look like "VF-STF-audio_0-suédois__VO_", "VF-STF-audio_0-français",
"VF-STF-audio_0-français__audiodescription_", "...__confort_audio_".
Subtitles are keyed "fr" (full), "fr-forced", "fr-acc" (SDH), "de-forced", ...
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from .config import Config
AUDIO_ID_RE = re.compile(r'-audio_\d+-(?P<name>.+)$')
CODEC_ALIASES = {
'avc': ('avc1', 'avc3', 'h264'),
'h264': ('avc1', 'avc3', 'h264'),
'hevc': ('hev1', 'hvc1', 'h265', 'hevc'),
'h265': ('hev1', 'hvc1', 'h265', 'hevc'),
'av1': ('av01',),
'vp9': ('vp9', 'vp09'),
}
LANGUAGE_NAMES = {
'fr': 'Français', 'de': 'Deutsch', 'en': 'English', 'es': 'Español',
'it': 'Italiano', 'pl': 'Polski', 'sv': 'Svenska', 'da': 'Dansk',
'no': 'Norsk', 'fi': 'Suomi', 'nl': 'Nederlands', 'pt': 'Português',
}
SUB_KINDS = {'': 'full', 'forced': 'forced', 'acc': 'sdh'}
SUB_KIND_LABELS = {'full': '', 'forced': ' (forcés)', 'sdh': ' (SDH)'}
class SelectionError(Exception):
pass
@dataclass
class AudioTrack:
format_id: str
lang: str | None
title: str
original: bool = False
kind: str = 'main' # main | ad | comfort
@dataclass
class SubtitleTrack:
key: str
lang: str
kind: str # full | forced | sdh
title: str
@dataclass
class Selection:
video: dict
audio: list[AudioTrack] = field(default_factory=list)
subtitles: list[SubtitleTrack] = field(default_factory=list)
default_subtitle: int | None = None
warnings: list[str] = field(default_factory=list)
@property
def format_spec(self) -> str:
return '+'.join([self.video['format_id'], *(a.format_id for a in self.audio)])
def normalize_lang(lang: str | None) -> str | None:
if not lang:
return None
lang = lang.lower().split('-')[0]
if len(lang) == 3:
from yt_dlp.utils import ISO639Utils
return ISO639Utils.long2short(lang) or lang
return lang
def parse_audio(fmt: dict) -> AudioTrack:
m = AUDIO_ID_RE.search(fmt['format_id'])
name = m['name'] if m else (fmt.get('format_note') or fmt.get('language') or 'audio')
# "suédois__VO_" (processed) or "suédois (VO)" (raw)
parts = [p.strip('_ ') for p in re.sub(r'[\s()]', '_', name).split('__')]
base = parts[0].replace('_', ' ').strip()
flags = ' '.join(parts[1:]).lower().replace('_', ' ')
original = bool(re.search(r'\bvo\b', flags))
kind = 'ad' if 'audiodescription' in flags else 'comfort' if 'confort' in flags else 'main'
lang = normalize_lang(fmt.get('language'))
label = base[:1].upper() + base[1:] if base else LANGUAGE_NAMES.get(lang or '', lang or 'Audio')
suffix = [s for s, on in (('VO', original), ('audiodescription', kind == 'ad'),
('confort audio', kind == 'comfort')) if on]
title = f'{label} ({", ".join(suffix)})' if suffix else label
return AudioTrack(format_id=fmt['format_id'], lang=lang, title=title,
original=original, kind=kind)
def audio_matches(spec: str, track: AudioTrack) -> bool:
spec = spec.lower()
if spec in ('original', 'vo'):
return track.original and track.kind == 'main'
lang, _, kind = spec.partition('-')
kind = {'': 'main', 'ad': 'ad', 'comfort': 'comfort'}.get(kind)
if kind is None:
raise SelectionError(f'Invalid audio track "{spec}" (use original, fr, fr-ad, fr-comfort...)')
return track.lang == lang and track.kind == kind
def _codec_rank(vcodec: str, preferences: list[str]) -> int:
vcodec = (vcodec or '').lower()
for i, pref in enumerate(preferences):
if vcodec.startswith(CODEC_ALIASES.get(pref.lower(), (pref.lower(),))):
return len(preferences) - i
return 0
def is_video(f: dict) -> bool:
return f.get('vcodec') not in (None, 'none') and bool(f.get('height'))
def pick_video(formats: list[dict], cfg: Config) -> dict:
videos = [f for f in formats if is_video(f)]
if not videos:
raise SelectionError('No video format found')
capped = [f for f in videos if not cfg.video.max_height or f['height'] <= cfg.video.max_height]
return max(capped or videos, key=lambda f: (
f['height'], _codec_rank(f.get('vcodec'), cfg.video.codecs), f.get('tbr') or 0))
def select(info: dict, cfg: Config) -> Selection:
formats = info.get('formats') or []
video = pick_video(formats, cfg)
sel = Selection(video=video)
# Audio renditions (Arte reports acodec=None for them, so test vcodec only). Keep
# the first occurrence of each format id: several "versions" can repeat them.
audio_tracks, seen = [], set()
for f in formats:
if f.get('vcodec') == 'none' and f['format_id'] not in seen:
seen.add(f['format_id'])
audio_tracks.append(parse_audio(f))
chosen = []
for spec in cfg.audio.tracks:
track = next((t for t in audio_tracks if audio_matches(spec, t)), None)
if track is None:
sel.warnings.append(f'audio "{spec}" not available')
elif all(t.format_id != track.format_id for t in chosen):
chosen.append(track)
if not chosen and audio_tracks:
best = max((f for f in formats if f['format_id'] in seen),
key=lambda f: (f.get('language_preference') or 0, f.get('abr') or 0))
chosen.append(parse_audio(best))
sel.warnings.append(f'no configured audio track found, falling back to "{chosen[0].title}"')
elif not audio_tracks and video.get('acodec') in (None, 'none'):
sel.warnings.append('no separate audio track found')
sel.audio = chosen
# Subtitles: the same file can be listed under several keys (e.g. "sv" and "fr").
subs = info.get('subtitles') or {}
seen_urls = set()
for key in cfg.subtitles.tracks:
entries = subs.get(key)
if not entries:
sel.warnings.append(f'subtitles "{key}" not available')
continue
url = entries[0].get('url')
if url in seen_urls:
continue
seen_urls.add(url)
lang, _, suffix = key.partition('-')
kind = SUB_KINDS.get(suffix, 'full')
name = LANGUAGE_NAMES.get(lang, lang)
sel.subtitles.append(SubtitleTrack(key=key, lang=lang, kind=kind,
title=name + SUB_KIND_LABELS[kind]))
sel.default_subtitle = _default_subtitle(sel, cfg.subtitles.default)
return sel
def _default_subtitle(sel: Selection, mode: str) -> int | None:
subs = sel.subtitles
if not subs or mode == 'none':
return None
if mode != 'auto':
return next((i for i, s in enumerate(subs) if s.key == mode), None)
audio_lang = sel.audio[0].lang if sel.audio else None
sub_lang = subs[0].lang
def find(kind):
return next((i for i, s in enumerate(subs) if s.lang == sub_lang and s.kind == kind), None)
if audio_lang == sub_lang:
return find('forced')
full = find('full')
return full if full is not None else find('sdh')
+54
View File
@@ -0,0 +1,54 @@
"""Minimal WebVTT -> SRT conversion.
Arte serves VTT files with CRLF line endings and STYLE blocks, which older
ffmpeg releases (e.g. 4.4, also used by yt-dlp --convert-subs) silently turn
into empty subtitles. Converting ourselves avoids depending on the ffmpeg version.
"""
from __future__ import annotations
import html
import re
_TIMING_RE = re.compile(
r'^\s*(?P<start>(?:\d+:)?\d{2}:\d{2}[.,]\d{3})\s+-->\s+(?P<end>(?:\d+:)?\d{2}:\d{2}[.,]\d{3})')
_KEEP_TAGS_RE = re.compile(r'</?(?:i|b|u)>')
_TAG_RE = re.compile(r'<[^>]*>')
def _timestamp(ts: str) -> str:
ts = ts.replace(',', '.')
parts = ts.split(':')
if len(parts) == 2:
parts.insert(0, '0')
h, m, s = parts
sec, ms = s.split('.')
return f'{int(h):02d}:{int(m):02d}:{int(sec):02d},{ms}'
def _clean(line: str) -> str:
# Keep <i>, <b>, <u>; drop <c.class>, <v Speaker>, <lang>, inline timestamps...
kept = []
def stash(m):
kept.append(m[0])
return f'\x00{len(kept) - 1}\x00'
line = _KEEP_TAGS_RE.sub(stash, line)
line = html.unescape(_TAG_RE.sub('', line)).replace(' ', ' ')
return re.sub(r'\x00(\d+)\x00', lambda m: kept[int(m[1])], line)
def vtt_to_srt(vtt: str) -> str:
text = vtt.lstrip('').replace('\r\n', '\n').replace('\r', '\n')
cues = []
for block in re.split(r'\n{2,}', text):
lines = block.strip('\n').split('\n')
timing_idx = next((i for i, l in enumerate(lines) if _TIMING_RE.match(l)), None)
if timing_idx is None: # header, STYLE, NOTE, REGION blocks
continue
m = _TIMING_RE.match(lines[timing_idx])
payload = [c for c in (_clean(l).strip() for l in lines[timing_idx + 1:]) if c]
if payload:
cues.append((_timestamp(m['start']), _timestamp(m['end']), payload))
return ''.join(f'{n}\n{start} --> {end}\n' + '\n'.join(payload) + '\n\n'
for n, (start, end, payload) in enumerate(cues, 1))
+49
View File
@@ -0,0 +1,49 @@
# arte-dl — copier dans ~/.config/arte-dl/config.toml (ou passer avec -c)
# Toutes les options sont facultatives ; les valeurs ci-dessous sont les valeurs par défaut.
[output]
# Racine de la vidéothèque (~ et $VARIABLES acceptés)
directory = "."
# Champs disponibles : {series} {year} {tmdb_id} {tvdb_id} {season} {episode} {title} {id}
# Un groupe vide (année ou id inconnus) est retiré : "Série ()" -> "Série".
# Pour forcer l'identification par Jellyfin : "{series} ({year}) [tmdbid-{tmdb_id}]"
series_dir = "{series} ({year})"
season_dir = "Season {season:02d}"
# {episode:02d} donne "01", ou "01-E02" pour un fichier qui contient deux épisodes TMDB
filename = "{series} - S{season:02d}E{episode:02d} - {title}"
[video]
# Hauteur maximale (216, 360, 432, 720, 1080)
max_height = 1080
# Codec préféré à hauteur égale : "hevc" (H.265, meilleur débit chez Arte) ou "avc" (H.264, plus compatible)
codecs = ["hevc", "avc"]
[audio]
# Pistes à inclure, dans l'ordre ; la première trouvée est la piste par défaut.
# "original" -> version originale (VO), quelle que soit sa langue
# "fr", "de"… -> piste dans cette langue (hors audiodescription / confort audio)
# "fr-ad" -> audiodescription
# "fr-comfort" -> « confort audio » (dialogues renforcés)
# Si la VO est en français, "original" et "fr" désignent la même piste : elle n'est incluse qu'une fois.
tracks = ["original", "fr"]
[subtitles]
# "fr" -> sous-titres complets, "fr-forced" -> forcés (passages en langue étrangère), "fr-acc" -> sourds et malentendants
tracks = ["fr", "fr-forced"]
# Piste de sous-titres par défaut : une clé de `tracks`, "none" ou "auto"
# ("auto" : forcés si la piste audio par défaut est dans la langue des sous-titres, complets sinon)
default = "auto"
[metadata]
# "tmdb" : nom de série, année, numérotation et titres d'épisodes depuis themoviedb.org
# "none" : uniquement les données Arte
provider = "tmdb"
# Clé API TMDB (v3) ou jeton d'accès en lecture (v4). La variable d'environnement
# TMDB_API_KEY fonctionne aussi. Sans clé : données Arte uniquement.
api_key = ""
# Ordre de priorité pour le nom de la série, les titres et les résumés d'épisodes.
# "fr-FR", "en-US", "de"… -> langue TMDB
# "arte" -> titres / résumés Arte
# "original" -> langue originale de la série
# Une langue sans titre (ou avec un titre générique « Épisode 3 ») passe à la suivante.
languages = ["fr-FR", "arte", "en-US"]
+23
View File
@@ -0,0 +1,23 @@
[project]
name = "arte-dl"
version = "0.1.0"
description = "Download whole arte.tv series with yt-dlp into a tidy Series/Season XX/ tree of MKV files"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"yt-dlp>=2025.1.1",
"tomli>=2.0; python_version < '3.11'",
]
[project.optional-dependencies]
dev = ["pytest>=8"]
[project.scripts]
arte-dl = "arte_dl.cli:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["arte_dl"]
+189
View File
@@ -0,0 +1,189 @@
import json
import pytest
from arte_dl import metadata
from arte_dl.arte_api import Episode, Season, Series
from arte_dl.config import MetadataConfig
from arte_dl.metadata import Metadata, align, find_show, merge_titles
def arte_ep(season, n, total, minutes, title, pid=None):
return Episode(pid or f'{season:03d}{n:03d}-000-A', 'u', 'Meurtres à Sandhamn', season, n, title,
description=f'arte {season}/{n}', duration=minutes * 60, total=total)
def tmdb_ep(n, name, runtime=45, overview=''):
return {'episode_number': n, 'name': name, 'runtime': runtime, 'overview': overview}
# As seen on TMDB (show 55270) and Arte (RC-022391)
S01_TMDB = [tmdb_ep(i, n) for i, n in enumerate(
['La Reine de la Baltique (1)', 'La Reine de la Baltique (2)', 'La Reine de la Baltique (3)'], 1)]
S06_TMDB = [tmdb_ep(i, f'{name} part {p}') for i, (name, p) in enumerate(
[(t, p) for t in ['Le prix à payer', 'Au nom de la vérité', 'À la vie, à la mort', 'Un goût amer']
for p in (1, 2)], 1)]
def s06_arte():
return [arte_ep(6, i, 4, 88, f'Enquête {5 + i}') for i in range(1, 5)]
def test_align_one_to_one():
eps = [arte_ep(1, i, 3, 43, 'Enquête 1 : La reine de la Baltique') for i in range(1, 4)]
mapping, how = align(eps, S01_TMDB)
assert how == '1:1' and [mapping[e.id] for e in eps] == [[1], [2], [3]]
def test_align_arte_merges_two_parts():
eps = s06_arte()
mapping, how = align(eps, S06_TMDB)
assert how == '1 Arte episode = 2 TMDB episodes'
assert [mapping[e.id] for e in eps] == [[1, 2], [3, 4], [5, 6], [7, 8]]
def test_align_partial_season_uses_arte_total():
eps = s06_arte()[2:3] # only Arte episode 3/4 still online
mapping, _ = align(eps, S06_TMDB)
assert mapping[eps[0].id] == [5, 6]
def test_align_uneven_by_duration():
eps = [arte_ep(2, 1, 2, 88, 'a'), arte_ep(2, 2, 2, 45, 'b')]
tmdb = [tmdb_ep(1, 'x'), tmdb_ep(2, 'y'), tmdb_ep(3, 'z')]
mapping, how = align(eps, tmdb)
assert how == 'aligned by duration' and [mapping[e.id] for e in eps] == [[1, 2], [3]]
def test_align_refuses_inconsistent():
eps = [arte_ep(2, i, 3, 45, 'a') for i in range(1, 4)]
mapping, how = align(eps, [tmdb_ep(i, 'x') for i in range(1, 6)])
assert mapping is None and 'no reliable mapping' in how
def test_merge_titles():
assert merge_titles(['Le prix à payer part 1', 'Le prix à payer part 2']) == 'Le prix à payer'
assert merge_titles(['Au nom de la vérité (part1)', 'Au nom de la vérité (part 2)']) == 'Au nom de la vérité'
assert merge_titles(['A', 'B']) == 'A / B'
assert merge_titles(['Mensonges bleus (1)', 'Mensonges bleus (2)']) == 'Mensonges bleus'
assert merge_titles(['Madeleine', 'Madeleine (2)']) == 'Madeleine'
assert merge_titles(['Chapitre 12', 'Chapitre 13']) == 'Chapitre 12 / Chapitre 13'
assert merge_titles(['Solo (part 1)']) == 'Solo (part 1)' # a single episode keeps its title
class FakeClient:
def __init__(self, search=(), shows=None, seasons=None):
self._search, self._shows, self._seasons = search, shows or {}, seasons or {}
self.calls = []
def search(self, query, language):
self.calls.append(('search', query))
return tuple(r for r in self._search if query.lower() in json.dumps(r, ensure_ascii=False).lower())
def show(self, show_id):
return self._shows[show_id]
def season(self, show_id, number, language):
self.calls.append(('season', number, language))
eps = self._seasons.get((number, language))
return {'episodes': eps} if eps is not None else None
def test_find_show_prefers_year_and_language():
series = Series('RC-027900', 'The Hack : sur écoute', 'fr', original_title='The Hack',
original_language='en', year=2025)
client = FakeClient(search=[
{'id': 1, 'name': 'The Hack', 'original_name': 'The Hack', 'first_air_date': '2012-01-01',
'original_language': 'en'},
{'id': 2, 'name': 'The Hack', 'original_name': 'The Hack', 'first_air_date': '2025-09-24',
'original_language': 'en'},
{'id': 3, 'name': 'Hacks', 'original_name': 'Hacks', 'first_air_date': '2021-05-13',
'original_language': 'en'},
])
assert find_show(client, series, 'fr-FR')[0] == 2
def test_find_show_rejects_weak_or_ambiguous():
series = Series('RC-1', 'Inconnue', 'fr', year=2020)
client = FakeClient(search=[{'id': 9, 'name': 'Inconnue au bataillon', 'first_air_date': '2020-01-01'}])
assert find_show(client, series, 'fr-FR')[0] is None
series = Series('RC-2', 'Twin', 'fr') # no year: two identical names
client = FakeClient(search=[{'id': 1, 'name': 'Twin'}, {'id': 2, 'name': 'Twin'}])
show_id, why = find_show(client, series, 'fr-FR')
assert show_id is None and why.startswith('ambiguous')
SHOW = {
'id': 55270, 'name': 'Meurtres à Sandhamn', 'original_name': 'Morden i Sandhamn',
'original_language': 'sv', 'first_air_date': '2010-01-09', 'external_ids': {'tvdb_id': 158851},
'seasons': [{'season_number': 0, 'episode_count': 2}, {'season_number': 1, 'episode_count': 3},
{'season_number': 6, 'episode_count': 8}],
'translations': {'translations': [
{'iso_639_1': 'fr', 'iso_3166_1': 'FR', 'data': {'name': 'Meurtres à Sandhamn'}},
{'iso_639_1': 'en', 'iso_3166_1': 'US', 'data': {'name': 'The Sandhamn Murders'}},
{'iso_639_1': 'sv', 'iso_3166_1': 'SE', 'data': {'name': ''}},
]},
}
def test_series_name_language_priority():
meta = Metadata(FakeClient(), SHOW, ['en-US', 'fr-FR'])
assert meta.series_name('Arte') == 'The Sandhamn Murders'
assert Metadata(FakeClient(), SHOW, ['sv', 'fr-FR']).series_name('Arte') == 'Morden i Sandhamn'
assert Metadata(FakeClient(), SHOW, ['original']).series_name('Arte') == 'Morden i Sandhamn'
assert Metadata(FakeClient(), SHOW, ['arte', 'fr-FR']).series_name('Arte') == 'Arte'
# No French translation (The Hack): TMDB shows the original name, not Arte's
untranslated = {**SHOW, 'original_name': 'The Hack', 'translations': {'translations': [
{'iso_639_1': 'fr', 'iso_3166_1': 'FR', 'data': {'name': ''}}]}}
assert Metadata(FakeClient(), untranslated, ['fr-FR', 'arte']).series_name('Arte') == 'The Hack'
def test_localize_falls_back_across_languages():
client = FakeClient(seasons={
(1, 'fr-FR'): [tmdb_ep(1, 'Épisode 1'), tmdb_ep(2, 'Le retour', overview='fr')],
(1, 'en-US'): [tmdb_ep(1, 'Pilot', overview='en'), tmdb_ep(2, 'Return')],
})
meta = Metadata(client, {**SHOW, 'id': 1}, ['fr-FR', 'arte', 'en-US'])
generic = Episode('x-1', 'u', 's', 1, 1, 'Épisode 1', description='arte')
meta.localize(generic)
assert (generic.title, generic.description) == ('Pilot', 'arte') # fr title is a placeholder
titled = Episode('x-2', 'u', 's', 1, 2, 'Titre Arte')
meta.localize(titled)
assert (titled.title, titled.description) == ('Le retour', 'fr')
@pytest.fixture
def data_home(tmp_path, monkeypatch):
monkeypatch.setenv('XDG_DATA_HOME', str(tmp_path))
return tmp_path
def test_apply_end_to_end(data_home, monkeypatch):
seasons = {(6, 'fr-FR'): S06_TMDB, (1, 'fr-FR'): S01_TMDB}
client = FakeClient(search=[{'id': 55270, 'name': 'Meurtres à Sandhamn',
'original_name': 'Morden i Sandhamn', 'first_air_date': '2010-01-09',
'original_language': 'sv'}],
shows={55270: SHOW}, seasons=seasons)
monkeypatch.setattr(metadata, 'TMDBClient', lambda key: client)
series = Series('RC-022391', 'Meurtres à Sandhamn', 'fr', original_title='Morden I Sandhamn',
original_language='sv', year=2010)
series.seasons = [Season('RC-022393', 6, 'Saison 6', s06_arte()),
Season('RC-X', 7, 'Saison 7', [arte_ep(7, 1, 4, 88, 'Enquête 10')])]
logs = []
metadata.apply(series, MetadataConfig(api_key='k'), log=logs.append)
s6 = series.seasons[0].episodes
assert [(e.label, e.title) for e in s6[:2]] == [('S06E01-E02', 'Le prix à payer'),
('S06E03-E04', 'Au nom de la vérité')]
assert s6[0].arte_number == 1 and s6[0].description == 'arte 6/1' # no TMDB overview
s7 = series.seasons[1].episodes[0]
assert (s7.label, s7.title) == ('S07E01', 'Enquête 10') # unknown on TMDB: Arte kept
assert (series.tmdb_id, series.tvdb_id, series.year) == (55270, 158851, 2010)
assert any('season 7 not found' in line for line in logs)
assert metadata.load_ids() == {'RC-022391': 55270}
# Second run: id remembered, no search
client.calls.clear()
metadata.apply(series, MetadataConfig(api_key='k'), log=logs.append)
assert not [c for c in client.calls if c[0] == 'search']
+78
View File
@@ -0,0 +1,78 @@
from pathlib import Path
import pytest
from arte_dl.arte_api import ArteClient, parse_url
from arte_dl.cli import parse_ranges
from arte_dl.config import Config, ConfigError, load_config
from arte_dl.download import destination, sanitize
from arte_dl.subtitles import vtt_to_srt
VTT = (
'WEBVTT\r\n\r\nSTYLE\r\n::cue(.red) {\r\n color: red;\r\n}\r\n\r\n'
'NOTE a comment\r\n\r\n'
'cue-1\r\n00:00:15.320 --> 00:00:16.880 line:91% align:center\r\n'
'<c.white.bg_black>Je la tiens</c> &amp; <i>vite</i> !\r\n\r\n'
'01:02.000 --> 01:03.500\r\n<v Mia>Bonjour,</v>\r\nMaria.\r\n\r\n'
'00:01:04.000 --> 00:01:05.000\r\n<c.red></c>\r\n'
)
def test_vtt_to_srt():
assert vtt_to_srt(VTT) == (
'1\n00:00:15,320 --> 00:00:16,880\nJe la tiens & <i>vite</i> !\n\n'
'2\n00:01:02,000 --> 00:01:03,500\nBonjour,\nMaria.\n\n'
)
def test_parse_url():
assert parse_url('https://www.arte.tv/fr/videos/RC-027900/the-hack-sur-ecoute/') == ('fr', 'RC-027900')
assert parse_url('https://www.arte.tv/de/videos/059534-001-A/x/') == ('de', '059534-001-A')
def test_parse_ranges():
assert parse_ranges('1,3-5, 8') == {1, 3, 4, 5, 8}
def test_episode_numbering_and_titles():
items = [
{'providerId': '044639-009-A', 'title': 'Twin Peaks - Saison 2 (1/22)', 'subtitle': 'Le géant'},
{'providerId': 'RC-000000', 'title': 'bonus collection'},
{'providerId': '125066-002-A', 'title': 'The Hack (2/7)', 'subtitle': None},
{'providerId': '125066-009-A', 'title': 'Making-of'},
]
eps = ArteClient('fr')._episodes(items, 'Series', 2, None)
assert [(e.number, e.title) for e in eps] == [(1, 'Le géant'), (2, 'Épisode 2'), (3, 'Making-of')]
assert eps[0].url == 'https://www.arte.tv/fr/videos/044639-009-A/'
def test_destination():
from arte_dl.arte_api import Episode, Series
cfg = Config()
cfg.output.directory = '/media/series'
series = Series('RC-027900', 'The Hack : sur écoute', 'fr')
ep = Episode('125066-001-A', 'u', 'The Hack : sur écoute', 1, 1, 'Qui ? Quoi / où')
# no year known: the empty "()" disappears
assert destination(series, ep, cfg) == Path(
'/media/series/The Hack - sur écoute/Season 01/The Hack - sur écoute - S01E01 - Qui Quoi - où.mkv')
series.year, series.tmdb_id = 2025, 12345
ep.series, ep.season, ep.number, ep.last_number = 'Meurtres à Sandhamn', 6, 1, 2
cfg.output.series_dir = '{series} ({year}) [tmdbid-{tmdb_id}] {{tvdb-{tvdb_id}}}'
assert destination(series, ep, cfg) == Path(
'/media/series/Meurtres à Sandhamn (2025) [tmdbid-12345]/Season 06/'
'Meurtres à Sandhamn - S06E01-E02 - Qui Quoi - où.mkv')
assert sanitize('a: b.') == 'a - b'
def test_config(tmp_path):
path = tmp_path / 'c.toml'
path.write_text('[video]\nmax_height = 720\n[audio]\ntracks = ["fr"]\n')
cfg = load_config(path)
assert (cfg.video.max_height, cfg.audio.tracks, cfg.video.codecs) == (720, ['fr'], ['hevc', 'avc'])
path.write_text('[video]\nmax_heigth = 720\n')
with pytest.raises(ConfigError, match='max_heigth'):
load_config(path)
path.write_text('[audio]\ntracks = "fr"\n')
with pytest.raises(ConfigError, match='list'):
load_config(path)
+96
View File
@@ -0,0 +1,96 @@
import pytest
from arte_dl.config import Config
from arte_dl.selection import SelectionError, parse_audio, select
def audio(name, lang):
return {'format_id': f'VF-STF-audio_0-{name}', 'vcodec': 'none', 'acodec': None, 'language': lang}
def video(fid, height, vcodec, tbr):
return {'format_id': fid, 'vcodec': vcodec, 'acodec': 'none', 'height': height, 'tbr': tbr}
# Formats as seen on https://www.arte.tv/fr/videos/059534-001-A/ after yt-dlp processing
INFO = {
'formats': [
audio('allemand', 'de'),
audio('allemand__audiodescription_', 'de'),
audio('français__audiodescription_', 'fr'),
audio('français__confort_audio_', 'fr'),
audio('suédois__VO_', 'sv'),
audio('français', 'fr'),
video('VF-STF-427', 216, 'avc1.42e00d', 427),
video('VF-STF-2314', 720, 'avc1.4d401f', 2314),
video('VF-STF-2312', 1080, 'avc1.4d0028', 2312),
video('VF-STF-3117', 1080, 'hev1.2.4.L123.B0', 3117),
],
'subtitles': {
'fr-forced': [{'url': 'https://x/st_VF-FRA.m3u8'}],
'fr': [{'url': 'https://x/st_VO-FRA.m3u8'}],
'fr-acc': [{'url': 'https://x/st_VF-MAL.m3u8'}],
'sv': [{'url': 'https://x/st_VO-FRA.m3u8'}], # same file as "fr"
},
}
def test_parse_audio_flags():
vo = parse_audio(audio('suédois__VO_', 'sv'))
assert (vo.original, vo.kind, vo.title) == (True, 'main', 'Suédois (VO)')
ad = parse_audio(audio('français__audiodescription_', 'fr'))
assert (ad.original, ad.kind, ad.title) == (False, 'ad', 'Français (audiodescription)')
raw = parse_audio(audio('français (confort audio)', 'fr')) # unprocessed id
assert (raw.kind, raw.title) == ('comfort', 'Français (confort audio)')
def test_default_selection():
sel = select(INFO, Config())
assert sel.video['format_id'] == 'VF-STF-3117'
assert [a.title for a in sel.audio] == ['Suédois (VO)', 'Français']
assert sel.format_spec == 'VF-STF-3117+VF-STF-audio_0-suédois__VO_+VF-STF-audio_0-français'
assert [s.key for s in sel.subtitles] == ['fr', 'fr-forced']
assert sel.default_subtitle == 0 # VO audio -> full subs
assert not sel.warnings
def test_codec_and_height_preferences():
cfg = Config()
cfg.video.codecs = ['avc', 'hevc']
assert select(INFO, cfg).video['format_id'] == 'VF-STF-2312'
cfg.video.max_height = 720
assert select(INFO, cfg).video['format_id'] == 'VF-STF-2314'
def test_french_first_gets_forced_subs():
cfg = Config()
cfg.audio.tracks = ['fr', 'original']
sel = select(INFO, cfg)
assert [a.lang for a in sel.audio] == ['fr', 'sv']
assert sel.subtitles[sel.default_subtitle].key == 'fr-forced'
def test_missing_and_duplicate_tracks():
cfg = Config()
cfg.audio.tracks = ['original', 'en', 'fr-ad']
cfg.subtitles.tracks = ['fr', 'sv', 'de-forced']
cfg.subtitles.default = 'none'
sel = select(INFO, cfg)
assert [a.title for a in sel.audio] == ['Suédois (VO)', 'Français (audiodescription)']
assert [s.key for s in sel.subtitles] == ['fr'] # "sv" is the same file
assert sel.default_subtitle is None
assert sel.warnings == ['audio "en" not available', 'subtitles "de-forced" not available']
def test_fallback_audio_when_nothing_matches():
cfg = Config()
cfg.audio.tracks = ['it']
sel = select(INFO, cfg)
assert len(sel.audio) == 1 and 'falling back' in sel.warnings[-1]
def test_invalid_audio_spec():
cfg = Config()
cfg.audio.tracks = ['fr-xyz']
with pytest.raises(SelectionError):
select(INFO, cfg)