feat(dl): add --latest-episode option to download only the most recent episode

Adds a new CLI option `-le, --latest-episode` that automatically selects and downloads only the single most recent episode from a series, regardless of which season it's in.

Fixes #28
This commit is contained in:
Andy
2025-10-18 07:04:11 +00:00
parent 7a49a6a4f9
commit ed1314572b

View File

@@ -152,6 +152,13 @@ class dl:
default=None, default=None,
help="Wanted episodes, e.g. `S01-S05,S07`, `S01E01-S02E03`, `S02-S02E03`, e.t.c, defaults to all.", help="Wanted episodes, e.g. `S01-S05,S07`, `S01E01-S02E03`, `S02-S02E03`, e.t.c, defaults to all.",
) )
@click.option(
"-le",
"--latest-episode",
is_flag=True,
default=False,
help="Download only the single most recent episode available.",
)
@click.option( @click.option(
"-l", "-l",
"--lang", "--lang",
@@ -322,11 +329,7 @@ class dl:
debug_log_path = config.directories.logs / config.filenames.debug_log.format_map( debug_log_path = config.directories.logs / config.filenames.debug_log.format_map(
defaultdict(str, service=self.service, time=datetime.now().strftime("%Y%m%d-%H%M%S")) defaultdict(str, service=self.service, time=datetime.now().strftime("%Y%m%d-%H%M%S"))
) )
init_debug_logger( init_debug_logger(log_path=debug_log_path, enabled=True, log_keys=config.debug_keys)
log_path=debug_log_path,
enabled=True,
log_keys=config.debug_keys
)
self.debug_logger = get_debug_logger() self.debug_logger = get_debug_logger()
if self.debug_logger: if self.debug_logger:
@@ -342,8 +345,12 @@ class dl:
"tmdb_id": tmdb_id, "tmdb_id": tmdb_id,
"tmdb_name": tmdb_name, "tmdb_name": tmdb_name,
"tmdb_year": tmdb_year, "tmdb_year": tmdb_year,
"cli_params": {k: v for k, v in ctx.params.items() if k not in ['profile', 'proxy', 'tag', 'tmdb_id', 'tmdb_name', 'tmdb_year']} "cli_params": {
} k: v
for k, v in ctx.params.items()
if k not in ["profile", "proxy", "tag", "tmdb_id", "tmdb_name", "tmdb_year"]
},
},
) )
else: else:
self.debug_logger = None self.debug_logger = None
@@ -361,7 +368,7 @@ class dl:
level="DEBUG", level="DEBUG",
operation="load_service_config", operation="load_service_config",
service=self.service, service=self.service,
context={"config_path": str(service_config_path), "config": self.service_config} context={"config_path": str(service_config_path), "config": self.service_config},
) )
else: else:
self.service_config = {} self.service_config = {}
@@ -438,19 +445,25 @@ class dl:
cdm_info = {"type": "DecryptLabs", "drm_type": drm_type, "security_level": self.cdm.security_level} cdm_info = {"type": "DecryptLabs", "drm_type": drm_type, "security_level": self.cdm.security_level}
elif hasattr(self.cdm, "device_type") and self.cdm.device_type.name in ["ANDROID", "CHROME"]: elif hasattr(self.cdm, "device_type") and self.cdm.device_type.name in ["ANDROID", "CHROME"]:
self.log.info(f"Loaded Widevine CDM: {self.cdm.system_id} (L{self.cdm.security_level})") self.log.info(f"Loaded Widevine CDM: {self.cdm.system_id} (L{self.cdm.security_level})")
cdm_info = {"type": "Widevine", "system_id": self.cdm.system_id, "security_level": self.cdm.security_level, "device_type": self.cdm.device_type.name} cdm_info = {
"type": "Widevine",
"system_id": self.cdm.system_id,
"security_level": self.cdm.security_level,
"device_type": self.cdm.device_type.name,
}
else: else:
self.log.info( self.log.info(
f"Loaded PlayReady CDM: {self.cdm.certificate_chain.get_name()} (L{self.cdm.security_level})" f"Loaded PlayReady CDM: {self.cdm.certificate_chain.get_name()} (L{self.cdm.security_level})"
) )
cdm_info = {"type": "PlayReady", "certificate": self.cdm.certificate_chain.get_name(), "security_level": self.cdm.security_level} cdm_info = {
"type": "PlayReady",
"certificate": self.cdm.certificate_chain.get_name(),
"security_level": self.cdm.security_level,
}
if self.debug_logger and cdm_info: if self.debug_logger and cdm_info:
self.debug_logger.log( self.debug_logger.log(
level="INFO", level="INFO", operation="load_cdm", service=self.service, context={"cdm": cdm_info}
operation="load_cdm",
service=self.service,
context={"cdm": cdm_info}
) )
self.proxy_providers = [] self.proxy_providers = []
@@ -526,6 +539,7 @@ class dl:
channels: float, channels: float,
no_atmos: bool, no_atmos: bool,
wanted: list[str], wanted: list[str],
latest_episode: bool,
lang: list[str], lang: list[str],
v_lang: list[str], v_lang: list[str],
a_lang: list[str], a_lang: list[str],
@@ -587,8 +601,8 @@ class dl:
context={ context={
"cdm_only": cdm_only, "cdm_only": cdm_only,
"vaults_only": vaults_only, "vaults_only": vaults_only,
"mode": "CDM only" if cdm_only else ("Vaults only" if vaults_only else "Both CDM and Vaults") "mode": "CDM only" if cdm_only else ("Vaults only" if vaults_only else "Both CDM and Vaults"),
} },
) )
with console.status("Authenticating with Service...", spinner="dots"): with console.status("Authenticating with Service...", spinner="dots"):
@@ -606,16 +620,13 @@ class dl:
context={ context={
"has_cookies": bool(cookies), "has_cookies": bool(cookies),
"has_credentials": bool(credential), "has_credentials": bool(credential),
"profile": self.profile "profile": self.profile,
} },
) )
except Exception as e: except Exception as e:
if self.debug_logger: if self.debug_logger:
self.debug_logger.log_error( self.debug_logger.log_error(
"authenticate", "authenticate", e, service=self.service, context={"profile": self.profile}
e,
service=self.service,
context={"profile": self.profile}
) )
raise raise
@@ -630,31 +641,24 @@ class dl:
operation="get_titles", operation="get_titles",
service=self.service, service=self.service,
message="No titles returned from service", message="No titles returned from service",
success=False success=False,
) )
sys.exit(1) sys.exit(1)
except Exception as e: except Exception as e:
if self.debug_logger: if self.debug_logger:
self.debug_logger.log_error( self.debug_logger.log_error("get_titles", e, service=self.service)
"get_titles",
e,
service=self.service
)
raise raise
if self.debug_logger: if self.debug_logger:
titles_info = { titles_info = {
"type": titles.__class__.__name__, "type": titles.__class__.__name__,
"count": len(titles) if hasattr(titles, "__len__") else 1, "count": len(titles) if hasattr(titles, "__len__") else 1,
"title": str(titles) "title": str(titles),
} }
if hasattr(titles, "seasons"): if hasattr(titles, "seasons"):
titles_info["seasons"] = len(titles.seasons) if hasattr(titles, "seasons") else 0 titles_info["seasons"] = len(titles.seasons) if hasattr(titles, "seasons") else 0
self.debug_logger.log( self.debug_logger.log(
level="INFO", level="INFO", operation="get_titles", service=self.service, context={"titles": titles_info}
operation="get_titles",
service=self.service,
context={"titles": titles_info}
) )
if self.tmdb_year and self.tmdb_id: if self.tmdb_year and self.tmdb_id:
@@ -674,8 +678,21 @@ class dl:
if list_titles: if list_titles:
return return
# Determine the latest episode if --latest-episode is set
latest_episode_id = None
if latest_episode and isinstance(titles, Series) and len(titles) > 0:
# Series is already sorted by (season, number, year)
# The last episode in the sorted list is the latest
latest_ep = titles[-1]
latest_episode_id = f"{latest_ep.season}x{latest_ep.number}"
self.log.info(f"Latest episode mode: Selecting S{latest_ep.season:02}E{latest_ep.number:02}")
for i, title in enumerate(titles): for i, title in enumerate(titles):
if isinstance(title, Episode) and wanted and f"{title.season}x{title.number}" not in wanted: if isinstance(title, Episode) and latest_episode and latest_episode_id:
# If --latest-episode is set, only process the latest episode
if f"{title.season}x{title.number}" != latest_episode_id:
continue
elif isinstance(title, Episode) and wanted and f"{title.season}x{title.number}" not in wanted:
continue continue
console.print(Padding(Rule(f"[rule.text]{title}"), (1, 2))) console.print(Padding(Rule(f"[rule.text]{title}"), (1, 2)))
@@ -750,10 +767,7 @@ class dl:
except Exception as e: except Exception as e:
if self.debug_logger: if self.debug_logger:
self.debug_logger.log_error( self.debug_logger.log_error(
"get_tracks", "get_tracks", e, service=self.service, context={"title": str(title)}
e,
service=self.service,
context={"title": str(title)}
) )
raise raise
@@ -764,34 +778,40 @@ class dl:
"audio_tracks": len(title.tracks.audio), "audio_tracks": len(title.tracks.audio),
"subtitle_tracks": len(title.tracks.subtitles), "subtitle_tracks": len(title.tracks.subtitles),
"has_chapters": bool(title.tracks.chapters), "has_chapters": bool(title.tracks.chapters),
"videos": [{ "videos": [
{
"codec": str(v.codec), "codec": str(v.codec),
"resolution": f"{v.width}x{v.height}" if v.width and v.height else "unknown", "resolution": f"{v.width}x{v.height}" if v.width and v.height else "unknown",
"bitrate": v.bitrate, "bitrate": v.bitrate,
"range": str(v.range), "range": str(v.range),
"language": str(v.language) if v.language else None, "language": str(v.language) if v.language else None,
"drm": [str(type(d).__name__) for d in v.drm] if v.drm else [] "drm": [str(type(d).__name__) for d in v.drm] if v.drm else [],
} for v in title.tracks.videos], }
"audio": [{ for v in title.tracks.videos
],
"audio": [
{
"codec": str(a.codec), "codec": str(a.codec),
"bitrate": a.bitrate, "bitrate": a.bitrate,
"channels": a.channels, "channels": a.channels,
"language": str(a.language) if a.language else None, "language": str(a.language) if a.language else None,
"descriptive": a.descriptive, "descriptive": a.descriptive,
"drm": [str(type(d).__name__) for d in a.drm] if a.drm else [] "drm": [str(type(d).__name__) for d in a.drm] if a.drm else [],
} for a in title.tracks.audio], }
"subtitles": [{ for a in title.tracks.audio
],
"subtitles": [
{
"codec": str(s.codec), "codec": str(s.codec),
"language": str(s.language) if s.language else None, "language": str(s.language) if s.language else None,
"forced": s.forced, "forced": s.forced,
"sdh": s.sdh "sdh": s.sdh,
} for s in title.tracks.subtitles] }
for s in title.tracks.subtitles
],
} }
self.debug_logger.log( self.debug_logger.log(
level="INFO", level="INFO", operation="get_tracks", service=self.service, context=tracks_info
operation="get_tracks",
service=self.service,
context=tracks_info
) )
# strip SDH subs to non-SDH if no equivalent same-lang non-SDH is available # strip SDH subs to non-SDH if no equivalent same-lang non-SDH is available
@@ -1185,7 +1205,7 @@ class dl:
operation="download_tracks", operation="download_tracks",
service=self.service, service=self.service,
message="Download cancelled by user", message="Download cancelled by user",
context={"title": str(title)} context={"title": str(title)},
) )
return return
except Exception as e: # noqa except Exception as e: # noqa
@@ -1219,8 +1239,8 @@ class dl:
"title": str(title), "title": str(title),
"error_type": type(e).__name__, "error_type": type(e).__name__,
"tracks_count": len(title.tracks), "tracks_count": len(title.tracks),
"returncode": getattr(e, "returncode", None) "returncode": getattr(e, "returncode", None),
} },
) )
return return
@@ -1475,9 +1495,13 @@ class dl:
if not no_folder and isinstance(title, (Episode, Song)): if not no_folder and isinstance(title, (Episode, Song)):
# Create folder based on title # Create folder based on title
# Use first available track for filename generation # Use first available track for filename generation
sample_track = title.tracks.videos[0] if title.tracks.videos else ( sample_track = (
title.tracks.audio[0] if title.tracks.audio else ( title.tracks.videos[0]
title.tracks.subtitles[0] if title.tracks.subtitles else None if title.tracks.videos
else (
title.tracks.audio[0]
if title.tracks.audio
else (title.tracks.subtitles[0] if title.tracks.subtitles else None)
) )
) )
if sample_track and sample_track.path: if sample_track and sample_track.path:
@@ -1498,7 +1522,9 @@ class dl:
track_suffix = f".{track.codec.name if hasattr(track.codec, 'name') else 'video'}" track_suffix = f".{track.codec.name if hasattr(track.codec, 'name') else 'video'}"
elif isinstance(track, Audio): elif isinstance(track, Audio):
lang_suffix = f".{track.language}" if track.language else "" lang_suffix = f".{track.language}" if track.language else ""
track_suffix = f"{lang_suffix}.{track.codec.name if hasattr(track.codec, 'name') else 'audio'}" track_suffix = (
f"{lang_suffix}.{track.codec.name if hasattr(track.codec, 'name') else 'audio'}"
)
elif isinstance(track, Subtitle): elif isinstance(track, Subtitle):
lang_suffix = f".{track.language}" if track.language else "" lang_suffix = f".{track.language}" if track.language else ""
forced_suffix = ".forced" if track.forced else "" forced_suffix = ".forced" if track.forced else ""
@@ -1595,8 +1621,8 @@ class dl:
"title": str(title), "title": str(title),
"pssh": drm.pssh.dumps() if drm.pssh else None, "pssh": drm.pssh.dumps() if drm.pssh else None,
"kids": [k.hex for k in drm.kids], "kids": [k.hex for k in drm.kids],
"track_kid": track_kid.hex if track_kid else None "track_kid": track_kid.hex if track_kid else None,
} },
) )
with self.DRM_TABLE_LOCK: with self.DRM_TABLE_LOCK:
@@ -1637,8 +1663,8 @@ class dl:
"kid": kid.hex, "kid": kid.hex,
"content_key": content_key, "content_key": content_key,
"track": str(track), "track": str(track),
"from_cache": True "from_cache": True,
} },
) )
elif vaults_only: elif vaults_only:
msg = f"No Vault has a Key for {kid.hex} and --vaults-only was used" msg = f"No Vault has a Key for {kid.hex} and --vaults-only was used"
@@ -1651,7 +1677,7 @@ class dl:
operation="vault_key_not_found", operation="vault_key_not_found",
service=self.service, service=self.service,
message=msg, message=msg,
context={"kid": kid.hex, "track": str(track)} context={"kid": kid.hex, "track": str(track)},
) )
raise Widevine.Exceptions.CEKNotFound(msg) raise Widevine.Exceptions.CEKNotFound(msg)
else: else:
@@ -1671,8 +1697,8 @@ class dl:
message="Requesting Widevine license from service", message="Requesting Widevine license from service",
context={ context={
"track": str(track), "track": str(track),
"kids_needed": [k.hex for k in all_kids if k not in drm.content_keys] "kids_needed": [k.hex for k in all_kids if k not in drm.content_keys],
} },
) )
try: try:
@@ -1693,10 +1719,7 @@ class dl:
"get_license", "get_license",
e, e,
service=self.service, service=self.service,
context={ context={"track": str(track), "exception_type": type(e).__name__},
"track": str(track),
"exception_type": type(e).__name__
}
) )
raise e raise e
@@ -1708,8 +1731,8 @@ class dl:
context={ context={
"track": str(track), "track": str(track),
"keys_count": len(drm.content_keys), "keys_count": len(drm.content_keys),
"kids": [k.hex for k in drm.content_keys.keys()] "kids": [k.hex for k in drm.content_keys.keys()],
} },
) )
for kid_, key in drm.content_keys.items(): for kid_, key in drm.content_keys.items():
@@ -1767,8 +1790,8 @@ class dl:
"title": str(title), "title": str(title),
"pssh": drm.pssh_b64 or "", "pssh": drm.pssh_b64 or "",
"kids": [k.hex for k in drm.kids], "kids": [k.hex for k in drm.kids],
"track_kid": track_kid.hex if track_kid else None "track_kid": track_kid.hex if track_kid else None,
} },
) )
with self.DRM_TABLE_LOCK: with self.DRM_TABLE_LOCK:
@@ -1816,8 +1839,8 @@ class dl:
"content_key": content_key, "content_key": content_key,
"track": str(track), "track": str(track),
"from_cache": True, "from_cache": True,
"drm_type": "PlayReady" "drm_type": "PlayReady",
} },
) )
elif vaults_only: elif vaults_only:
msg = f"No Vault has a Key for {kid.hex} and --vaults-only was used" msg = f"No Vault has a Key for {kid.hex} and --vaults-only was used"
@@ -1830,7 +1853,7 @@ class dl:
operation="vault_key_not_found", operation="vault_key_not_found",
service=self.service, service=self.service,
message=msg, message=msg,
context={"kid": kid.hex, "track": str(track), "drm_type": "PlayReady"} context={"kid": kid.hex, "track": str(track), "drm_type": "PlayReady"},
) )
raise PlayReady.Exceptions.CEKNotFound(msg) raise PlayReady.Exceptions.CEKNotFound(msg)
else: else:
@@ -1860,8 +1883,8 @@ class dl:
context={ context={
"track": str(track), "track": str(track),
"exception_type": type(e).__name__, "exception_type": type(e).__name__,
"drm_type": "PlayReady" "drm_type": "PlayReady",
} },
) )
raise e raise e
@@ -1937,7 +1960,7 @@ class dl:
@staticmethod @staticmethod
def save_cookies(path: Path, cookies: CookieJar): def save_cookies(path: Path, cookies: CookieJar):
if hasattr(cookies, 'jar'): if hasattr(cookies, "jar"):
cookies = cookies.jar cookies = cookies.jar
cookie_jar = MozillaCookieJar(path) cookie_jar = MozillaCookieJar(path)
@@ -2084,12 +2107,12 @@ class dl:
else: else:
return RemoteCdm( return RemoteCdm(
device_type=cdm_api['Device Type'], device_type=cdm_api["Device Type"],
system_id=cdm_api['System ID'], system_id=cdm_api["System ID"],
security_level=cdm_api['Security Level'], security_level=cdm_api["Security Level"],
host=cdm_api['Host'], host=cdm_api["Host"],
secret=cdm_api['Secret'], secret=cdm_api["Secret"],
device_name=cdm_api['Device Name'], device_name=cdm_api["Device Name"],
) )
prd_path = config.directories.prds / f"{cdm_name}.prd" prd_path = config.directories.prds / f"{cdm_name}.prd"