mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-12 20:15:10 +00:00
feat(tracks): download and mux stereoscopic 3D (MV-HEVC) video to MP4
Mark 3D from REQ-VIDEO-LAYOUT, DV profile 20 codecs or an lhvC box; mux with MP4Box, decrypt with mp4decrypt, skip FFmpeg repack, sample cut and hybrid; add {3d} and env MP4Box.
This commit is contained in:
@@ -339,7 +339,7 @@ When you give both `season` and `episode`, the server combines them into a `"{se
|
|||||||
"bitrate": 8000, "bitrate_bps": 8000000, "width": 3840, "height": 2160,
|
"bitrate": 8000, "bitrate_bps": 8000000, "width": 3840, "height": 2160,
|
||||||
"resolution": "2160p", "fps": "24", "range": "HDR10",
|
"resolution": "2160p", "fps": "24", "range": "HDR10",
|
||||||
"range_display": "HDR", "scan_type": null, "closed_captions": [],
|
"range_display": "HDR", "scan_type": null, "closed_captions": [],
|
||||||
"dv_compatible_bitstream": false, "hdr10plus": false, "hdr10_base": false, "language": "en",
|
"dv_compatible_bitstream": false, "hdr10plus": false, "hdr10_base": false, "stereo": false, "language": "en",
|
||||||
"is_original_lang": true,
|
"is_original_lang": true,
|
||||||
"name": null, "track_name": "English", "needs_repack": false, "edition": [], "note": null, "data": null,
|
"name": null, "track_name": "English", "needs_repack": false, "edition": [], "note": null, "data": null,
|
||||||
"drm": [], "descriptor": "DASH"
|
"drm": [], "descriptor": "DASH"
|
||||||
|
|||||||
@@ -454,6 +454,7 @@ Two DV mechanisms sit on top of the track model. **DV fixup** re-exposes DV sign
|
|||||||
HLS and DASH streams that carry a valid DV RPU but advertise plain HEVC, with DV only in the
|
HLS and DASH streams that carry a valid DV RPU but advertise plain HEVC, with DV only in the
|
||||||
supplemental codecs (`dv_compatible_bitstream`). `--list` shows such a track as `HDR10+DV` or `HDR10P+DV`.
|
supplemental codecs (`dv_compatible_bitstream`). `--list` shows such a track as `HDR10+DV` or `HDR10P+DV`.
|
||||||
A service sets `hdr10_base` on a DV track that has an HDR10 base layer (DV profile 8.1), and `--list` shows it as `DV+HDR10`.
|
A service sets `hdr10_base` on a DV track that has an HDR10 base layer (DV profile 8.1), and `--list` shows it as `DV+HDR10`.
|
||||||
|
The HLS and DASH parsers set `stereo` on a stereoscopic 3D (MV-HEVC) track: an HLS `REQ-VIDEO-LAYOUT` that names `CH-STEREO`, or a DV profile 20 codec. `--list` shows it as `3D`.
|
||||||
**Hybrid** processing (`Video.Range.HYBRID`) selects an HDR10(+) base and a DV donor,
|
**Hybrid** processing (`Video.Range.HYBRID`) selects an HDR10(+) base and a DV donor,
|
||||||
extracts the RPU with `dovi_tool`, and injects it onto the base layer, to make a single
|
extracts the RPU with `dovi_tool`, and injects it onto the base layer, to make a single
|
||||||
track that carries both HDR10 metadata and the DV RPU. Both are automatic once the
|
track that carries both HDR10 metadata and the DV RPU. Both are automatic once the
|
||||||
|
|||||||
@@ -268,7 +268,7 @@ download. For example, `dovi_tool` only matters if you download Dolby Vision vid
|
|||||||
| Tool | Binary looked up | Why it is needed |
|
| Tool | Binary looked up | Why it is needed |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Bento4 (`mp4decrypt`) | `mp4decrypt` | Alternative DRM decryptor to shaka-packager |
|
| Bento4 (`mp4decrypt`) | `mp4decrypt` | Alternative DRM decryptor to shaka-packager |
|
||||||
| MP4Box (GPAC) | `MP4Box` | Muxing DTS:X Profile 2 (DTS-UHD) and MPEG-H audio |
|
| MP4Box (GPAC) | `MP4Box` | Muxing DTS:X Profile 2 (DTS-UHD) and MPEG-H audio, and 3D video |
|
||||||
| dovi_tool | `dovi_tool` | Dolby Vision metadata handling |
|
| dovi_tool | `dovi_tool` | Dolby Vision metadata handling |
|
||||||
| HDR10Plus_tool | `hdr10plus_tool` | HDR10+ metadata handling |
|
| HDR10Plus_tool | `hdr10plus_tool` | HDR10+ metadata handling |
|
||||||
| SubtitleEdit | `seconv`, `SubtitleEdit` | Subtitle conversion (the `SeConv` CLI from 5.x) |
|
| SubtitleEdit | `seconv`, `SubtitleEdit` | Subtitle conversion (the `SeConv` CLI from 5.x) |
|
||||||
@@ -280,9 +280,9 @@ download. For example, `dovi_tool` only matters if you download Dolby Vision vid
|
|||||||
| Docker | `docker` | Gluetun VPN proxy support |
|
| Docker | `docker` | Gluetun VPN proxy support |
|
||||||
| Git | `git` | Fetching and updating remote service repositories |
|
| Git | `git` | Fetching and updating remote service repositories |
|
||||||
|
|
||||||
!!! info "MP4Box is only used for DTS:X Profile 2 and MPEG-H"
|
!!! info "MP4Box is only used for DTS:X Profile 2, MPEG-H and 3D video"
|
||||||
Titles with DTS:X Profile 2 (DTS-UHD) or MPEG-H audio mux to MP4; everything else muxes to MKV
|
Titles with DTS:X Profile 2 (DTS-UHD) or MPEG-H audio, or a stereoscopic 3D video track, mux to
|
||||||
with `mkvmerge`. Install with `apt install gpac`.
|
MP4; everything else muxes to MKV with `mkvmerge`. Install with `apt install gpac`.
|
||||||
|
|
||||||
!!! tip "SubtitleEdit specifics"
|
!!! tip "SubtitleEdit specifics"
|
||||||
unshackle looks for `seconv` first, the batch CLI shipped with
|
unshackle looks for `seconv` first, the batch CLI shipped with
|
||||||
|
|||||||
@@ -164,6 +164,10 @@ unshackle dl -q 2160 -r HDR10,DV EXAMPLE '...'
|
|||||||
|
|
||||||
The output file of a track with a Dolby Vision profile 8 RPU plays as Dolby Vision on a DV screen.
|
The output file of a track with a Dolby Vision profile 8 RPU plays as Dolby Vision on a DV screen.
|
||||||
|
|
||||||
|
`--list` adds `3D` to a stereoscopic 3D (MV-HEVC) track, for example `[H.265, DV, 3D]`. When a title
|
||||||
|
has a 2D and a 3D track at the same resolution, unshackle selects the 2D track. A 3D title muxes to
|
||||||
|
MP4, see [3D output](output-and-naming.md#3d-output).
|
||||||
|
|
||||||
!!! warning "HYBRID requires dovi_tool"
|
!!! warning "HYBRID requires dovi_tool"
|
||||||
`-r HYBRID` makes a single hybrid track by injecting the Dolby Vision RPU
|
`-r HYBRID` makes a single hybrid track by injecting the Dolby Vision RPU
|
||||||
metadata onto an HDR10/HDR10+ base layer with **dovi_tool**.
|
metadata onto an HDR10/HDR10+ base layer with **dovi_tool**.
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ When a download finishes, unshackle builds the final path from three pieces:
|
|||||||
- **Output directory**: `directories.downloads` from your config, or whatever you pass to `-o/--output` on the command line for a single run.
|
- **Output directory**: `directories.downloads` from your config, or whatever you pass to `-o/--output` on the command line for a single run.
|
||||||
- **Folder template**: an optional per-title subfolder. unshackle **always** puts TV episodes and songs in a folder. Movies only get one if you define a `movies` folder template, or a single-string `folder` template that applies to all title kinds (see [Folder templates](#folder-templates)).
|
- **Folder template**: an optional per-title subfolder. unshackle **always** puts TV episodes and songs in a folder. Movies only get one if you define a `movies` folder template, or a single-string `folder` template that applies to all title kinds (see [Folder templates](#folder-templates)).
|
||||||
- **Filename template**: the `output_template` for the title kind (movie, series, or song).
|
- **Filename template**: the `output_template` for the title kind (movie, series, or song).
|
||||||
- **Extension**: chosen by the muxer for a movie or an episode: `.mkv` for video, `.mka` for audio-only, `.mks` for subtitle-only, `.mp4` for [DTS:X Profile 2 and MPEG-H audio](#dtsx-profile-2-and-mpeg-h-output). unshackle does not mux a song. It remuxes a song into the container of its codec, such as `.flac`, `.opus`, or `.m4a`. Read [Music output files](#music-output-files).
|
- **Extension**: chosen by the muxer for a movie or an episode: `.mkv` for video, `.mka` for audio-only, `.mks` for subtitle-only, `.mp4` for [DTS:X Profile 2 and MPEG-H audio](#dtsx-profile-2-and-mpeg-h-output) and [3D video](#3d-output). unshackle does not mux a song. It remuxes a song into the container of its codec, such as `.flac`, `.opus`, or `.m4a`. Read [Music output files](#music-output-files).
|
||||||
|
|
||||||
!!! example "A typical episode path"
|
!!! example "A typical episode path"
|
||||||
```text
|
```text
|
||||||
@@ -110,6 +110,7 @@ Every variable below is valid in both output and folder templates. unshackle tak
|
|||||||
| `video` | Video codec | `H.264`, `H.265` |
|
| `video` | Video codec | `H.264`, `H.265` |
|
||||||
| `hdr` | Dynamic-range label | `HDR`, `HDR10P`, `DV`, `DV.HDR`, `DV.HDR10P`, `HLG` |
|
| `hdr` | Dynamic-range label | `HDR`, `HDR10P`, `DV`, `DV.HDR`, `DV.HDR10P`, `HLG` |
|
||||||
| `hfr` | High frame rate marker (frame rate > 30) | `HFR` |
|
| `hfr` | High frame rate marker (frame rate > 30) | `HFR` |
|
||||||
|
| `3d` | Stereoscopic 3D marker (MV-HEVC video) | `3D` |
|
||||||
| `edition` | Edition label from the track, if any | `Directors Cut` |
|
| `edition` | Edition label from the track, if any | `Directors Cut` |
|
||||||
| `tag` | Release-group tag (from `config.tag` or `--tag`) | `TAG` |
|
| `tag` | Release-group tag (from `config.tag` or `--tag`) | `TAG` |
|
||||||
| `repack` | `REPACK` when the `--repack` flag is used | `REPACK` |
|
| `repack` | `REPACK` when the `--repack` flag is used | `REPACK` |
|
||||||
@@ -429,6 +430,23 @@ An MP4 title loses:
|
|||||||
- SubStation Alpha (`.ass`) subtitles
|
- SubStation Alpha (`.ass`) subtitles
|
||||||
- container tags (`mkvpropedit`)
|
- container tags (`mkvpropedit`)
|
||||||
|
|
||||||
|
### 3D output
|
||||||
|
|
||||||
|
A stereoscopic 3D (MV-HEVC) video track also muxes to MP4 with MP4Box, because Matroska has no
|
||||||
|
mapping for its second view and `mkvmerge` drops it. There is no 3D Matroska output. unshackle
|
||||||
|
finds a 3D track from the manifest, or from a second video layer in the downloaded file. The MP4
|
||||||
|
title loses the same items as above. For a 3D title, unshackle also:
|
||||||
|
|
||||||
|
- decrypts the track with mp4decrypt, whatever [`decryption`](../reference/configuration/drm.md#decryption) selects
|
||||||
|
- writes QuickTime chapters
|
||||||
|
- keeps the whole downloaded segments of a `--sample` run, because the MP4Box cut removes the 3D signalling
|
||||||
|
- does not repack the track or rewrite its colour metadata with FFmpeg
|
||||||
|
- writes no `--include-sample` sample, because `mkvmerge` cuts only Matroska files
|
||||||
|
- never uses the track in a `HYBRID` merge
|
||||||
|
|
||||||
|
A Dolby Vision profile 20 track has no HDR10 base layer, so a player without Dolby Vision shows
|
||||||
|
wrong colours.
|
||||||
|
|
||||||
You configure muxing (the combination of video, audio, subtitle, chapter and attachment tracks into a single Matroska file with `mkvmerge`) under the `muxing` config key. unshackle muxes only movies and episodes. Read [Music output files](#music-output-files) for what a music download does instead.
|
You configure muxing (the combination of video, audio, subtitle, chapter and attachment tracks into a single Matroska file with `mkvmerge`) under the `muxing` config key. unshackle muxes only movies and episodes. Read [Music output files](#music-output-files) for what a music download does instead.
|
||||||
|
|
||||||
```yaml title="unshackle.yaml"
|
```yaml title="unshackle.yaml"
|
||||||
@@ -475,6 +493,7 @@ The `--no-mux` flag skips muxing entirely and writes the individual track files,
|
|||||||
[`--sample`](cli-reference.md#output-muxing-files) and `--include-sample` write a sample into a
|
[`--sample`](cli-reference.md#output-muxing-files) and `--include-sample` write a sample into a
|
||||||
`Sample` folder inside the output folder of the title. The sample keeps the filename of the
|
`Sample` folder inside the output folder of the title. The sample keeps the filename of the
|
||||||
title, with a `SAMPLE` tag at the end. The tag uses the separator of the output template.
|
title, with a `SAMPLE` tag at the end. The tag uses the separator of the output template.
|
||||||
|
`--include-sample` cuts the sample with `mkvmerge`, so a title that muxes to MP4 gets no sample.
|
||||||
|
|
||||||
```text title="A movie with --include-sample"
|
```text title="A movie with --include-sample"
|
||||||
Some.Movie.2026.1080p.EXAMPLE.WEB-DL.H.264-TAG/
|
Some.Movie.2026.1080p.EXAMPLE.WEB-DL.H.264-TAG/
|
||||||
|
|||||||
@@ -187,6 +187,10 @@ service tag case-insensitively and uses the `default` entry as the fallback.
|
|||||||
|
|
||||||
unshackle decrypts HLS AES-128 ClearKey in-process and ignores this setting.
|
unshackle decrypts HLS AES-128 ClearKey in-process and ignores this setting.
|
||||||
|
|
||||||
|
unshackle decrypts a stereoscopic 3D (MV-HEVC) track with mp4decrypt whatever this setting says.
|
||||||
|
shaka-packager refuses its Dolby Vision configuration (version 3), and before v3.5.0 it also drops
|
||||||
|
the boxes of the second view.
|
||||||
|
|
||||||
mp4decrypt finds a content key by the KID in the first `sinf` box of the track. When that box
|
mp4decrypt finds a content key by the KID in the first `sinf` box of the track. When that box
|
||||||
has no CENC `tenc` (for example, a PIFF `sinf` before the CENC one), unshackle also gives
|
has no CENC `tenc` (for example, a PIFF `sinf` before the CENC one), unshackle also gives
|
||||||
mp4decrypt the content key of that KID by track ID. When mp4decrypt copies a track through
|
mp4decrypt the content key of that KID by track ID. When mp4decrypt copies a track through
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ folder-kind keys.
|
|||||||
`absolute`, `date`, `quality`, `resolution`, `source`, `tag`, `track_number`, `artist`,
|
`absolute`, `date`, `quality`, `resolution`, `source`, `tag`, `track_number`, `artist`,
|
||||||
`album_artist`, `album`, `disc`, `track_total`, `disc_total`, `release_type`, `genre`,
|
`album_artist`, `album`, `disc`, `track_total`, `disc_total`, `release_type`, `genre`,
|
||||||
`explicit`, `isrc`, `upc`, `label`, `audio`, `audio_channels`, `audio_full`, `atmos`,
|
`explicit`, `isrc`, `upc`, `label`, `audio`, `audio_channels`, `audio_full`, `atmos`,
|
||||||
`dual`, `multi`, `dubbed`, `video`, `hdr`, `hfr`, `edition`, `repack`, `lang_tag`,
|
`dual`, `multi`, `dubbed`, `video`, `hdr`, `hfr`, `3d`, `edition`, `repack`, `lang_tag`,
|
||||||
`title_type`.
|
`title_type`.
|
||||||
|
|
||||||
!!! warning "`{part?}` is already inside `{season_episode}`"
|
!!! warning "`{part?}` is already inside `{season_episode}`"
|
||||||
@@ -129,7 +129,7 @@ Rule semantics:
|
|||||||
- A value can be a scalar or a list. A list matches if **any** entry matches.
|
- A value can be a scalar or a list. A list matches if **any** entry matches.
|
||||||
- Comparison is case-insensitive string equality.
|
- Comparison is case-insensitive string equality.
|
||||||
- The keys of `when` are the filename template variables for the release itself:
|
- The keys of `when` are the filename template variables for the release itself:
|
||||||
`title_type`, `quality`, `resolution`, `source`, `video`, `hdr`, `hfr`, `audio`,
|
`title_type`, `quality`, `resolution`, `source`, `video`, `hdr`, `hfr`, `3d`, `audio`,
|
||||||
`audio_channels`, `audio_full`, `atmos`, `dual`, `multi`, `dubbed`, `edition`, `repack`,
|
`audio_channels`, `audio_full`, `atmos`, `dual`, `multi`, `dubbed`, `edition`, `repack`,
|
||||||
and `lang_tag`. Only these are available. unshackle adds the title's own fields (`title`,
|
and `lang_tag`. Only these are available. unshackle adds the title's own fields (`title`,
|
||||||
`year`, `season`, `episode`, and so on) after it evaluates the rules, so a condition cannot
|
`year`, `season`, `episode`, and so on) after it evaluates the rules, so a condition cannot
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ values.
|
|||||||
| `{year}` `{season}` `{episode}` `{episode_name}` | Season and episode are plain numbers (`1`, `5`) |
|
| `{year}` `{season}` `{episode}` `{episode_name}` | Season and episode are plain numbers (`1`, `5`) |
|
||||||
| `{part}` | Part number of a split episode as a plain number (`1` for `Part.1`). Empty when the episode has no part |
|
| `{part}` | Part number of a split episode as a plain number (`1` for `Part.1`). Empty when the episode has no part |
|
||||||
| `{season_episode}` `{absolute}` `{date}` | As in the filename template: `{season_episode}` keeps the part suffix (`S01E05.Part.1`) while `{episode}` drops it |
|
| `{season_episode}` `{absolute}` `{date}` | As in the filename template: `{season_episode}` keeps the part suffix (`S01E05.Part.1`) while `{episode}` drops it |
|
||||||
| `{quality}` `{resolution}` `{hdr}` `{hfr}` `{vcodec}` `{acodec}` `{edition}` `{tag}` `{lang_tag}` | As in the filename template, for this file |
|
| `{quality}` `{resolution}` `{hdr}` `{hfr}` `{3d}` `{vcodec}` `{acodec}` `{edition}` `{tag}` `{lang_tag}` | As in the filename template, for this file |
|
||||||
| `{tmdb}` `{imdb}` `{tvdb}` | The tagging IDs in use, otherwise empty. See [Tagging IDs](#tagging-ids) |
|
| `{tmdb}` `{imdb}` `{tvdb}` | The tagging IDs in use, otherwise empty. See [Tagging IDs](#tagging-ids) |
|
||||||
| `{error}` | Failure message. Empty on success |
|
| `{error}` | Failure message. Empty on success |
|
||||||
|
|
||||||
@@ -132,7 +132,7 @@ Every variable from your `output_template` is available here as well, including
|
|||||||
music has none of them.
|
music has none of them.
|
||||||
- **The naming context renders it empty on purpose.** `{hdr}` is empty for an SDR
|
- **The naming context renders it empty on purpose.** `{hdr}` is empty for an SDR
|
||||||
file, because SDR is the absence of an HDR tag in a filename rather than a tag that
|
file, because SDR is the absence of an HDR tag in a filename rather than a tag that
|
||||||
reads `SDR`. `{edition}`, `{atmos}`, `{multi}`, `{dual}` and `{hfr}` behave the same
|
reads `SDR`. `{edition}`, `{atmos}`, `{multi}`, `{dual}`, `{hfr}` and `{3d}` behave the same
|
||||||
way. If your script needs the word `SDR`, derive it from an empty `{hdr}` yourself.
|
way. If your script needs the word `SDR`, derive it from an empty `{hdr}` yourself.
|
||||||
- The post-script is a `failure` one. A failed download has no output file to read metadata
|
- The post-script is a `failure` one. A failed download has no output file to read metadata
|
||||||
from, so everything that comes from the output file is empty. See
|
from, so everything that comes from the output file is empty. See
|
||||||
|
|||||||
@@ -476,3 +476,16 @@ def test_import_keeps_every_period_when_no_exported_period_is_left(
|
|||||||
|
|
||||||
assert video.data["dash"]["filtered_period_ids"] == []
|
assert video.data["dash"]["filtered_period_ids"] == []
|
||||||
assert "keeping every period" in caplog.text
|
assert "keeping every period" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_3d_video_stays_3d_through_export_and_import(tmp_path: Path) -> None:
|
||||||
|
export = tmp_path / "export.json"
|
||||||
|
title = make_title()
|
||||||
|
title.tracks.videos[0].stereo = True
|
||||||
|
runner = make_dl()
|
||||||
|
for track in [*title.tracks.videos, *title.tracks.audio]:
|
||||||
|
runner.write_export(export, title, track)
|
||||||
|
assert [row.get("stereo") for row in entry(export)["tracks"]][0] is True
|
||||||
|
svc = ImportService(import_ctx(), "EXAMPLE", "movie-1", str(export))
|
||||||
|
movie = next(iter(svc.get_titles()))
|
||||||
|
assert svc.get_tracks(movie).videos[0].stereo
|
||||||
|
|||||||
@@ -856,7 +856,7 @@ def video(
|
|||||||
codec: Optional[Video.Codec] = Video.Codec.HEVC,
|
codec: Optional[Video.Codec] = Video.Codec.HEVC,
|
||||||
native_hybrid: bool = False,
|
native_hybrid: bool = False,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
return SimpleNamespace(id=id_, height=height, range=range_, codec=codec, native_hybrid=native_hybrid)
|
return SimpleNamespace(id=id_, height=height, range=range_, codec=codec, native_hybrid=native_hybrid, stereo=False)
|
||||||
|
|
||||||
|
|
||||||
def test_hybrid_fails_with_neither_a_base_nor_dv() -> None:
|
def test_hybrid_fails_with_neither_a_base_nor_dv() -> None:
|
||||||
|
|||||||
@@ -297,3 +297,8 @@ def test_a_file_row_can_name_its_codec_and_range_by_name(tmp_path: Path) -> None
|
|||||||
service = file_export(tmp_path, [row])
|
service = file_export(tmp_path, [row])
|
||||||
video = service.get_tracks(next(iter(service.get_titles()))).videos[0]
|
video = service.get_tracks(next(iter(service.get_titles()))).videos[0]
|
||||||
assert (video.codec, video.range) == (Video.Codec.HEVC, Video.Range.HDR10P)
|
assert (video.codec, video.range) == (Video.Codec.HEVC, Video.Range.HDR10P)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_file_row_marked_stereo_imports_as_a_3d_video(tmp_path: Path) -> None:
|
||||||
|
service = file_export(tmp_path, [{**FILE_ROWS[0], "stereo": True}])
|
||||||
|
assert service.get_tracks(next(iter(service.get_titles()))).videos[0].stereo
|
||||||
|
|||||||
@@ -0,0 +1,275 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import itertools
|
||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from langcodes import Language
|
||||||
|
|
||||||
|
from unshackle.commands.dl import dl, hybrid_plan, pick_video
|
||||||
|
from unshackle.core import binaries
|
||||||
|
from unshackle.core.config import config
|
||||||
|
from unshackle.core.drm.verify import decrypter_for
|
||||||
|
from unshackle.core.manifests import DASH, HLS
|
||||||
|
from unshackle.core.titles.movie import Movie
|
||||||
|
from unshackle.core.tracks import Audio, Chapter, Chapters, Track, Tracks, Video
|
||||||
|
from unshackle.core.tracks import tracks as tracks_module
|
||||||
|
|
||||||
|
VIDEO_IDS = itertools.count()
|
||||||
|
|
||||||
|
MASTER = """#EXTM3U
|
||||||
|
#EXT-X-VERSION:12
|
||||||
|
#EXT-X-STREAM-INF:BANDWIDTH=6786883,CODECS="dvh1.20.02",RESOLUTION=960x540,VIDEO-RANGE=PQ,REQ-VIDEO-LAYOUT="CH-MONO,CH-STEREO"
|
||||||
|
stereo.m3u8
|
||||||
|
#EXT-X-STREAM-INF:BANDWIDTH=5000000,CODECS="hvc1.2.4.L150.90",RESOLUTION=1920x1080,REQ-VIDEO-LAYOUT="CH-STEREO"
|
||||||
|
mvhevc.m3u8
|
||||||
|
#EXT-X-STREAM-INF:BANDWIDTH=4000000,CODECS="dvh1.08.06",RESOLUTION=1920x1080,VIDEO-RANGE=PQ
|
||||||
|
flat.m3u8
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("codecs", "layout", "expected"),
|
||||||
|
[
|
||||||
|
("dvh1.20.06,ec-3", None, True),
|
||||||
|
("dvhe.20.09", None, True),
|
||||||
|
("hvc1.2.4.L150.90", '"CH-MONO,CH-STEREO"', True),
|
||||||
|
("dvh1.08.06", '"CH-MONO"', False),
|
||||||
|
("dvh1.05.06", None, False),
|
||||||
|
("dvh1.200.06", None, False),
|
||||||
|
(None, None, False),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_is_stereo(codecs: str | None, layout: str | None, expected: bool) -> None:
|
||||||
|
assert Video.is_stereo(codecs, video_layout=layout) is expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_hls_marks_stereo_tracks_from_the_layout_or_the_codec() -> None:
|
||||||
|
tracks = HLS.from_text(MASTER, url="https://cdn.example/master.m3u8").to_tracks(Language.get("en"))
|
||||||
|
stereo = {str(v.url).rsplit("/", 1)[-1]: v.stereo for v in tracks.videos}
|
||||||
|
assert stereo == {"stereo.m3u8": True, "mvhevc.m3u8": True, "flat.m3u8": False}
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_stereo_track_lists_as_3d_and_keeps_the_flag_through_to_dict() -> None:
|
||||||
|
video = Video(url="https://x/v.mp4", language="en", codec=Video.Codec.HEVC, range_=Video.Range.DV, stereo=True)
|
||||||
|
assert str(video).startswith("VID | [H.265, DV, 3D] |")
|
||||||
|
assert Video.from_dict(video.to_dict()).stereo
|
||||||
|
|
||||||
|
|
||||||
|
def stereo_video(tmp_path: Path, stereo: bool = True, **kwargs: Any) -> Video:
|
||||||
|
n = next(VIDEO_IDS)
|
||||||
|
path = tmp_path / f"v{n}.mp4"
|
||||||
|
path.write_bytes(b"\x00" * 16)
|
||||||
|
video = Video(url=f"https://x/v{n}.mp4", language="en", codec=Video.Codec.HEVC, stereo=stereo, **kwargs)
|
||||||
|
video.path = path
|
||||||
|
return video
|
||||||
|
|
||||||
|
|
||||||
|
def test_dash_marks_a_dv_profile_20_representation_stereo() -> None:
|
||||||
|
mpd = """<?xml version="1.0"?>
|
||||||
|
<MPD xmlns="urn:mpeg:dash:schema:mpd:2011" type="static" mediaPresentationDuration="PT10S">
|
||||||
|
<Period>
|
||||||
|
<AdaptationSet contentType="video" mimeType="video/mp4">
|
||||||
|
<SegmentTemplate media="$RepresentationID$/$Number$.m4s" initialization="$RepresentationID$/init.mp4" duration="2" />
|
||||||
|
<Representation id="v3d" codecs="dvh1.20.06" bandwidth="9000000" width="1920" height="1080" />
|
||||||
|
<Representation id="v2d" codecs="dvh1.08.06" bandwidth="5000000" width="1920" height="1080" />
|
||||||
|
</AdaptationSet>
|
||||||
|
</Period>
|
||||||
|
</MPD>"""
|
||||||
|
videos = DASH.from_text(mpd, url="https://cdn.example/manifest.mpd").to_tracks(language="en").videos
|
||||||
|
assert {v.data["dash"]["representation"].get("id"): v.stereo for v in videos} == {"v3d": True, "v2d": False}
|
||||||
|
|
||||||
|
|
||||||
|
def box(kind: bytes, payload: bytes = b"") -> bytes:
|
||||||
|
return (8 + len(payload)).to_bytes(4, "big") + kind + payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_track_with_a_second_video_layer_decrypts_with_mp4decrypt(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
(layered := tmp_path / "3d.mp4").write_bytes(box(b"moov", box(b"lhvC")))
|
||||||
|
(flat := tmp_path / "2d.mp4").write_bytes(box(b"moov", box(b"hvcC")))
|
||||||
|
monkeypatch.setattr(config, "decryption", "Shaka")
|
||||||
|
assert decrypter_for(layered) == "mp4decrypt"
|
||||||
|
assert decrypter_for(flat) == "shaka"
|
||||||
|
monkeypatch.setattr(config, "decryption", "MP4DECRYPT")
|
||||||
|
assert decrypter_for(flat) == "mp4decrypt"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_3d_title_muxes_with_mp4box(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
calls: list[str] = []
|
||||||
|
monkeypatch.setattr(Tracks, "_mux_mp4", lambda self, **kwargs: calls.append(kwargs["title"]) or (Path("x"), 0, []))
|
||||||
|
assert Tracks(stereo_video(tmp_path)).mux("Film")[1] == 0
|
||||||
|
assert calls == ["Film"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mp4box_commands(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> list[list[str]]:
|
||||||
|
commands: list[list[str]] = []
|
||||||
|
|
||||||
|
class FakePopen:
|
||||||
|
def __init__(self, command: list[str], **kwargs: Any) -> None:
|
||||||
|
commands.append([str(c) for c in command])
|
||||||
|
Path(command[-1]).write_bytes(b"mp4")
|
||||||
|
self.stdout = io.StringIO("")
|
||||||
|
|
||||||
|
def wait(self) -> int:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
monkeypatch.setattr(binaries, "MP4Box", Path("/bin/MP4Box"), raising=False)
|
||||||
|
monkeypatch.setattr(tracks_module.subprocess, "Popen", FakePopen)
|
||||||
|
monkeypatch.setattr(tracks_module, "run_step", lambda command, **kwargs: commands.append([str(c) for c in command]))
|
||||||
|
monkeypatch.setattr(config, "sample", None)
|
||||||
|
monkeypatch.setattr(config.directories, "temp", tmp_path)
|
||||||
|
return commands
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_3d_mp4_mux_keeps_the_sample_whole(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mp4box_commands: list[list[str]]
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(config, "sample", (120.0, 180.0))
|
||||||
|
Tracks(stereo_video(tmp_path))._mux_mp4(title="Film", output_path=tmp_path / "out.mkv")
|
||||||
|
assert len(mp4box_commands) == 1
|
||||||
|
assert "-splitx" not in mp4box_commands[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_3d_mp4_mux_writes_quicktime_chapters(tmp_path: Path, mp4box_commands: list[list[str]]) -> None:
|
||||||
|
for stereo, flag in ((True, "-chapqt"), (False, "-chap")):
|
||||||
|
tracks = Tracks(stereo_video(tmp_path, stereo=stereo))
|
||||||
|
tracks.chapters = Chapters([Chapter(0.0, "Story")])
|
||||||
|
tracks._mux_mp4(title="Film", output_path=tmp_path / f"out{stereo}.mkv")
|
||||||
|
assert flag in mp4box_commands[-1] and ({"-chap", "-chapqt"} - {flag}).isdisjoint(mp4box_commands[-1])
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_3d_track_is_never_a_hybrid_ingredient(tmp_path: Path) -> None:
|
||||||
|
flat, layered = (stereo_video(tmp_path, stereo=s, range_=Video.Range.DV) for s in (False, True))
|
||||||
|
candidates, standalone = Tracks.partition_hybrid_videos([flat, layered], [])
|
||||||
|
assert candidates == [flat] and standalone == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_2d_ranks_over_3d_at_one_resolution_and_the_signatures_differ(tmp_path: Path) -> None:
|
||||||
|
flat = stereo_video(tmp_path, stereo=False, height=1080, bitrate=5_000_000)
|
||||||
|
layered = stereo_video(tmp_path, stereo=True, height=1080, bitrate=9_000_000)
|
||||||
|
uhd = stereo_video(tmp_path, stereo=True, height=2160, bitrate=40_000_000, width=3840)
|
||||||
|
tracks = Tracks(layered, flat, uhd)
|
||||||
|
tracks.sort_videos()
|
||||||
|
assert tracks.videos == [uhd, flat, layered]
|
||||||
|
assert Tracks.signature(flat) != Tracks.signature(
|
||||||
|
stereo_video(tmp_path, stereo=True, height=1080, bitrate=5_000_000)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_3d_track_gets_no_ffmpeg_vui_rewrite_or_repack(tmp_path: Path) -> None:
|
||||||
|
track = stereo_video(tmp_path, range_=Video.Range.HDR10)
|
||||||
|
assert track.vui_bsf() is None
|
||||||
|
assert track.repackage() is False
|
||||||
|
assert track.path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_filename_context_marks_a_3d_title(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(config, "tag_rules", [])
|
||||||
|
monkeypatch.setattr(config, "language_tags", {})
|
||||||
|
|
||||||
|
class Svc:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def context(stereo: bool) -> str:
|
||||||
|
movie = Movie(id_="movie-0001", service=Svc, name="The Film", year=2024, language="en")
|
||||||
|
movie.tracks = Tracks(Video(url="https://x/v.mp4", language="en", codec=Video.Codec.HEVC, stereo=stereo))
|
||||||
|
video = SimpleNamespace(
|
||||||
|
width=3840,
|
||||||
|
height=2160,
|
||||||
|
format="HEVC",
|
||||||
|
scan_type="Progressive",
|
||||||
|
hdr_format_commercial="",
|
||||||
|
hdr_format="",
|
||||||
|
hdr_format_profile="",
|
||||||
|
transfer_characteristics="PQ",
|
||||||
|
transfer_characteristics_original=None,
|
||||||
|
frame_rate="47.952",
|
||||||
|
)
|
||||||
|
return str(movie.build_base_template_context(SimpleNamespace(video_tracks=[video], audio_tracks=[]))["3d"])
|
||||||
|
|
||||||
|
assert (context(True), context(False)) == ("3D", "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_worst_prefers_the_2d_track_at_one_height(tmp_path: Path) -> None:
|
||||||
|
videos = [
|
||||||
|
stereo_video(tmp_path, stereo=s, height=2160, bitrate=b)
|
||||||
|
for s, b in ((False, 9_000_000), (False, 5_000_000), (True, 40_000_000), (True, 30_000_000))
|
||||||
|
]
|
||||||
|
ranked = Tracks(videos)
|
||||||
|
ranked.sort_videos()
|
||||||
|
assert pick_video(ranked.videos, worst=True) is videos[1]
|
||||||
|
assert pick_video(ranked.videos, worst=False) is videos[0]
|
||||||
|
assert pick_video([videos[3], videos[2]], worst=True) is videos[2]
|
||||||
|
assert pick_video([], worst=True) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_hybrid_never_pairs_a_3d_dv_track(tmp_path: Path) -> None:
|
||||||
|
base = stereo_video(tmp_path, stereo=False, range_=Video.Range.HDR10, height=2160)
|
||||||
|
layered = stereo_video(tmp_path, stereo=True, range_=Video.Range.DV, height=2160)
|
||||||
|
assert hybrid_plan([base, layered], generate_dv=False) == ([], None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mp4_names_skip_the_extra_tracks_of_a_3d_video_file(tmp_path: Path, mp4box_commands: list[list[str]]) -> None:
|
||||||
|
video = stereo_video(tmp_path)
|
||||||
|
video.path.write_bytes(box(b"moov", box(b"trak") + box(b"trak")))
|
||||||
|
audio = Audio(url="https://x/a.mp4", language="en", codec=Audio.Codec.EC3, name="English")
|
||||||
|
audio.path = tmp_path / "a.mp4"
|
||||||
|
audio.path.write_bytes(b"\x00" * 16)
|
||||||
|
Tracks([video, audio])._mux_mp4(title="Film", output_path=tmp_path / "out.mkv")
|
||||||
|
command = mp4box_commands[0]
|
||||||
|
assert command[command.index("-name") + 1] == "3=English"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_3d_mux_that_loses_its_second_view_warns(
|
||||||
|
tmp_path: Path, mp4box_commands: list[list[str]], caplog: pytest.LogCaptureFixture
|
||||||
|
) -> None:
|
||||||
|
Tracks(stereo_video(tmp_path))._mux_mp4(title="Film", output_path=tmp_path / "out.mkv")
|
||||||
|
assert "lost its second view" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_downloaded_file_with_a_second_view_marks_the_video_3d(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
video = stereo_video(tmp_path, stereo=False)
|
||||||
|
monkeypatch.setattr(Track, "download", lambda self, *a, **k: self.path.write_bytes(box(b"moov", box(b"lhvC"))))
|
||||||
|
video.download(None, None)
|
||||||
|
assert video.stereo
|
||||||
|
|
||||||
|
|
||||||
|
def test_dash_reads_dv_profile_20_from_supplemental_codecs() -> None:
|
||||||
|
mpd = """<?xml version="1.0"?>
|
||||||
|
<MPD xmlns="urn:mpeg:dash:schema:mpd:2011" type="static" mediaPresentationDuration="PT10M">
|
||||||
|
<Period id="1"><AdaptationSet contentType="video" mimeType="video/mp4" lang="en">
|
||||||
|
<Representation id="v" codecs="hvc1.2.4.L153" supplementalCodecs="dvh1.20.09" bandwidth="1" width="3840" height="2160">
|
||||||
|
<BaseURL>v.mp4</BaseURL><SegmentBase indexRange="0-1"/>
|
||||||
|
</Representation>
|
||||||
|
</AdaptationSet></Period>
|
||||||
|
</MPD>"""
|
||||||
|
assert DASH.from_text(mpd, url="https://cdn.example/m.mpd").to_tracks(language="en").videos[0].stereo
|
||||||
|
|
||||||
|
|
||||||
|
def test_hls_reads_each_variant_layout_even_when_two_share_a_playlist() -> None:
|
||||||
|
master = """#EXTM3U
|
||||||
|
#EXT-X-STREAM-INF:BANDWIDTH=2,CODECS="hvc1.2.4.L150.90",RESOLUTION=1920x1080,REQ-VIDEO-LAYOUT="CH-STEREO"
|
||||||
|
shared.m3u8
|
||||||
|
#EXT-X-STREAM-INF:BANDWIDTH=1,CODECS="hvc1.2.4.L150.90",RESOLUTION=1920x1080,AUDIO="a"
|
||||||
|
shared.m3u8
|
||||||
|
"""
|
||||||
|
tracks = HLS.from_text(master, url="https://cdn.example/master.m3u8").to_tracks(Language.get("en"))
|
||||||
|
assert any(v.stereo for v in tracks.videos)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_sample_is_cut_only_from_a_matroska_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
runner = dl.__new__(dl)
|
||||||
|
runner.log = logging.getLogger("download")
|
||||||
|
monkeypatch.setattr(subprocess, "run", lambda *a, **k: pytest.fail("mkvmerge ran on an MP4"))
|
||||||
|
runner.write_sample(tmp_path / "Film.mp4", SimpleNamespace(), SimpleNamespace())
|
||||||
|
assert not (tmp_path / "Sample").exists()
|
||||||
@@ -58,6 +58,11 @@ def test_a_dv_track_keeps_its_hdr10_base_label_over_remote() -> None:
|
|||||||
assert deserialize_video(serialize_video_track(v)).range_label == "DV+HDR10"
|
assert deserialize_video(serialize_video_track(v)).range_label == "DV+HDR10"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_stereo_track_stays_3d_over_remote() -> None:
|
||||||
|
v = Video(url="https://x/v.mp4", language="en", codec=Video.Codec.HEVC, range_=Video.Range.DV, stereo=True)
|
||||||
|
assert deserialize_video(serialize_video_track(v)).stereo
|
||||||
|
|
||||||
|
|
||||||
def test_a_track_note_shows_over_remote_and_export_and_is_not_an_edition() -> None:
|
def test_a_track_note_shows_over_remote_and_export_and_is_not_an_edition() -> None:
|
||||||
from unshackle.core.tracks.track import Track
|
from unshackle.core.tracks.track import Track
|
||||||
|
|
||||||
|
|||||||
+21
-11
@@ -336,18 +336,26 @@ def hybrid_gate_failure(videos: Sequence[Video], generate_dv: bool) -> Optional[
|
|||||||
|
|
||||||
|
|
||||||
def hybrid_plan(videos: Sequence[Video], generate_dv: bool) -> tuple[list[Video], Optional[Video]]:
|
def hybrid_plan(videos: Sequence[Video], generate_dv: bool) -> tuple[list[Video], Optional[Video]]:
|
||||||
"""The hybrid bases and the DV track to pair with them.
|
"""The hybrid bases and the 2D DV track to pair with them.
|
||||||
|
|
||||||
With no DV track, a base qualifies only with ``--generate-dv``. A resolution with a
|
With no DV track, a base qualifies only with ``--generate-dv``. A resolution with a
|
||||||
finished hybrid (``native_hybrid``) gets no base: that track is its hybrid.
|
finished hybrid (``native_hybrid``) gets no base: that track is its hybrid.
|
||||||
"""
|
"""
|
||||||
dv_tracks = [v for v in videos if v.range == Video.Range.DV]
|
dv_tracks = [v for v in videos if v.range == Video.Range.DV and not v.stereo]
|
||||||
native_heights = {v.height for v in videos if v.native_hybrid}
|
native_heights = {v.height for v in videos if v.native_hybrid}
|
||||||
base_pool = [v for v in videos if v.height not in native_heights and (dv_tracks or generate_dv)]
|
base_pool = [v for v in videos if v.height not in native_heights and (dv_tracks or generate_dv)]
|
||||||
matching_dv = min(dv_tracks, key=lambda v: v.height or 0) if dv_tracks else None
|
matching_dv = min(dv_tracks, key=lambda v: v.height or 0) if dv_tracks else None
|
||||||
return select_hybrid_bases(base_pool), matching_dv
|
return select_hybrid_bases(base_pool), matching_dv
|
||||||
|
|
||||||
|
|
||||||
|
def pick_video(candidates: list[Video], worst: bool) -> Optional[Video]:
|
||||||
|
"""The best candidate, or with ``worst`` the last one, preferring 2D: a 3D track sorts after 2D at one height."""
|
||||||
|
if worst:
|
||||||
|
pool = [v for v in candidates if not v.stereo] or candidates
|
||||||
|
return pool[-1] if pool else None
|
||||||
|
return next(iter(candidates), None)
|
||||||
|
|
||||||
|
|
||||||
def hybrid_source_tracks(base: Video, dv: Optional[Video], base_container: Optional[Path] = None) -> list[Video]:
|
def hybrid_source_tracks(base: Video, dv: Optional[Video], base_container: Optional[Path] = None) -> list[Video]:
|
||||||
"""Copies of the base and DV tracks for the hybrid step, with the base on its container file.
|
"""Copies of the base and DV tracks for the hybrid step, with the base on its container file.
|
||||||
|
|
||||||
@@ -2874,7 +2882,7 @@ class dl:
|
|||||||
and (not codec or t.codec == codec)
|
and (not codec or t.codec == codec)
|
||||||
and (vlang is None or str(t.language) == vlang)
|
and (vlang is None or str(t.language) == vlang)
|
||||||
]
|
]
|
||||||
match = candidates[-1] if worst and candidates else next(iter(candidates), None)
|
match = pick_video(candidates, worst)
|
||||||
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)
|
||||||
|
|
||||||
@@ -2903,7 +2911,7 @@ class dl:
|
|||||||
and (not codec or t.codec == codec)
|
and (not codec or t.codec == codec)
|
||||||
and (vlang is None or str(t.language) == vlang)
|
and (vlang is None or str(t.language) == vlang)
|
||||||
]
|
]
|
||||||
match = candidates[-1] if worst and candidates else next(iter(candidates), None)
|
match = pick_video(candidates, worst)
|
||||||
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
|
||||||
@@ -2941,7 +2949,7 @@ class dl:
|
|||||||
and (not codec or t.codec == codec)
|
and (not codec or t.codec == codec)
|
||||||
and (vlang is None or str(t.language) == vlang)
|
and (vlang is None or str(t.language) == vlang)
|
||||||
]
|
]
|
||||||
match = candidates[-1] if worst and candidates else next(iter(candidates), None)
|
match = pick_video(candidates, worst)
|
||||||
if match and match not in fallback_selected:
|
if match and match not in fallback_selected:
|
||||||
fallback_selected.append(match)
|
fallback_selected.append(match)
|
||||||
title.tracks.videos = fallback_selected
|
title.tracks.videos = fallback_selected
|
||||||
@@ -3448,13 +3456,9 @@ class dl:
|
|||||||
self.suggest_missing_fonts(missing_fonts)
|
self.suggest_missing_fonts(missing_fonts)
|
||||||
|
|
||||||
# Handle DRM decryption BEFORE repacking (must decrypt first!)
|
# Handle DRM decryption BEFORE repacking (must decrypt first!)
|
||||||
service_name = service.__class__.__name__.upper()
|
|
||||||
decryption_method = resolve_decryption(config.decryption_map, config.decryption, service_name)
|
|
||||||
decrypt_tool = "mp4decrypt" if decryption_method.lower() == "mp4decrypt" else "Shaka Packager"
|
|
||||||
|
|
||||||
drm_tracks = [track for track in title.tracks if track.drm]
|
drm_tracks = [track for track in title.tracks if track.drm]
|
||||||
if drm_tracks:
|
if drm_tracks:
|
||||||
with console.status(f"Decrypting tracks with {decrypt_tool}..."):
|
with console.status("Decrypting tracks..."):
|
||||||
has_decrypted = False
|
has_decrypted = False
|
||||||
for track in drm_tracks:
|
for track in drm_tracks:
|
||||||
drm = track.get_drm_for_cdm(self.cdm)
|
drm = track.get_drm_for_cdm(self.cdm)
|
||||||
@@ -3470,7 +3474,7 @@ class dl:
|
|||||||
f"No matching DRM found for track {track} with CDM type {type(self.cdm).__name__}"
|
f"No matching DRM found for track {track} with CDM type {type(self.cdm).__name__}"
|
||||||
)
|
)
|
||||||
if has_decrypted:
|
if has_decrypted:
|
||||||
self.log.info(f"Decrypted tracks with {decrypt_tool}")
|
self.log.info("Decrypted tracks")
|
||||||
|
|
||||||
if (
|
if (
|
||||||
not no_subs
|
not no_subs
|
||||||
@@ -4050,7 +4054,11 @@ class dl:
|
|||||||
"""Cut a 60 second sample from a finished file into a ``Sample`` folder next to it.
|
"""Cut a 60 second sample from a finished file into a ``Sample`` folder next to it.
|
||||||
|
|
||||||
The sample starts 2 minutes in. A file shorter than 3 minutes gives its middle 60 seconds.
|
The sample starts 2 minutes in. A file shorter than 3 minutes gives its middle 60 seconds.
|
||||||
|
mkvmerge cuts only Matroska: an MP4 (3D video, or audio Matroska cannot carry) gets no sample.
|
||||||
"""
|
"""
|
||||||
|
if source.suffix.lower() != ".mkv":
|
||||||
|
self.log.warning(f"No sample for {source.name}: mkvmerge cuts only Matroska files")
|
||||||
|
return
|
||||||
duration = float(media_info.general_tracks[0].duration or 0) / 1000
|
duration = float(media_info.general_tracks[0].duration or 0) / 1000
|
||||||
start: float
|
start: float
|
||||||
start, end = DEFAULT_SAMPLE
|
start, end = DEFAULT_SAMPLE
|
||||||
@@ -4518,6 +4526,8 @@ class dl:
|
|||||||
}
|
}
|
||||||
if isinstance(t, Video):
|
if isinstance(t, Video):
|
||||||
row.update(width=t.width, height=t.height, range=t.range.name.lower() if t.range else "")
|
row.update(width=t.width, height=t.height, range=t.range.name.lower() if t.range else "")
|
||||||
|
if t.stereo:
|
||||||
|
row["stereo"] = True
|
||||||
elif isinstance(t, Audio):
|
elif isinstance(t, Audio):
|
||||||
row.update(channels=str(t.channels) if t.channels else "", atmos=bool(t.joc), descriptive=t.descriptive)
|
row.update(channels=str(t.channels) if t.channels else "", atmos=bool(t.joc), descriptive=t.descriptive)
|
||||||
elif isinstance(t, Subtitle):
|
elif isinstance(t, Subtitle):
|
||||||
|
|||||||
@@ -48,6 +48,13 @@ def get_dependencies() -> list[dict]:
|
|||||||
"desc": "MKV metadata",
|
"desc": "MKV metadata",
|
||||||
"cat": "Core",
|
"cat": "Core",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "MP4Box",
|
||||||
|
"binary": binaries.MP4Box,
|
||||||
|
"required": False,
|
||||||
|
"desc": "MP4 muxing",
|
||||||
|
"cat": "Core",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "shaka-packager",
|
"name": "shaka-packager",
|
||||||
"binary": binaries.ShakaPackager,
|
"binary": binaries.ShakaPackager,
|
||||||
|
|||||||
@@ -1744,6 +1744,7 @@ def serialize_video_track(track: Video, include_url: bool = False) -> Dict[str,
|
|||||||
"dv_compatible_bitstream": bool(getattr(track, "dv_compatible_bitstream", False)),
|
"dv_compatible_bitstream": bool(getattr(track, "dv_compatible_bitstream", False)),
|
||||||
"hdr10plus": bool(getattr(track, "hdr10plus", False)),
|
"hdr10plus": bool(getattr(track, "hdr10plus", False)),
|
||||||
"hdr10_base": bool(getattr(track, "hdr10_base", False)),
|
"hdr10_base": bool(getattr(track, "hdr10_base", False)),
|
||||||
|
"stereo": bool(getattr(track, "stereo", False)),
|
||||||
}
|
}
|
||||||
result.update(serialize_track_common(track))
|
result.update(serialize_track_common(track))
|
||||||
if include_url and hasattr(track, "url") and track.url:
|
if include_url and hasattr(track, "url") and track.url:
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ __binaries = {
|
|||||||
"DoviTool": ("dovi_tool",),
|
"DoviTool": ("dovi_tool",),
|
||||||
"HDR10PlusTool": ("hdr10plus_tool", "HDR10Plus_tool"),
|
"HDR10PlusTool": ("hdr10plus_tool", "HDR10Plus_tool"),
|
||||||
"Mp4decrypt": ("mp4decrypt",),
|
"Mp4decrypt": ("mp4decrypt",),
|
||||||
|
"MP4Box": ("MP4Box", "mp4box"),
|
||||||
"Docker": ("docker",),
|
"Docker": ("docker",),
|
||||||
"Git": ("git",),
|
"Git": ("git",),
|
||||||
}
|
}
|
||||||
@@ -226,6 +227,7 @@ __all__ = (
|
|||||||
"DoviTool",
|
"DoviTool",
|
||||||
"HDR10PlusTool",
|
"HDR10PlusTool",
|
||||||
"Mp4decrypt",
|
"Mp4decrypt",
|
||||||
|
"MP4Box",
|
||||||
"Docker",
|
"Docker",
|
||||||
"Git",
|
"Git",
|
||||||
"find",
|
"find",
|
||||||
|
|||||||
@@ -279,6 +279,7 @@ class Config:
|
|||||||
"video",
|
"video",
|
||||||
"hdr",
|
"hdr",
|
||||||
"hfr",
|
"hfr",
|
||||||
|
"3d",
|
||||||
"edition",
|
"edition",
|
||||||
"repack",
|
"repack",
|
||||||
"lang_tag",
|
"lang_tag",
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from unshackle.core import binaries
|
|||||||
from unshackle.core.config import config
|
from unshackle.core.config import config
|
||||||
from unshackle.core.console import console
|
from unshackle.core.console import console
|
||||||
from unshackle.core.drm import key_args as drm_key_args
|
from unshackle.core.drm import key_args as drm_key_args
|
||||||
from unshackle.core.drm.verify import kidless_track_kids, mp4decrypt_or_shaka, read_top_level_box
|
from unshackle.core.drm.verify import decrypter_for, kidless_track_kids, mp4decrypt_or_shaka, read_top_level_box
|
||||||
from unshackle.core.utilities import log_event
|
from unshackle.core.utilities import log_event
|
||||||
from unshackle.core.utils import run_proxy
|
from unshackle.core.utils import run_proxy
|
||||||
|
|
||||||
@@ -196,7 +196,7 @@ class ClearKeyCENC:
|
|||||||
if not path or not path.exists():
|
if not path or not path.exists():
|
||||||
raise ValueError("Tried to decrypt a file that does not exist.")
|
raise ValueError("Tried to decrypt a file that does not exist.")
|
||||||
|
|
||||||
decrypter = str(getattr(config, "decryption", "")).lower()
|
decrypter = decrypter_for(path)
|
||||||
tool = "mp4decrypt" if decrypter == "mp4decrypt" else "shaka-packager"
|
tool = "mp4decrypt" if decrypter == "mp4decrypt" else "shaka-packager"
|
||||||
|
|
||||||
log_event(
|
log_event(
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ from unshackle.core.config import config
|
|||||||
from unshackle.core.console import console
|
from unshackle.core.console import console
|
||||||
from unshackle.core.constants import AnyTrack
|
from unshackle.core.constants import AnyTrack
|
||||||
from unshackle.core.drm import key_args as drm_key_args
|
from unshackle.core.drm import key_args as drm_key_args
|
||||||
from unshackle.core.drm.verify import kidless_track_kids, mp4decrypt_or_shaka, read_top_level_box
|
from unshackle.core.drm.verify import decrypter_for, kidless_track_kids, mp4decrypt_or_shaka, read_top_level_box
|
||||||
from unshackle.core.session import AnySession, utf8_text
|
from unshackle.core.session import AnySession, utf8_text
|
||||||
from unshackle.core.utilities import call_licence, get_boxes, log_event
|
from unshackle.core.utilities import call_licence, get_boxes, log_event
|
||||||
from unshackle.core.utils import run_proxy
|
from unshackle.core.utils import run_proxy
|
||||||
@@ -557,7 +557,7 @@ class PlayReady:
|
|||||||
if not path or not path.exists():
|
if not path or not path.exists():
|
||||||
raise ValueError("Tried to decrypt a file that does not exist.")
|
raise ValueError("Tried to decrypt a file that does not exist.")
|
||||||
|
|
||||||
decrypter = str(getattr(config, "decryption", "")).lower()
|
decrypter = decrypter_for(path)
|
||||||
tool = "mp4decrypt" if decrypter == "mp4decrypt" else "shaka-packager"
|
tool = "mp4decrypt" if decrypter == "mp4decrypt" else "shaka-packager"
|
||||||
|
|
||||||
log_event(
|
log_event(
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ from pathlib import Path
|
|||||||
from typing import Any, BinaryIO, Callable, Iterator, NamedTuple, Optional
|
from typing import Any, BinaryIO, Callable, Iterator, NamedTuple, Optional
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
|
from unshackle.core.config import config
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
DECRYPT_HOOK: Optional[Callable[..., None]] = None
|
DECRYPT_HOOK: Optional[Callable[..., None]] = None
|
||||||
@@ -221,6 +223,37 @@ def kidless_track_kids(data: bytes) -> dict[int, UUID]:
|
|||||||
return kids
|
return kids
|
||||||
|
|
||||||
|
|
||||||
|
def find_child_box(buf: bytes, box_type: bytes) -> Optional[bytes]:
|
||||||
|
"""Return the first box of the given type nested anywhere in buf, header included."""
|
||||||
|
i = buf.find(box_type, 4)
|
||||||
|
while i != -1:
|
||||||
|
size = int.from_bytes(buf[i - 4 : i], "big")
|
||||||
|
if 8 <= size <= len(buf) - (i - 4):
|
||||||
|
return buf[i - 4 : i - 4 + size]
|
||||||
|
i = buf.find(box_type, i + 1)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def has_second_view(path: Path) -> bool:
|
||||||
|
"""True if the MP4 holds a second video layer (an ``lhvC`` box, as stereoscopic MV-HEVC has)."""
|
||||||
|
try:
|
||||||
|
return find_child_box(read_top_level_box(path, b"moov") or b"", b"lhvC") is not None
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def decrypter_for(path: Path) -> str:
|
||||||
|
"""The configured decrypter, or mp4decrypt for a multi-layer (MV-HEVC) track.
|
||||||
|
|
||||||
|
shaka-packager refuses a version 3 dvvC (Dolby Vision profile 20) and, before v3.5.0, drops lhvC and vexu.
|
||||||
|
"""
|
||||||
|
configured = str(getattr(config, "decryption", "")).lower()
|
||||||
|
if configured != "mp4decrypt" and has_second_view(path):
|
||||||
|
log.debug(f"Decrypting {path.name} with mp4decrypt: it holds a second video layer (lhvC)")
|
||||||
|
return "mp4decrypt"
|
||||||
|
return configured
|
||||||
|
|
||||||
|
|
||||||
def mp4decrypt_or_shaka(drm: Any, path: Path) -> None:
|
def mp4decrypt_or_shaka(drm: Any, path: Path) -> None:
|
||||||
"""Decrypt ``path`` with mp4decrypt, and again with shaka-packager when mp4decrypt copied it through.
|
"""Decrypt ``path`` with mp4decrypt, and again with shaka-packager when mp4decrypt copied it through.
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from unshackle.core.config import config
|
|||||||
from unshackle.core.console import console
|
from unshackle.core.console import console
|
||||||
from unshackle.core.constants import AnyTrack
|
from unshackle.core.constants import AnyTrack
|
||||||
from unshackle.core.drm import key_args as drm_key_args
|
from unshackle.core.drm import key_args as drm_key_args
|
||||||
from unshackle.core.drm.verify import kidless_track_kids, mp4decrypt_or_shaka, read_top_level_box
|
from unshackle.core.drm.verify import decrypter_for, kidless_track_kids, mp4decrypt_or_shaka, read_top_level_box
|
||||||
from unshackle.core.session import AnySession, utf8_text
|
from unshackle.core.session import AnySession, utf8_text
|
||||||
from unshackle.core.utilities import call_licence, get_boxes, log_event
|
from unshackle.core.utilities import call_licence, get_boxes, log_event
|
||||||
from unshackle.core.utils import run_proxy
|
from unshackle.core.utils import run_proxy
|
||||||
@@ -307,7 +307,7 @@ class Widevine:
|
|||||||
if not path or not path.exists():
|
if not path or not path.exists():
|
||||||
raise ValueError("Tried to decrypt a file that does not exist.")
|
raise ValueError("Tried to decrypt a file that does not exist.")
|
||||||
|
|
||||||
decrypter = str(getattr(config, "decryption", "")).lower()
|
decrypter = decrypter_for(path)
|
||||||
tool = "mp4decrypt" if decrypter == "mp4decrypt" else "shaka-packager"
|
tool = "mp4decrypt" if decrypter == "mp4decrypt" else "shaka-packager"
|
||||||
|
|
||||||
log_event(
|
log_event(
|
||||||
|
|||||||
@@ -562,6 +562,7 @@ class ImportService:
|
|||||||
width=int_or_none(row.get("width")),
|
width=int_or_none(row.get("width")),
|
||||||
height=int_or_none(row.get("height")),
|
height=int_or_none(row.get("height")),
|
||||||
fps=row.get("fps") or None,
|
fps=row.get("fps") or None,
|
||||||
|
stereo=bool(row.get("stereo")),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
track = Audio(
|
track = Audio(
|
||||||
|
|||||||
@@ -333,6 +333,7 @@ class DASH:
|
|||||||
video_args["dv_compatible_bitstream"] = Video.is_dv_composite(
|
video_args["dv_compatible_bitstream"] = Video.is_dv_composite(
|
||||||
track_codec, codecs, get("supplementalCodecs")
|
track_codec, codecs, get("supplementalCodecs")
|
||||||
)
|
)
|
||||||
|
video_args["stereo"] = Video.is_stereo(codecs, get("supplementalCodecs"))
|
||||||
elif content_type == "audio":
|
elif content_type == "audio":
|
||||||
track_type = Audio
|
track_type = Audio
|
||||||
track_codec = Audio.Codec.from_codecs(codecs or "")
|
track_codec = Audio.Codec.from_codecs(codecs or "")
|
||||||
|
|||||||
@@ -222,7 +222,11 @@ class HLS:
|
|||||||
|
|
||||||
supplemental_codecs = self.supplemental_codecs_by_uri()
|
supplemental_codecs = self.supplemental_codecs_by_uri()
|
||||||
|
|
||||||
for playlist in self.manifest.playlists:
|
video_layouts = [
|
||||||
|
(raw.get("stream_info") or {}).get("req_video_layout")
|
||||||
|
for raw in (getattr(self.manifest, "data", None) or {}).get("playlists", [])
|
||||||
|
]
|
||||||
|
for playlist, video_layout in zip(self.manifest.playlists, video_layouts):
|
||||||
video_codec: Optional[Video.Codec] = None
|
video_codec: Optional[Video.Codec] = None
|
||||||
primary_track_type: type[Audio] | type[Video]
|
primary_track_type: type[Audio] | type[Video]
|
||||||
try:
|
try:
|
||||||
@@ -272,6 +276,9 @@ class HLS:
|
|||||||
(playlist.stream_info.closed_captions or "").strip('"'), []
|
(playlist.stream_info.closed_captions or "").strip('"'), []
|
||||||
),
|
),
|
||||||
dv_compatible_bitstream=dv_compatible_bitstream,
|
dv_compatible_bitstream=dv_compatible_bitstream,
|
||||||
|
stereo=Video.is_stereo(
|
||||||
|
playlist.stream_info.codecs, supplemental_codecs.get(playlist.uri), video_layout
|
||||||
|
),
|
||||||
)
|
)
|
||||||
if primary_track_type is Video
|
if primary_track_type is Video
|
||||||
else {}
|
else {}
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ class RemoteError(SystemExit):
|
|||||||
"""A remote call that failed: logged, then exits a CLI run like ``SystemExit(1)``.
|
"""A remote call that failed: logged, then exits a CLI run like ``SystemExit(1)``.
|
||||||
|
|
||||||
A serve relaying for a REST client catches it instead and answers with ``message``; ``kind`` is the
|
A serve relaying for a REST client catches it instead and answers with ``message``; ``kind`` is the
|
||||||
remote server's error code, or CONNECT / TIMEOUT / AUTH when the session could not be reached or opened.
|
remote server's error code, or CONNECT / TIMEOUT / AUTH when the remote session could not be reached or opened.
|
||||||
(Not ``code``: SystemExit's ``code`` is the exit status.)
|
(Not ``code``: SystemExit's ``code`` is the exit status.)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -132,7 +132,7 @@ class RemoteError(SystemExit):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def session_broke(self) -> bool:
|
def session_broke(self) -> bool:
|
||||||
"""Whether the remote session itself is gone, not just this one call."""
|
"""Whether the remote session itself is gone, not only this one call."""
|
||||||
return self.kind in SESSION_LOST
|
return self.kind in SESSION_LOST
|
||||||
|
|
||||||
|
|
||||||
@@ -348,6 +348,7 @@ def deserialize_video(data: Dict[str, Any]) -> Video:
|
|||||||
dv_compatible_bitstream=bool(data.get("dv_compatible_bitstream", False)),
|
dv_compatible_bitstream=bool(data.get("dv_compatible_bitstream", False)),
|
||||||
hdr10plus=bool(data.get("hdr10plus", False)),
|
hdr10plus=bool(data.get("hdr10plus", False)),
|
||||||
hdr10_base=bool(data.get("hdr10_base", False)),
|
hdr10_base=bool(data.get("hdr10_base", False)),
|
||||||
|
stereo=bool(data.get("stereo", False)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ class Title:
|
|||||||
"video": "",
|
"video": "",
|
||||||
"hdr": "",
|
"hdr": "",
|
||||||
"hfr": "",
|
"hfr": "",
|
||||||
|
"3d": "",
|
||||||
"edition": "",
|
"edition": "",
|
||||||
"lang_tag": "",
|
"lang_tag": "",
|
||||||
"title_type": {"Movie": "movie", "Episode": "series", "Song": "music"}.get(
|
"title_type": {"Movie": "movie", "Episode": "series", "Song": "music"}.get(
|
||||||
@@ -163,6 +164,7 @@ class Title:
|
|||||||
|
|
||||||
frame_rate = float(primary_video_track.frame_rate) if primary_video_track.frame_rate else 0.0
|
frame_rate = float(primary_video_track.frame_rate) if primary_video_track.frame_rate else 0.0
|
||||||
context["hfr"] = "HFR" if frame_rate > 30 else ""
|
context["hfr"] = "HFR" if frame_rate > 30 else ""
|
||||||
|
context["3d"] = "3D" if any(v.stereo for v in self.tracks.videos[:1]) else ""
|
||||||
|
|
||||||
if primary_audio_track:
|
if primary_audio_track:
|
||||||
codec = primary_audio_track.format
|
codec = primary_audio_track.format
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ from unshackle.core.downloaders.requests import requests
|
|||||||
from unshackle.core.drm import DRM_T, BlowfishStripe, ClearKeyCENC, PlayReady, Widevine
|
from unshackle.core.drm import DRM_T, BlowfishStripe, ClearKeyCENC, PlayReady, Widevine
|
||||||
from unshackle.core.drm.verify import (
|
from unshackle.core.drm.verify import (
|
||||||
decrypt_track,
|
decrypt_track,
|
||||||
|
find_child_box,
|
||||||
has_encrypted_sample_entry,
|
has_encrypted_sample_entry,
|
||||||
read_top_level_box,
|
read_top_level_box,
|
||||||
senc_protects_samples,
|
senc_protects_samples,
|
||||||
@@ -114,17 +115,6 @@ def iter_top_level_boxes(path: Path) -> Iterable[tuple[bytes, int, int]]:
|
|||||||
offset += size
|
offset += size
|
||||||
|
|
||||||
|
|
||||||
def find_child_box(buf: bytes, box_type: bytes) -> Optional[bytes]:
|
|
||||||
"""Return the first box of the given type nested anywhere in buf, header included."""
|
|
||||||
i = buf.find(box_type, 4)
|
|
||||||
while i != -1:
|
|
||||||
size = int.from_bytes(buf[i - 4 : i], "big")
|
|
||||||
if 8 <= size <= len(buf) - (i - 4):
|
|
||||||
return buf[i - 4 : i - 4 + size]
|
|
||||||
i = buf.find(box_type, i + 1)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def strip_duplicate_init_boxes(src: Path, dst: Path) -> int:
|
def strip_duplicate_init_boxes(src: Path, dst: Path) -> int:
|
||||||
"""Write src to dst without its repeated ftyp/moov pairs, returning how many it dropped.
|
"""Write src to dst without its repeated ftyp/moov pairs, returning how many it dropped.
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from unshackle.core import binaries
|
|||||||
from unshackle.core.config import config
|
from unshackle.core.config import config
|
||||||
from unshackle.core.console import GradientPulseBarColumn, console
|
from unshackle.core.console import GradientPulseBarColumn, console
|
||||||
from unshackle.core.constants import AnyTrack, TrackT
|
from unshackle.core.constants import AnyTrack, TrackT
|
||||||
|
from unshackle.core.drm.verify import children, has_second_view, read_top_level_box
|
||||||
from unshackle.core.events import events
|
from unshackle.core.events import events
|
||||||
from unshackle.core.tracks.attachment import Attachment
|
from unshackle.core.tracks.attachment import Attachment
|
||||||
from unshackle.core.tracks.audio import Audio
|
from unshackle.core.tracks.audio import Audio
|
||||||
@@ -68,6 +69,12 @@ def file_track_ids(path: Path) -> dict[str, int]:
|
|||||||
return ids or {"video": 0, "audio": 1}
|
return ids or {"video": 0, "audio": 1}
|
||||||
|
|
||||||
|
|
||||||
|
def mp4_track_count(path: Path) -> int:
|
||||||
|
"""How many tracks ``MP4Box -add`` takes from a file: each ``trak`` of an MP4, or 1 for any other file."""
|
||||||
|
moov = read_top_level_box(path, b"moov") or b""
|
||||||
|
return sum(1 for kind, _, _ in children(moov, 8) if kind == b"trak") or 1
|
||||||
|
|
||||||
|
|
||||||
class Tracks:
|
class Tracks:
|
||||||
"""
|
"""
|
||||||
Video, Audio, Subtitle, Chapter, and Attachment Track Store.
|
Video, Audio, Subtitle, Chapter, and Attachment Track Store.
|
||||||
@@ -287,7 +294,17 @@ class Tracks:
|
|||||||
def signature(track: AnyTrack) -> tuple:
|
def signature(track: AnyTrack) -> tuple:
|
||||||
"""The attributes that listing and selection read. Two tracks with one signature look and select the same."""
|
"""The attributes that listing and selection read. Two tracks with one signature look and select the same."""
|
||||||
if isinstance(track, Video):
|
if isinstance(track, Video):
|
||||||
return Video, track.codec, track.range, track.width, track.height, track.fps, track.bitrate, track.name
|
return (
|
||||||
|
Video,
|
||||||
|
track.codec,
|
||||||
|
track.range,
|
||||||
|
track.stereo,
|
||||||
|
track.width,
|
||||||
|
track.height,
|
||||||
|
track.fps,
|
||||||
|
track.bitrate,
|
||||||
|
track.name,
|
||||||
|
)
|
||||||
if isinstance(track, Audio):
|
if isinstance(track, Audio):
|
||||||
return (
|
return (
|
||||||
Audio,
|
Audio,
|
||||||
@@ -333,11 +350,13 @@ class Tracks:
|
|||||||
def sort_videos(
|
def sort_videos(
|
||||||
self, by_language: Optional[Sequence[Union[str, Language]]] = None, exact_match: bool = False
|
self, by_language: Optional[Sequence[Union[str, Language]]] = None, exact_match: bool = False
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Sort video tracks by resolution then bitrate, and optionally language."""
|
"""Sort video tracks by resolution, 2D before 3D, then bitrate, and optionally language.
|
||||||
|
|
||||||
|
A 3D track carries two views, so its higher bitrate would otherwise rank it over the 2D one.
|
||||||
|
"""
|
||||||
if not self.videos:
|
if not self.videos:
|
||||||
return
|
return
|
||||||
# resolution first, then bitrate (unknown-bitrate tracks still rank by resolution)
|
self.videos.sort(key=lambda x: (x.height or 0, not x.stereo, float(x.bitrate or 0.0)), reverse=True)
|
||||||
self.videos.sort(key=lambda x: (x.height or 0, float(x.bitrate or 0.0)), reverse=True)
|
|
||||||
for language in reversed(by_language or []):
|
for language in reversed(by_language or []):
|
||||||
if str(language) in ("all", "best"):
|
if str(language) in ("all", "best"):
|
||||||
language = self._original_language(self.videos, exact_match)
|
language = self._original_language(self.videos, exact_match)
|
||||||
@@ -486,10 +505,10 @@ class Tracks:
|
|||||||
HDR10/HDR10+/DV tracks are hybrid ingredients. They only enter the standalone
|
HDR10/HDR10+/DV tracks are hybrid ingredients. They only enter the standalone
|
||||||
pool when the user explicitly requested their range alongside HYBRID, so for
|
pool when the user explicitly requested their range alongside HYBRID, so for
|
||||||
example `-r HYBRID` muxes only the hybrid while `-r HYBRID,HDR10P` also delivers
|
example `-r HYBRID` muxes only the hybrid while `-r HYBRID,HDR10P` also delivers
|
||||||
HDR10+.
|
HDR10+. A 3D track is never an ingredient: dovi_tool does not handle a second view.
|
||||||
"""
|
"""
|
||||||
ingredient_ranges = (Video.Range.HDR10, Video.Range.HDR10P, Video.Range.DV)
|
ingredient_ranges = (Video.Range.HDR10, Video.Range.HDR10P, Video.Range.DV)
|
||||||
hybrid_candidates = [v for v in videos if v.range in ingredient_ranges]
|
hybrid_candidates = [v for v in videos if v.range in ingredient_ranges and not v.stereo]
|
||||||
non_hybrid = [v for v in videos if v.range not in ingredient_ranges or v.range in non_hybrid_ranges]
|
non_hybrid = [v for v in videos if v.range not in ingredient_ranges or v.range in non_hybrid_ranges]
|
||||||
return hybrid_candidates, non_hybrid
|
return hybrid_candidates, non_hybrid
|
||||||
|
|
||||||
@@ -654,19 +673,21 @@ class Tracks:
|
|||||||
"""
|
"""
|
||||||
Multiplex the Tracks into an MP4 Container with MP4Box.
|
Multiplex the Tracks into an MP4 Container with MP4Box.
|
||||||
|
|
||||||
Only used for a codec Matroska cannot carry, DTS-UHD or MPEG-H, because MP4 carries
|
Only used for what Matroska cannot carry: DTS-UHD or MPEG-H audio, and stereoscopic 3D
|
||||||
far less: it drops the forced, hearing-impaired and original flags, holds no
|
(MV-HEVC) video, whose second view mkvmerge drops. MP4 carries far less: it drops the
|
||||||
attachments at all, and leaves out a subtitle it has no format for rather than
|
forced, hearing-impaired and original flags, holds no attachments at all, and leaves
|
||||||
mangling it. Losing those beats shipping audio no player can name.
|
out a subtitle it has no format for rather than mangling it. Losing those beats
|
||||||
|
shipping audio no player can name or a 3D film as 2D.
|
||||||
|
|
||||||
Returns the same (path, returncode, errors) shape as the Matroska mux.
|
Returns the same (path, returncode, errors) shape as the Matroska mux.
|
||||||
"""
|
"""
|
||||||
mp4box = getattr(binaries, "MP4Box", None)
|
mp4box = getattr(binaries, "MP4Box", None)
|
||||||
if not mp4box:
|
if not mp4box:
|
||||||
raise RuntimeError("MP4Box (GPAC) not found, muxing DTS-UHD or MPEG-H audio needs it")
|
raise RuntimeError("MP4Box (GPAC) not found, muxing DTS-UHD or MPEG-H audio or 3D video needs it")
|
||||||
|
|
||||||
progress = progress or partial(lambda **kwargs: None)
|
progress = progress or partial(lambda **kwargs: None)
|
||||||
|
|
||||||
|
stereo = any(v.stereo for v in self.videos)
|
||||||
cl = [str(mp4box)]
|
cl = [str(mp4box)]
|
||||||
sample_base = self.sample_base
|
sample_base = self.sample_base
|
||||||
names: list[str] = []
|
names: list[str] = []
|
||||||
@@ -685,7 +706,6 @@ class Tracks:
|
|||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
events.emit(events.Types.TRACK_MULTIPLEX, track=track)
|
events.emit(events.Types.TRACK_MULTIPLEX, track=track)
|
||||||
track_id += 1
|
|
||||||
|
|
||||||
source = track.path
|
source = track.path
|
||||||
candidate = config.directories.temp / f"mp4mux_{track.id}_{os.getpid()}_{threading.get_ident()}.mp4"
|
candidate = config.directories.temp / f"mp4mux_{track.id}_{os.getpid()}_{threading.get_ident()}.mp4"
|
||||||
@@ -706,7 +726,8 @@ class Tracks:
|
|||||||
cl.extend(["-add", f"{source}:lang={track.mux_language}" + (f":delay={delay}" if delay else "")])
|
cl.extend(["-add", f"{source}:lang={track.mux_language}" + (f":delay={delay}" if delay else "")])
|
||||||
name = "" if isinstance(track, Video) else (track.get_track_name() or "")
|
name = "" if isinstance(track, Video) else (track.get_track_name() or "")
|
||||||
if name:
|
if name:
|
||||||
names.extend(["-name", f"{track_id}={name}"])
|
names.extend(["-name", f"{track_id + 1}={name}"])
|
||||||
|
track_id += mp4_track_count(source)
|
||||||
|
|
||||||
if not track_id:
|
if not track_id:
|
||||||
raise ValueError("No tracks provided, at least one track must be provided.")
|
raise ValueError("No tracks provided, at least one track must be provided.")
|
||||||
@@ -719,7 +740,7 @@ class Tracks:
|
|||||||
title=sanitize_filename(title), random=f"{self.chapters.id}_{os.getpid()}_{threading.get_ident()}"
|
title=sanitize_filename(title), random=f"{self.chapters.id}_{os.getpid()}_{threading.get_ident()}"
|
||||||
)
|
)
|
||||||
self.chapters.dump(chapters_path, fallback_name=config.chapter_fallback_name)
|
self.chapters.dump(chapters_path, fallback_name=config.chapter_fallback_name)
|
||||||
cl.extend(["-chap", str(chapters_path)])
|
cl.extend(["-chapqt" if stereo else "-chap", str(chapters_path)])
|
||||||
|
|
||||||
if self.attachments:
|
if self.attachments:
|
||||||
log_event(
|
log_event(
|
||||||
@@ -749,7 +770,9 @@ class Tracks:
|
|||||||
"muxer": str(mp4box),
|
"muxer": str(mp4box),
|
||||||
"command": full_command,
|
"command": full_command,
|
||||||
"container": "mp4",
|
"container": "mp4",
|
||||||
"reason": "DTS-UHD or MPEG-H audio has no Matroska CodecID",
|
"reason": "3D video has no Matroska mapping"
|
||||||
|
if stereo
|
||||||
|
else "DTS-UHD or MPEG-H audio has no Matroska CodecID",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -778,7 +801,7 @@ class Tracks:
|
|||||||
returncode = p.wait()
|
returncode = p.wait()
|
||||||
if returncode < 2 and any(MP4BOX_OPEN_FAILED.search(line) for line in errors):
|
if returncode < 2 and any(MP4BOX_OPEN_FAILED.search(line) for line in errors):
|
||||||
returncode = 2
|
returncode = 2
|
||||||
if config.sample and returncode == 0:
|
if config.sample and returncode == 0 and not stereo:
|
||||||
start, end = (seconds - sample_base for seconds in config.sample)
|
start, end = (seconds - sample_base for seconds in config.sample)
|
||||||
cut_path = output_path.with_suffix(".cut.mp4")
|
cut_path = output_path.with_suffix(".cut.mp4")
|
||||||
try:
|
try:
|
||||||
@@ -791,6 +814,8 @@ class Tracks:
|
|||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
returncode = 2
|
returncode = 2
|
||||||
errors.append(f"MP4Box could not cut the sample window: {e}")
|
errors.append(f"MP4Box could not cut the sample window: {e}")
|
||||||
|
if stereo and returncode == 0 and not has_second_view(output_path):
|
||||||
|
logging.getLogger("Tracks").warning(f"The 3D mux of {output_path.name} lost its second view")
|
||||||
mux_duration_ms = round((time.monotonic() - mux_start_time) * 1000, 1)
|
mux_duration_ms = round((time.monotonic() - mux_start_time) * 1000, 1)
|
||||||
output_size = output_path.stat().st_size if output_path.exists() else 0
|
output_size = output_path.stat().st_size if output_path.exists() else 0
|
||||||
|
|
||||||
@@ -868,7 +893,9 @@ class Tracks:
|
|||||||
video.needs_repack = True
|
video.needs_repack = True
|
||||||
video.data["audio_language"] = lang_code
|
video.data["audio_language"] = lang_code
|
||||||
|
|
||||||
if any(at.path and at.path.exists() and has_mp4_only_audio_entry(at.path) for at in self.audio):
|
if any(v.stereo for v in self.videos) or any(
|
||||||
|
at.path and at.path.exists() and has_mp4_only_audio_entry(at.path) for at in self.audio
|
||||||
|
):
|
||||||
return self._mux_mp4(
|
return self._mux_mp4(
|
||||||
title=title,
|
title=title,
|
||||||
delete=delete,
|
delete=delete,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from langcodes import Language
|
|||||||
|
|
||||||
from unshackle.core import binaries
|
from unshackle.core import binaries
|
||||||
from unshackle.core.config import config
|
from unshackle.core.config import config
|
||||||
|
from unshackle.core.drm.verify import has_second_view
|
||||||
from unshackle.core.tracks.subtitle import Subtitle
|
from unshackle.core.tracks.subtitle import Subtitle
|
||||||
from unshackle.core.tracks.track import Track
|
from unshackle.core.tracks.track import Track
|
||||||
from unshackle.core.utilities import FPS, get_boxes, log_event
|
from unshackle.core.utilities import FPS, get_boxes, log_event
|
||||||
@@ -228,6 +229,7 @@ class Video(Track):
|
|||||||
dv_compatible_bitstream: bool = False,
|
dv_compatible_bitstream: bool = False,
|
||||||
hdr10plus: bool = False,
|
hdr10plus: bool = False,
|
||||||
hdr10_base: bool = False,
|
hdr10_base: bool = False,
|
||||||
|
stereo: bool = False,
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -248,6 +250,7 @@ class Video(Track):
|
|||||||
All strings will be cast to either a number or float.
|
All strings will be cast to either a number or float.
|
||||||
hdr10plus: The DV track also carries HDR10+ metadata. The HDR10+ probe sets it.
|
hdr10plus: The DV track also carries HDR10+ metadata. The HDR10+ probe sets it.
|
||||||
hdr10_base: The DV track has an HDR10 base layer, as DV profile 8.1 does.
|
hdr10_base: The DV track has an HDR10 base layer, as DV profile 8.1 does.
|
||||||
|
stereo: The track is stereoscopic 3D (MV-HEVC), with a second view for the other eye.
|
||||||
|
|
||||||
Note: If codec, bitrate, width, height, or fps is not specified, unshackle can
|
Note: If codec, bitrate, width, height, or fps is not specified, unshackle can
|
||||||
skip some checks or assume a value. Give as much information as possible.
|
skip some checks or assume a value. Give as much information as possible.
|
||||||
@@ -298,6 +301,7 @@ class Video(Track):
|
|||||||
self.dv_compatible_bitstream = dv_compatible_bitstream
|
self.dv_compatible_bitstream = dv_compatible_bitstream
|
||||||
self.hdr10plus = hdr10plus
|
self.hdr10plus = hdr10plus
|
||||||
self.hdr10_base = hdr10_base
|
self.hdr10_base = hdr10_base
|
||||||
|
self.stereo = stereo
|
||||||
self.hybrid_base_only = False
|
self.hybrid_base_only = False
|
||||||
self.native_hybrid = False
|
self.native_hybrid = False
|
||||||
|
|
||||||
@@ -315,6 +319,7 @@ class Video(Track):
|
|||||||
"dv_compatible_bitstream": self.dv_compatible_bitstream,
|
"dv_compatible_bitstream": self.dv_compatible_bitstream,
|
||||||
"hdr10plus": self.hdr10plus,
|
"hdr10plus": self.hdr10plus,
|
||||||
"hdr10_base": self.hdr10_base,
|
"hdr10_base": self.hdr10_base,
|
||||||
|
"stereo": self.stereo,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return data
|
return data
|
||||||
@@ -334,6 +339,7 @@ class Video(Track):
|
|||||||
dv_compatible_bitstream=data.get("dv_compatible_bitstream", False),
|
dv_compatible_bitstream=data.get("dv_compatible_bitstream", False),
|
||||||
hdr10plus=data.get("hdr10plus", False),
|
hdr10plus=data.get("hdr10plus", False),
|
||||||
hdr10_base=data.get("hdr10_base", False),
|
hdr10_base=data.get("hdr10_base", False),
|
||||||
|
stereo=data.get("stereo", False),
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -347,6 +353,16 @@ class Video(Track):
|
|||||||
"""Whether a codecs list names a Dolby Vision codec."""
|
"""Whether a codecs list names a Dolby Vision codec."""
|
||||||
return bool(Video.dv_fourccs(codecs))
|
return bool(Video.dv_fourccs(codecs))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_stereo(
|
||||||
|
codecs: Optional[str], supplemental_codecs: Optional[str] = None, video_layout: Optional[str] = None
|
||||||
|
) -> bool:
|
||||||
|
"""Whether a track is stereoscopic 3D: the HLS REQ-VIDEO-LAYOUT names CH-STEREO, or a codec is DV profile 20."""
|
||||||
|
tokens = re.split(r"[\s,]+", f"{codecs or ''},{supplemental_codecs or ''}".lower())
|
||||||
|
return "CH-STEREO" in (video_layout or "").upper() or any(
|
||||||
|
re.match(r"dvh[1e]\.20(\.|$)", token) for token in tokens
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def is_dv_composite(
|
def is_dv_composite(
|
||||||
codec: Optional[Video.Codec], codecs: Optional[str], supplemental_codecs: Optional[str]
|
codec: Optional[Video.Codec], codecs: Optional[str], supplemental_codecs: Optional[str]
|
||||||
@@ -377,12 +393,15 @@ class Video(Track):
|
|||||||
return f"{label}+DV" if self.dv_compatible_bitstream and self.range != Video.Range.DV else label
|
return f"{label}+DV" if self.dv_compatible_bitstream and self.range != Video.Range.DV else label
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
|
labels = filter(
|
||||||
|
None, [self.codec.value if self.codec else None, self.range_label, "3D" if self.stereo else None]
|
||||||
|
)
|
||||||
return " | ".join(
|
return " | ".join(
|
||||||
filter(
|
filter(
|
||||||
None,
|
None,
|
||||||
[
|
[
|
||||||
"VID",
|
"VID",
|
||||||
"[" + (", ".join(filter(None, [self.codec.value if self.codec else None, self.range_label]))) + "]",
|
f"[{', '.join(labels)}]",
|
||||||
str(self.language),
|
str(self.language),
|
||||||
", ".join(
|
", ".join(
|
||||||
filter(
|
filter(
|
||||||
@@ -459,16 +478,34 @@ class Video(Track):
|
|||||||
self.path = output_path
|
self.path = output_path
|
||||||
original_path.unlink()
|
original_path.unlink()
|
||||||
|
|
||||||
|
def download(self, *args: Any, **kwargs: Any) -> None:
|
||||||
|
"""Download the track and mark it 3D when the file holds a second view; a 3D track is never repacked."""
|
||||||
|
super().download(*args, **kwargs)
|
||||||
|
if not self.stereo and self.path and has_second_view(self.path):
|
||||||
|
self.stereo = True
|
||||||
|
if self.stereo:
|
||||||
|
self.needs_repack = False
|
||||||
|
|
||||||
|
def repackage(self, bsf_v: Optional[str] = None) -> bool:
|
||||||
|
"""Remux with FFmpeg, except a 3D track: an FFmpeg remux rewrites its stereo and DV boxes."""
|
||||||
|
if self.stereo:
|
||||||
|
logging.getLogger("Video").warning(
|
||||||
|
f"Skipped the FFmpeg repack of 3D track {self.id}: FFmpeg rewrites its stereo boxes"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
return super().repackage(bsf_v)
|
||||||
|
|
||||||
def vui_bsf(self) -> Optional[str]:
|
def vui_bsf(self) -> Optional[str]:
|
||||||
"""Return the ``-bsf:v`` value that rewrites SPS VUI colour metadata to match ``self.range``.
|
"""Return the ``-bsf:v`` value that rewrites SPS VUI colour metadata to match ``self.range``.
|
||||||
|
|
||||||
Returns None when a rewrite is not necessary: SDR/DV/HYBRID ranges, non-AVC/HEVC codecs, a
|
Returns None when a rewrite is not necessary or not safe: SDR/DV/HYBRID ranges, non-AVC/HEVC
|
||||||
missing file, or when the bitstream already ships the correct colour metadata. Otherwise
|
codecs, a 3D track (an FFmpeg remux rewrites its stereo and DV boxes), a missing file, or when the
|
||||||
|
bitstream already ships the correct colour metadata. Otherwise
|
||||||
returns a ``{h264,hevc}_metadata=...`` string suitable for FFmpeg ``-bsf:v``.
|
returns a ``{h264,hevc}_metadata=...`` string suitable for FFmpeg ``-bsf:v``.
|
||||||
"""
|
"""
|
||||||
if not self.path or not self.path.exists():
|
if not self.path or not self.path.exists():
|
||||||
return None
|
return None
|
||||||
if self.codec not in (Video.Codec.AVC, Video.Codec.HEVC):
|
if self.codec not in (Video.Codec.AVC, Video.Codec.HEVC) or self.stereo:
|
||||||
return None
|
return None
|
||||||
if self.range in (Video.Range.SDR, Video.Range.DV, Video.Range.HYBRID):
|
if self.range in (Video.Range.SDR, Video.Range.DV, Video.Range.HYBRID):
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -22,12 +22,12 @@ tag: "NOGRP"
|
|||||||
# ? suffix, e.g. {year?}.
|
# ? suffix, e.g. {year?}.
|
||||||
output_template:
|
output_template:
|
||||||
# Scene-style naming (dot-separated)
|
# Scene-style naming (dot-separated)
|
||||||
movies: "{title}.{year?}.{repack?}.{edition?}.{lang_tag?}.{quality}.{source}.WEB-DL.{dual?}.{multi?}.{dubbed?}.{audio_full}.{atmos?}.{hdr?}.{hfr?}.{video}-{tag}"
|
movies: "{title}.{year?}.{repack?}.{edition?}.{lang_tag?}.{3d?}.{quality}.{source}.WEB-DL.{dual?}.{multi?}.{dubbed?}.{audio_full}.{atmos?}.{hdr?}.{hfr?}.{video}-{tag}"
|
||||||
series: "{title}.{year?}.{season_episode}.{episode_name?}.{repack?}.{edition?}.{lang_tag?}.{quality}.{source}.WEB-DL.{dual?}.{multi?}.{dubbed?}.{audio_full}.{atmos?}.{hdr?}.{hfr?}.{video}-{tag}"
|
series: "{title}.{year?}.{season_episode}.{episode_name?}.{repack?}.{edition?}.{lang_tag?}.{3d?}.{quality}.{source}.WEB-DL.{dual?}.{multi?}.{dubbed?}.{audio_full}.{atmos?}.{hdr?}.{hfr?}.{video}-{tag}"
|
||||||
songs: "{track_number}.{title}.{repack?}.{edition?}.{lang_tag?}.{source?}.WEB-DL.{audio_full}.{atmos?}-{tag}"
|
songs: "{track_number}.{title}.{repack?}.{edition?}.{lang_tag?}.{source?}.WEB-DL.{audio_full}.{atmos?}-{tag}"
|
||||||
folder:
|
folder:
|
||||||
movies: "{title}.{year?}.{repack?}.{edition?}.{lang_tag?}.{quality}.{source}.WEB-DL.{dual?}.{multi?}.{dubbed?}.{audio_full}.{atmos?}.{hdr?}.{hfr?}.{video}-{tag}"
|
movies: "{title}.{year?}.{repack?}.{edition?}.{lang_tag?}.{3d?}.{quality}.{source}.WEB-DL.{dual?}.{multi?}.{dubbed?}.{audio_full}.{atmos?}.{hdr?}.{hfr?}.{video}-{tag}"
|
||||||
series: "{title}.{year?}.{repack?}.{edition?}.{lang_tag?}.{quality}.{source}.WEB-DL.{dual?}.{multi?}.{dubbed?}.{audio_full}.{atmos?}.{hdr?}.{hfr?}.{video}-{tag}"
|
series: "{title}.{year?}.{repack?}.{edition?}.{lang_tag?}.{3d?}.{quality}.{source}.WEB-DL.{dual?}.{multi?}.{dubbed?}.{audio_full}.{atmos?}.{hdr?}.{hfr?}.{video}-{tag}"
|
||||||
albums: "{album_artist} - {album} ({year?})"
|
albums: "{album_artist} - {album} ({year?})"
|
||||||
|
|
||||||
#
|
#
|
||||||
|
|||||||
Reference in New Issue
Block a user