12 Commits

Author SHA1 Message Date
Andy
79e8184474 ci: enable manual triggering of release workflow 2026-03-17 09:10:50 -06:00
Andy
178eed9236 ci: add GitHub Actions release workflow for major/minor versions 2026-03-17 09:08:20 -06:00
Andy
63d2ba60c4 chore(changelog): tag v4.0.0 release 2026-03-17 08:57:34 -06:00
Andy
f46aa9d8c8 chore(changelog): update changelog for upcoming release and reorganize sections 2026-03-17 08:55:14 -06:00
Andy
b1447eb14b fix(dl): filter CC subtitle languages with --s-lang and extract all manifest CCs
Fixes issues introduced in 15acaea where CC extraction only used the first manifest entry and ignored --s-lang filtering entirely. Now all CC languages from the HLS manifest are iterated and filtered against --s-lang using the same match logic as regular subtitle selection.
2026-03-16 14:09:05 -06:00
Andy
e02aa66843 feat(dl): add --worst flag and SHIELD OkHttp fingerprint preset
Add --worst CLI flag to select the lowest bitrate video track within a specified resolution (e.g. --worst -q 720). Requires -q/--quality.
Add shield_okhttp TLS fingerprint preset for NVIDIA SHIELD Android TV with OkHttp 4.11 JA3 signature.
2026-03-11 13:59:07 -06:00
Sp5rky
c82bb5fe34 Merge pull request #88 from CodeName393/fix-aria2c-progress-bar
fix(aria2c): Correct progress bar tracking for HLS downloads
2026-03-07 20:21:25 -07:00
Andy
ec2ecfe7b4 fix(ism): prevent duplicate track IDs for audio tracks with same lang/codec/bitrate
Include StreamIndex Name and Url attributes in the track ID hash to disambiguate tracks that share the same codec, language, bitrate, and QualityLevel index.
2026-03-07 13:01:36 -07:00
Andy
15acaea208 feat(dl): extract closed captions from HLS manifests and improve CC extraction
- Parse CLOSED-CAPTIONS entries from HLS manifests and attach CC metadata (language, name, instream_id) to video tracks
- Move CC extraction to run after decryption instead of before, fixing extraction failures on encrypted streams
- Extract CCs even when other subtitle tracks exist, using manifest CC language info instead of guessing
- Try ccextractor on the original file before repacking to preserve container-level CC data (e.g. c608 boxes) that ffmpeg remux strips
- Display deduplicated closed captions in --list output and download progress, positioned after subtitles
- Add closed_captions field to Video track class
2026-03-05 15:57:29 -07:00
CodeName393
def18a4c44 fix(aria2c): Correct progress bar tracking for HLS downloads
Modified the download generator in aria2c to track progress by the number of completed segments (len(completed)) when downloading multiple files. Single-file downloads remain byte-based.
2026-03-05 14:43:24 +09:00
Sp5rky
7dd6323be5 Merge pull request #87 from CodeName393/add-HDR-Vivid-TAG
fix(title): Add HDR Vivid Format HDR Tag
2026-03-04 15:38:03 -07:00
CodeName393
d68bb28a66 fix(title): Add HDR Vivid Format HDR Tag
The existing HDR Vivid format HDR tag processing is missing due to the feature of the title map.
2026-03-04 23:17:18 +09:00
10 changed files with 352 additions and 147 deletions

91
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,91 @@
name: Release
on:
workflow_dispatch:
push:
branches: [main]
paths:
- "pyproject.toml"
permissions:
contents: write
jobs:
check-version:
runs-on: ubuntu-latest
outputs:
should_release: ${{ steps.version_check.outputs.should_release }}
new_version: ${{ steps.version_check.outputs.new_version }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check for major/minor version bump
id: version_check
run: |
NEW_VERSION=$(grep -m1 '^version' pyproject.toml | sed 's/version = "\(.*\)"/\1/')
echo "Detected version in pyproject.toml: $NEW_VERSION"
LATEST_TAG=$(git tag --list | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1)
echo "Latest git tag: $LATEST_TAG"
if [ -z "$LATEST_TAG" ]; then
echo "No previous tag found, treating as new release"
echo "should_release=true" >> "$GITHUB_OUTPUT"
echo "new_version=$NEW_VERSION" >> "$GITHUB_OUTPUT"
exit 0
fi
OLD_MAJOR=$(echo "$LATEST_TAG" | cut -d. -f1)
OLD_MINOR=$(echo "$LATEST_TAG" | cut -d. -f2)
NEW_MAJOR=$(echo "$NEW_VERSION" | cut -d. -f1)
NEW_MINOR=$(echo "$NEW_VERSION" | cut -d. -f2)
if [ "$NEW_MAJOR" -gt "$OLD_MAJOR" ] || [ "$NEW_MINOR" -gt "$OLD_MINOR" ]; then
echo "Major or minor version bump detected: $LATEST_TAG -> $NEW_VERSION"
echo "should_release=true" >> "$GITHUB_OUTPUT"
else
echo "Patch-only change ($LATEST_TAG -> $NEW_VERSION), skipping release"
echo "should_release=false" >> "$GITHUB_OUTPUT"
fi
echo "new_version=$NEW_VERSION" >> "$GITHUB_OUTPUT"
release:
needs: check-version
if: needs.check-version.outputs.should_release == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v4
- name: Set up Python
run: uv python install 3.12
- name: Install dependencies and build
run: |
uv sync
uv build
- name: Extract changelog for release
id: changelog
run: |
VERSION=${{ needs.check-version.outputs.new_version }}
# Extract the section for this version from CHANGELOG.md
awk "/^## \[$VERSION\]/{found=1; next} /^## \[/{if(found) exit} found{print}" CHANGELOG.md > release_notes.md
echo "Extracted release notes:"
cat release_notes.md
- name: Create GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION=${{ needs.check-version.outputs.new_version }}
gh release create "$VERSION" \
--title "$VERSION" \
--notes-file release_notes.md \
dist/unshackle-${VERSION}-py3-none-any.whl \
dist/unshackle-${VERSION}.tar.gz

View File

@@ -6,7 +6,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
This changelog is automatically generated using [git-cliff](https://git-cliff.org). This changelog is automatically generated using [git-cliff](https://git-cliff.org).
## [Unreleased] ## [4.0.0] - 2026-03-17
### Features ### Features
@@ -19,6 +19,11 @@ This changelog is automatically generated using [git-cliff](https://git-cliff.or
- *tracks*: Add edition tags to output filenames - *tracks*: Add edition tags to output filenames
- *templates*: [**breaking**] Add customizable output filename templates - *templates*: [**breaking**] Add customizable output filename templates
- *templates*: Add configurable language tagging rule engine - *templates*: Add configurable language tagging rule engine
- Update unshackle version to 4.0.0
- *dl*: Add --animeapi and --enrich options for anime metadata and tagging
- *dl*: Add skip messages for --no-audio and --no-chapters flags
- *dl*: Extract closed captions from HLS manifests and improve CC extraction
- *dl*: Add --worst flag and SHIELD OkHttp fingerprint preset
### Bug Fixes ### Bug Fixes
@@ -33,6 +38,13 @@ This changelog is automatically generated using [git-cliff](https://git-cliff.or
- *n_m3u8dl_re*: Disable segment count validation for duration-based DASH - *n_m3u8dl_re*: Disable segment count validation for duration-based DASH
- Correct formatting and add missing newlines in selector and EXAMPLE service - Correct formatting and add missing newlines in selector and EXAMPLE service
- *dependencies*: Update pyplayready version to 0.8.3 and adjust dependencies - *dependencies*: Update pyplayready version to 0.8.3 and adjust dependencies
- *drm*: Update PlayReady KID extraction for pyplayready 0.8.3 compatibility
- *api*: Resolve Sentinel serialization, missing params, and add search endpoint
- *dash*: Pass period_filter to n_m3u8dl_re via filtered MPD file
- *title*: Add HDR Vivid Format HDR Tag
- *ism*: Prevent duplicate track IDs for audio tracks with same lang/codec/bitrate
- *aria2c*: Correct progress bar tracking for HLS downloads
- *dl*: Filter CC subtitle languages with --s-lang and extract all manifest CCs
### Documentation ### Documentation
@@ -45,10 +57,6 @@ This changelog is automatically generated using [git-cliff](https://git-cliff.or
- *example*: Migrate EXAMPLE service to track_request pattern - *example*: Migrate EXAMPLE service to track_request pattern
- *providers*: Extract metadata providers into modular system - *providers*: Extract metadata providers into modular system
### Maintenance
- *changelog*: Update changelog for upcoming release and reorganize sections
## [3.0.0] - 2026-02-15 ## [3.0.0] - 2026-02-15
### Features ### Features

View File

@@ -25,6 +25,7 @@ import click
import jsonpickle import jsonpickle
import yaml import yaml
from construct import ConstError from construct import ConstError
from langcodes import Language
from pymediainfo import MediaInfo from pymediainfo import MediaInfo
from pyplayready.cdm import Cdm as PlayReadyCdm from pyplayready.cdm import Cdm as PlayReadyCdm
from pyplayready.device import Device as PlayReadyDevice from pyplayready.device import Device as PlayReadyDevice
@@ -61,7 +62,7 @@ from unshackle.core.tracks import Audio, Subtitle, Tracks, Video
from unshackle.core.tracks.attachment import Attachment from unshackle.core.tracks.attachment import Attachment
from unshackle.core.tracks.hybrid import Hybrid from unshackle.core.tracks.hybrid import Hybrid
from unshackle.core.utilities import (find_font_with_fallbacks, get_debug_logger, get_system_fonts, init_debug_logger, from unshackle.core.utilities import (find_font_with_fallbacks, get_debug_logger, get_system_fonts, init_debug_logger,
is_close_match, suggest_font_packages, time_elapsed_since) is_close_match, is_exact_match, suggest_font_packages, time_elapsed_since)
from unshackle.core.utils import tags from unshackle.core.utils import tags
from unshackle.core.utils.click_types import (AUDIO_CODEC_LIST, LANGUAGE_RANGE, QUALITY_LIST, SEASON_RANGE, from unshackle.core.utils.click_types import (AUDIO_CODEC_LIST, LANGUAGE_RANGE, QUALITY_LIST, SEASON_RANGE,
ContextData, MultipleChoice, MultipleVideoCodecChoice, ContextData, MultipleChoice, MultipleVideoCodecChoice,
@@ -505,6 +506,12 @@ class dl:
@click.option( @click.option(
"--reset-cache", "reset_cache", is_flag=True, default=False, help="Clear title cache before fetching." "--reset-cache", "reset_cache", is_flag=True, default=False, help="Clear title cache before fetching."
) )
@click.option(
"--worst",
is_flag=True,
default=False,
help="Select the lowest bitrate track within the specified quality. Requires -q/--quality.",
)
@click.option( @click.option(
"--best-available", "--best-available",
"best_available", "best_available",
@@ -990,6 +997,7 @@ class dl:
no_mux: bool, no_mux: bool,
workers: Optional[int], workers: Optional[int],
downloads: int, downloads: int,
worst: bool,
best_available: bool, best_available: bool,
split_audio: Optional[bool] = None, split_audio: Optional[bool] = None,
*_: Any, *_: Any,
@@ -1015,6 +1023,10 @@ class dl:
self.log.error("--require-subs and --s-lang cannot be used together") self.log.error("--require-subs and --s-lang cannot be used together")
sys.exit(1) sys.exit(1)
if worst and not quality:
self.log.error("--worst requires -q/--quality to be specified")
sys.exit(1)
if select_titles and wanted: if select_titles and wanted:
self.log.error("--select-titles and -w/--wanted cannot be used together") self.log.error("--select-titles and -w/--wanted cannot be used together")
sys.exit(1) sys.exit(1)
@@ -1608,8 +1620,7 @@ class dl:
for resolution, color_range, codec in product( for resolution, color_range, codec in product(
quality or [None], non_hybrid_ranges, vcodec or [None] quality or [None], non_hybrid_ranges, vcodec or [None]
): ):
match = next( candidates = [
(
t t
for t in non_hybrid_tracks for t in non_hybrid_tracks
if ( if (
@@ -1619,9 +1630,8 @@ class dl:
) )
and (not color_range or t.range == color_range) and (not color_range or t.range == color_range)
and (not codec or t.codec == codec) and (not codec or t.codec == codec)
), ]
None, match = candidates[-1] if worst and candidates else next(iter(candidates), None)
)
if match and match not in non_hybrid_selected: if match and match not in non_hybrid_selected:
non_hybrid_selected.append(match) non_hybrid_selected.append(match)
@@ -1631,8 +1641,7 @@ class dl:
for resolution, color_range, codec in product( for resolution, color_range, codec in product(
quality or [None], range_ or [None], vcodec or [None] quality or [None], range_ or [None], vcodec or [None]
): ):
match = next( candidates = [
(
t t
for t in title.tracks.videos for t in title.tracks.videos
if ( if (
@@ -1642,9 +1651,8 @@ class dl:
) )
and (not color_range or t.range == color_range) and (not color_range or t.range == color_range)
and (not codec or t.codec == codec) and (not codec or t.codec == codec)
), ]
None, match = candidates[-1] if worst and candidates else next(iter(candidates), None)
)
if match and match not in selected_videos: if match and match not in selected_videos:
selected_videos.append(match) selected_videos.append(match)
title.tracks.videos = selected_videos title.tracks.videos = selected_videos
@@ -1704,8 +1712,6 @@ class dl:
f"Required languages found ({', '.join(require_subs)}), downloading all available subtitles" f"Required languages found ({', '.join(require_subs)}), downloading all available subtitles"
) )
elif s_lang and "all" not in s_lang: elif s_lang and "all" not in s_lang:
from unshackle.core.utilities import is_exact_match
match_func = is_exact_match if exact_lang else is_close_match match_func = is_exact_match if exact_lang else is_close_match
missing_langs = [ missing_langs = [
@@ -2025,49 +2031,6 @@ class dl:
dl_time = time_elapsed_since(dl_start_time) dl_time = time_elapsed_since(dl_start_time)
console.print(Padding(f"Track downloads finished in [progress.elapsed]{dl_time}[/]", (0, 5))) console.print(Padding(f"Track downloads finished in [progress.elapsed]{dl_time}[/]", (0, 5)))
video_track_n = 0
while (
not title.tracks.subtitles
and not no_subs
and not (hasattr(service, "NO_SUBTITLES") and service.NO_SUBTITLES)
and not video_only
and not no_video
and len(title.tracks.videos) > video_track_n
and any(
x.get("codec_name", "").startswith("eia_")
for x in ffprobe(title.tracks.videos[video_track_n].path).get("streams", [])
)
):
with console.status(f"Checking Video track {video_track_n + 1} for Closed Captions..."):
try:
# TODO: Figure out the real language, it might be different
# EIA-CC tracks sadly don't carry language information :(
# TODO: Figure out if the CC language is original lang or not.
# Will need to figure out above first to do so.
video_track = title.tracks.videos[video_track_n]
track_id = f"ccextractor-{video_track.id}"
cc_lang = title.language or video_track.language
cc = video_track.ccextractor(
track_id=track_id,
out_path=config.directories.temp
/ config.filenames.subtitle.format(id=track_id, language=cc_lang),
language=cc_lang,
original=False,
)
if cc:
# will not appear in track listings as it's added after all times it lists
title.tracks.add(cc)
self.log.info(f"Extracted a Closed Caption from Video track {video_track_n + 1}")
else:
self.log.info(f"No Closed Captions were found in Video track {video_track_n + 1}")
except EnvironmentError:
self.log.error(
"Cannot extract Closed Captions as the ccextractor executable was not found..."
)
break
video_track_n += 1
# Subtitle output mode configuration (for sidecar originals) # Subtitle output mode configuration (for sidecar originals)
subtitle_output_mode = config.subtitle.get("output_mode", "mux") subtitle_output_mode = config.subtitle.get("output_mode", "mux")
sidecar_format = config.subtitle.get("sidecar_format", "srt") sidecar_format = config.subtitle.get("sidecar_format", "srt")
@@ -2133,6 +2096,75 @@ class dl:
if has_decrypted: if has_decrypted:
self.log.info(f"Decrypted tracks with {decrypt_tool}") self.log.info(f"Decrypted tracks with {decrypt_tool}")
# Extract Closed Captions from decrypted video tracks
if (
not no_subs
and not (hasattr(service, "NO_SUBTITLES") and service.NO_SUBTITLES)
and not video_only
and not no_video
):
match_func = is_exact_match if exact_lang else is_close_match
for video_track_n, video_track in enumerate(title.tracks.videos):
has_manifest_cc = bool(getattr(video_track, "closed_captions", None))
has_eia_cc = (
not has_manifest_cc
and not title.tracks.subtitles
and any(
x.get("codec_name", "").startswith("eia_")
for x in ffprobe(video_track.path).get("streams", [])
)
)
if not has_manifest_cc and not has_eia_cc:
continue
# Build list of CC entries to extract
if has_manifest_cc:
cc_entries = video_track.closed_captions
# Filter CC languages against --s-lang if specified
if s_lang and "all" not in s_lang:
cc_entries = [
entry for entry in cc_entries
if entry.get("language")
and match_func(Language.get(entry["language"]), s_lang)
]
if not cc_entries:
continue
else:
# EIA fallback: single entry with unknown language
cc_entries = [{}]
with console.status(f"Checking Video track {video_track_n + 1} for Closed Captions..."):
try:
for cc_idx, cc_entry in enumerate(cc_entries):
cc_lang = (
Language.get(cc_entry["language"])
if cc_entry.get("language")
else title.language or video_track.language
)
track_id = f"ccextractor-{video_track.id}-{cc_idx}"
cc = video_track.ccextractor(
track_id=track_id,
out_path=config.directories.temp
/ config.filenames.subtitle.format(id=track_id, language=cc_lang),
language=cc_lang,
original=False,
)
if cc:
cc.cc = True
title.tracks.add(cc)
self.log.info(
f"Extracted a Closed Caption ({cc_lang}) from Video track {video_track_n + 1}"
)
else:
self.log.info(
f"No Closed Captions were found in Video track {video_track_n + 1}"
)
except EnvironmentError:
self.log.error(
"Cannot extract Closed Captions as the ccextractor executable was not found..."
)
break
# Now repack the decrypted tracks # Now repack the decrypted tracks
with console.status("Repackaging tracks with FFMPEG..."): with console.status("Repackaging tracks with FFMPEG..."):
has_repacked = False has_repacked = False

View File

@@ -431,14 +431,24 @@ def download(
raise ValueError(error) raise ValueError(error)
# Yield aggregate progress for this call's downloads # Yield aggregate progress for this call's downloads
if total_size > 0: progress_data = {"advance": 0}
# Yield both advance (bytes downloaded this iteration) and total for rich progress
if dl_speed != -1: if len(gids) > 1:
yield dict(downloaded=f"{filesize.decimal(dl_speed)}/s", advance=0, completed=total_completed, total=total_size) # Multi-file mode (e.g., HLS): Return the count of completed segments
progress_data["completed"] = len(completed)
progress_data["total"] = len(gids)
else: else:
yield dict(advance=0, completed=total_completed, total=total_size) # Single-file mode: Return the total bytes downloaded
elif dl_speed != -1: progress_data["completed"] = total_completed
yield dict(downloaded=f"{filesize.decimal(dl_speed)}/s") if total_size > 0:
progress_data["total"] = total_size
else:
progress_data["total"] = None
if dl_speed != -1:
progress_data["downloaded"] = f"{filesize.decimal(dl_speed)}/s"
yield progress_data
time.sleep(1) time.sleep(1)
except KeyboardInterrupt: except KeyboardInterrupt:

View File

@@ -112,6 +112,15 @@ class HLS:
session_drm = HLS.get_all_drm(session_keys) session_drm = HLS.get_all_drm(session_keys)
audio_codecs_by_group_id: dict[str, Audio.Codec] = {} audio_codecs_by_group_id: dict[str, Audio.Codec] = {}
cc_by_group_id: dict[str, list[dict[str, Any]]] = {}
for media in self.manifest.media:
if media.type == "CLOSED-CAPTIONS":
cc_by_group_id.setdefault(media.group_id, []).append({
"language": media.language,
"name": media.name,
"instream_id": media.instream_id,
"characteristics": media.characteristics,
})
tracks = Tracks() tracks = Tracks()
for playlist in self.manifest.playlists: for playlist in self.manifest.playlists:
@@ -161,6 +170,9 @@ class HLS:
width=playlist.stream_info.resolution[0] if playlist.stream_info.resolution else None, width=playlist.stream_info.resolution[0] if playlist.stream_info.resolution else None,
height=playlist.stream_info.resolution[1] if playlist.stream_info.resolution else None, height=playlist.stream_info.resolution[1] if playlist.stream_info.resolution else None,
fps=playlist.stream_info.frame_rate, fps=playlist.stream_info.frame_rate,
closed_captions=cc_by_group_id.get(
(playlist.stream_info.closed_captions or "").strip('"'), []
),
) )
if primary_track_type is Video if primary_track_type is Video
else {} else {}

View File

@@ -145,7 +145,14 @@ class ISM:
fragment_time += duration_frag fragment_time += duration_frag
track_id = hashlib.md5( track_id = hashlib.md5(
f"{codec}-{track_lang}-{ql.get('Bitrate') or 0}-{ql.get('Index') or 0}".encode() "{codec}-{lang}-{bitrate}-{index}-{name}-{url}".format(
codec=codec,
lang=track_lang,
bitrate=ql.get("Bitrate") or 0,
index=ql.get("Index") or 0,
name=stream_index.get("Name") or "",
url=stream_index.get("Url") or "",
).encode()
).hexdigest() ).hexdigest()
data = { data = {

View File

@@ -44,6 +44,17 @@ FINGERPRINT_PRESETS = {
"akamai": "4:16777216|16711681|0|m,p,a,s", "akamai": "4:16777216|16711681|0|m,p,a,s",
"description": "OkHttp 5.x (BoringSSL TLS stack)", "description": "OkHttp 5.x (BoringSSL TLS stack)",
}, },
"shield_okhttp": {
"ja3": (
"771," # TLS 1.2
"4865-4866-4867-49195-49199-49196-49200-52393-52392-49171-49172-156-157-47-53," # Ciphers (OkHttp 4.11)
"0-23-65281-10-11-35-16-5-13-51-45-43-21," # Extensions (incl padding ext 21)
"29-23-24," # Named groups (x25519, secp256r1, secp384r1)
"0" # EC point formats
),
"akamai": "4:16777216|16711681|0|m,p,a,s",
"description": "NVIDIA SHIELD Android TV OkHttp 4.11 (captured JA3)",
},
} }

View File

@@ -121,6 +121,8 @@ class Title:
base_layer = DYNAMIC_RANGE_MAP.get(hdr_format) base_layer = DYNAMIC_RANGE_MAP.get(hdr_format)
if base_layer and base_layer != "DV": if base_layer and base_layer != "DV":
context["hdr"] += f".{base_layer}" context["hdr"] += f".{base_layer}"
elif (primary_video_track.hdr_format or "").startswith("HDR Vivid"):
context["hdr"] = "HDR"
else: else:
context["hdr"] = DYNAMIC_RANGE_MAP.get(hdr_format, "") context["hdr"] = DYNAMIC_RANGE_MAP.get(hdr_format, "")
elif trc and "HLG" in trc: elif trc and "HLG" in trc:

View File

@@ -103,8 +103,7 @@ class Tracks:
tree = Tree("", hide_root=True) tree = Tree("", hide_root=True)
for track_type in self.TRACK_ORDER_MAP: for track_type in self.TRACK_ORDER_MAP:
tracks = list(x for x in all_tracks if isinstance(x, track_type)) tracks = list(x for x in all_tracks if isinstance(x, track_type))
if not tracks: if tracks:
continue
num_tracks = len(tracks) num_tracks = len(tracks)
track_type_plural = track_type.__name__ + ("s" if track_type != Audio and num_tracks != 1 else "") track_type_plural = track_type.__name__ + ("s" if track_type != Audio and num_tracks != 1 else "")
tracks_tree = tree.add(f"[repr.number]{num_tracks}[/] {track_type_plural}") tracks_tree = tree.add(f"[repr.number]{num_tracks}[/] {track_type_plural}")
@@ -151,6 +150,32 @@ class Tracks:
else: else:
tracks_tree.add(str(track)[6:], style="text2") tracks_tree.add(str(track)[6:], style="text2")
# Show Closed Captions right after Subtitles (even if no subtitle tracks exist)
if track_type is Subtitle:
seen_cc: set[str] = set()
unique_cc: list[str] = []
for video in (x for x in all_tracks if isinstance(x, Video)):
for cc in getattr(video, "closed_captions", []):
lang = cc.get("language", "und")
name = cc.get("name", "")
instream_id = cc.get("instream_id", "")
key = f"{lang}|{instream_id}"
if key in seen_cc:
continue
seen_cc.add(key)
parts = [f"[CC] | {lang}"]
if name:
parts.append(name)
if instream_id:
parts.append(instream_id)
unique_cc.append(" | ".join(parts))
if unique_cc:
cc_tree = tree.add(
f"[repr.number]{len(unique_cc)}[/] Closed Caption{'s' if len(unique_cc) != 1 else ''}"
)
for cc_str in unique_cc:
cc_tree.add(cc_str, style="text2")
return tree, progress_callables return tree, progress_callables
def exists(self, by_id: Optional[str] = None, by_url: Optional[Union[str, list[str]]] = None) -> bool: def exists(self, by_id: Optional[str] = None, by_url: Optional[Union[str, list[str]]] = None) -> bool:

View File

@@ -200,6 +200,7 @@ class Video(Track):
height: Optional[int] = None, height: Optional[int] = None,
fps: Optional[Union[str, int, float]] = None, fps: Optional[Union[str, int, float]] = None,
scan_type: Optional[Video.ScanType] = None, scan_type: Optional[Video.ScanType] = None,
closed_captions: Optional[list[dict[str, Any]]] = None,
**kwargs: Any, **kwargs: Any,
) -> None: ) -> None:
""" """
@@ -264,6 +265,7 @@ class Video(Track):
raise ValueError("Expected fps to be a number, float, or a string as numerator/denominator form, " + str(e)) raise ValueError("Expected fps to be a number, float, or a string as numerator/denominator form, " + str(e))
self.scan_type = scan_type self.scan_type = scan_type
self.closed_captions: list[dict[str, Any]] = closed_captions or []
self.needs_duration_fix = False self.needs_duration_fix = False
def __str__(self) -> str: def __str__(self) -> str:
@@ -346,11 +348,9 @@ class Video(Track):
if not binaries.CCExtractor: if not binaries.CCExtractor:
raise EnvironmentError("ccextractor executable was not found.") raise EnvironmentError("ccextractor executable was not found.")
# ccextractor often fails in weird ways unless we repack
self.repackage()
out_path = Path(out_path) out_path = Path(out_path)
def _run_ccextractor() -> bool:
try: try:
subprocess.run( subprocess.run(
[binaries.CCExtractor, "-trim", "-nobom", "-noru", "-ru1", "-o", out_path, self.path], [binaries.CCExtractor, "-trim", "-nobom", "-noru", "-ru1", "-o", out_path, self.path],
@@ -360,8 +360,15 @@ class Video(Track):
) )
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
out_path.unlink(missing_ok=True) out_path.unlink(missing_ok=True)
if not e.returncode == 10: # No captions found if e.returncode != 10: # 10 = No captions found
raise raise
return out_path.exists()
# Try on the original file first (preserves container-level CC data like c608 boxes),
# then fall back to repacked file (ccextractor can fail on some container formats).
if not _run_ccextractor():
self.repackage()
_run_ccextractor()
if out_path.exists(): if out_path.exists():
cc_track = Subtitle( cc_track = Subtitle(