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,
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(
"-l",
"--lang",
@@ -322,11 +329,7 @@ class dl:
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"))
)
init_debug_logger(
log_path=debug_log_path,
enabled=True,
log_keys=config.debug_keys
)
init_debug_logger(log_path=debug_log_path, enabled=True, log_keys=config.debug_keys)
self.debug_logger = get_debug_logger()
if self.debug_logger:
@@ -342,8 +345,12 @@ class dl:
"tmdb_id": tmdb_id,
"tmdb_name": tmdb_name,
"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:
self.debug_logger = None
@@ -361,7 +368,7 @@ class dl:
level="DEBUG",
operation="load_service_config",
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:
self.service_config = {}
@@ -438,19 +445,25 @@ class dl:
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"]:
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:
self.log.info(
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:
self.debug_logger.log(
level="INFO",
operation="load_cdm",
service=self.service,
context={"cdm": cdm_info}
level="INFO", operation="load_cdm", service=self.service, context={"cdm": cdm_info}
)
self.proxy_providers = []
@@ -526,6 +539,7 @@ class dl:
channels: float,
no_atmos: bool,
wanted: list[str],
latest_episode: bool,
lang: list[str],
v_lang: list[str],
a_lang: list[str],
@@ -587,8 +601,8 @@ class dl:
context={
"cdm_only": cdm_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"):
@@ -606,16 +620,13 @@ class dl:
context={
"has_cookies": bool(cookies),
"has_credentials": bool(credential),
"profile": self.profile
}
"profile": self.profile,
},
)
except Exception as e:
if self.debug_logger:
self.debug_logger.log_error(
"authenticate",
e,
service=self.service,
context={"profile": self.profile}
"authenticate", e, service=self.service, context={"profile": self.profile}
)
raise
@@ -630,31 +641,24 @@ class dl:
operation="get_titles",
service=self.service,
message="No titles returned from service",
success=False
success=False,
)
sys.exit(1)
except Exception as e:
if self.debug_logger:
self.debug_logger.log_error(
"get_titles",
e,
service=self.service
)
self.debug_logger.log_error("get_titles", e, service=self.service)
raise
if self.debug_logger:
titles_info = {
"type": titles.__class__.__name__,
"count": len(titles) if hasattr(titles, "__len__") else 1,
"title": str(titles)
"title": str(titles),
}
if hasattr(titles, "seasons"):
titles_info["seasons"] = len(titles.seasons) if hasattr(titles, "seasons") else 0
self.debug_logger.log(
level="INFO",
operation="get_titles",
service=self.service,
context={"titles": titles_info}
level="INFO", operation="get_titles", service=self.service, context={"titles": titles_info}
)
if self.tmdb_year and self.tmdb_id:
@@ -674,8 +678,21 @@ class dl:
if list_titles:
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):
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
console.print(Padding(Rule(f"[rule.text]{title}"), (1, 2)))
@@ -750,10 +767,7 @@ class dl:
except Exception as e:
if self.debug_logger:
self.debug_logger.log_error(
"get_tracks",
e,
service=self.service,
context={"title": str(title)}
"get_tracks", e, service=self.service, context={"title": str(title)}
)
raise
@@ -764,34 +778,40 @@ class dl:
"audio_tracks": len(title.tracks.audio),
"subtitle_tracks": len(title.tracks.subtitles),
"has_chapters": bool(title.tracks.chapters),
"videos": [{
"codec": str(v.codec),
"resolution": f"{v.width}x{v.height}" if v.width and v.height else "unknown",
"bitrate": v.bitrate,
"range": str(v.range),
"language": str(v.language) if v.language else None,
"drm": [str(type(d).__name__) for d in v.drm] if v.drm else []
} for v in title.tracks.videos],
"audio": [{
"codec": str(a.codec),
"bitrate": a.bitrate,
"channels": a.channels,
"language": str(a.language) if a.language else None,
"descriptive": a.descriptive,
"drm": [str(type(d).__name__) for d in a.drm] if a.drm else []
} for a in title.tracks.audio],
"subtitles": [{
"codec": str(s.codec),
"language": str(s.language) if s.language else None,
"forced": s.forced,
"sdh": s.sdh
} for s in title.tracks.subtitles]
"videos": [
{
"codec": str(v.codec),
"resolution": f"{v.width}x{v.height}" if v.width and v.height else "unknown",
"bitrate": v.bitrate,
"range": str(v.range),
"language": str(v.language) if v.language else None,
"drm": [str(type(d).__name__) for d in v.drm] if v.drm else [],
}
for v in title.tracks.videos
],
"audio": [
{
"codec": str(a.codec),
"bitrate": a.bitrate,
"channels": a.channels,
"language": str(a.language) if a.language else None,
"descriptive": a.descriptive,
"drm": [str(type(d).__name__) for d in a.drm] if a.drm else [],
}
for a in title.tracks.audio
],
"subtitles": [
{
"codec": str(s.codec),
"language": str(s.language) if s.language else None,
"forced": s.forced,
"sdh": s.sdh,
}
for s in title.tracks.subtitles
],
}
self.debug_logger.log(
level="INFO",
operation="get_tracks",
service=self.service,
context=tracks_info
level="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
@@ -1185,7 +1205,7 @@ class dl:
operation="download_tracks",
service=self.service,
message="Download cancelled by user",
context={"title": str(title)}
context={"title": str(title)},
)
return
except Exception as e: # noqa
@@ -1219,8 +1239,8 @@ class dl:
"title": str(title),
"error_type": type(e).__name__,
"tracks_count": len(title.tracks),
"returncode": getattr(e, "returncode", None)
}
"returncode": getattr(e, "returncode", None),
},
)
return
@@ -1475,9 +1495,13 @@ class dl:
if not no_folder and isinstance(title, (Episode, Song)):
# Create folder based on title
# Use first available track for filename generation
sample_track = title.tracks.videos[0] if title.tracks.videos else (
title.tracks.audio[0] if title.tracks.audio else (
title.tracks.subtitles[0] if title.tracks.subtitles else None
sample_track = (
title.tracks.videos[0]
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:
@@ -1498,7 +1522,9 @@ class dl:
track_suffix = f".{track.codec.name if hasattr(track.codec, 'name') else 'video'}"
elif isinstance(track, Audio):
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):
lang_suffix = f".{track.language}" if track.language else ""
forced_suffix = ".forced" if track.forced else ""
@@ -1595,8 +1621,8 @@ class dl:
"title": str(title),
"pssh": drm.pssh.dumps() if drm.pssh else None,
"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:
@@ -1637,8 +1663,8 @@ class dl:
"kid": kid.hex,
"content_key": content_key,
"track": str(track),
"from_cache": True
}
"from_cache": True,
},
)
elif vaults_only:
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",
service=self.service,
message=msg,
context={"kid": kid.hex, "track": str(track)}
context={"kid": kid.hex, "track": str(track)},
)
raise Widevine.Exceptions.CEKNotFound(msg)
else:
@@ -1671,8 +1697,8 @@ class dl:
message="Requesting Widevine license from service",
context={
"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:
@@ -1693,10 +1719,7 @@ class dl:
"get_license",
e,
service=self.service,
context={
"track": str(track),
"exception_type": type(e).__name__
}
context={"track": str(track), "exception_type": type(e).__name__},
)
raise e
@@ -1708,8 +1731,8 @@ class dl:
context={
"track": str(track),
"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():
@@ -1767,8 +1790,8 @@ class dl:
"title": str(title),
"pssh": drm.pssh_b64 or "",
"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:
@@ -1816,8 +1839,8 @@ class dl:
"content_key": content_key,
"track": str(track),
"from_cache": True,
"drm_type": "PlayReady"
}
"drm_type": "PlayReady",
},
)
elif vaults_only:
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",
service=self.service,
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)
else:
@@ -1860,8 +1883,8 @@ class dl:
context={
"track": str(track),
"exception_type": type(e).__name__,
"drm_type": "PlayReady"
}
"drm_type": "PlayReady",
},
)
raise e
@@ -1937,7 +1960,7 @@ class dl:
@staticmethod
def save_cookies(path: Path, cookies: CookieJar):
if hasattr(cookies, 'jar'):
if hasattr(cookies, "jar"):
cookies = cookies.jar
cookie_jar = MozillaCookieJar(path)
@@ -2084,12 +2107,12 @@ class dl:
else:
return RemoteCdm(
device_type=cdm_api['Device Type'],
system_id=cdm_api['System ID'],
security_level=cdm_api['Security Level'],
host=cdm_api['Host'],
secret=cdm_api['Secret'],
device_name=cdm_api['Device Name'],
device_type=cdm_api["Device Type"],
system_id=cdm_api["System ID"],
security_level=cdm_api["Security Level"],
host=cdm_api["Host"],
secret=cdm_api["Secret"],
device_name=cdm_api["Device Name"],
)
prd_path = config.directories.prds / f"{cdm_name}.prd"