Add native music video muxing

This commit is contained in:
Rafael Moraes
2026-05-20 07:57:52 -03:00
parent cbd161038e
commit 48df71271b
9 changed files with 1558 additions and 431 deletions
+11 -25
View File
@@ -31,23 +31,18 @@ A command-line app for downloading Apple Music songs, music videos and post vide
Add these tools to your system PATH or specify their paths via command-line arguments or the config file. The tools needed depend on which audio quality, video format, and download mode you want. Use the table below to find the required tools for your use case:
| Use Case | Configuration | Required Tools |
|---|---|---|
| **Songs in Legacy Codecs** | `song_codec_priority: aac-legacy\|aac-he-legacy` | None |
| **Songs in Non Legacy Codecs** | `song_codec_priority: aac\|aac-he\|aac-binaural\|aac-downmix\|aac-he-binaural\|aac-he-downmix\|atmos\|ac3`<br/>`use_wrapper: true` | Wrapper |
| **Music Videos** | `music_video_remux_mode: ffmpeg` | FFmpeg<br/>mp4decrypt |
| | `music_video_remux_mode: mp4box` | MP4Box<br/>mp4decrypt |
| **Faster Downloads** | `download_mode: nm3u8dlre` | N_m3u8DL-RE |
| Use Case | Configuration | Required Tools |
| ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------- | -------------- |
| **Songs in Legacy Codecs** | `song_codec_priority: aac-legacy\|aac-he-legacy` | None |
| **Songs in Non Legacy Codecs** | `song_codec_priority: aac\|aac-he\|aac-binaural\|aac-downmix\|aac-he-binaural\|aac-he-downmix\|atmos\|ac3`<br/>`use_wrapper: true` | Wrapper |
| **Faster Downloads** | `download_mode: nm3u8dlre` | N_m3u8DL-RE |
#### Tool Reference
| Tool | Download | Purpose |
|---|---|---|
| **FFmpeg** | [Windows](https://github.com/AnimMouse/ffmpeg-stable-autobuild/releases) / [Linux](https://johnvansickle.com/ffmpeg/) | Required for music video remuxing with FFmpeg mode |
| **MP4Box** | [Download](https://gpac.io/downloads/gpac-nightly-builds/) | Alternative for music video remuxing |
| **mp4decrypt** | [Download](https://www.bento4.com/downloads/) | Decrypts MP4 files when used with MP4Box |
| **N_m3u8DL-RE** | [Download](https://github.com/nilaoda/N_m3u8DL-RE/releases/latest) | Faster download alternative |
| **Wrapper** | [Download](https://github.com/WorldObservationLog/wrapper) | For downloading songs in ALAC and other experimental codecs |
| Tool | Download | Purpose |
| --------------- | ------------------------------------------------------------------ | ----------------------------------------------------------- |
| **N_m3u8DL-RE** | [Download](https://github.com/nilaoda/N_m3u8DL-RE/releases/latest) | Faster download alternative |
| **Wrapper** | [Download](https://github.com/WorldObservationLog/wrapper) | For downloading songs in ALAC and other experimental codecs |
## 📦 Installation
@@ -152,7 +147,6 @@ The file is created automatically on first run. Command-line arguments override
| **Music Video Options** | | |
| `--music-video-resolution` | Max music video resolution | `1080p` |
| `--music-video-codec-priority` | Comma-separated codec priority | `h264,h265` |
| `--music-video-remux-mode` | Remux mode | `ffmpeg` |
| `--music-video-remux-format` | Music video remux format | `m4v` |
| **Post Video Options** | | |
| `--uploaded-video-quality` | Post video quality | `best` |
@@ -160,9 +154,6 @@ The file is created automatically on first run. Command-line arguments override
| `--output-path`, `-o` | Output directory path | `./Apple Music` |
| `--temp-path` | Temporary directory path | `.` |
| `--nm3u8dlre-path` | N_m3u8DL-RE executable path | `N_m3u8DL-RE` |
| `--mp4decrypt-path` | mp4decrypt executable path | `mp4decrypt` |
| `--ffmpeg-path` | FFmpeg executable path | `ffmpeg` |
| `--mp4box-path` | MP4Box executable path | `MP4Box` |
| `--use-wrapper` | Use wrapper for decrypting songs | `false` |
| `--wrapper-decrypt-ip` | Wrapper decryption server IP | `127.0.0.1:10020` |
| `--download-mode` | Download mode | `ytdlp` |
@@ -183,7 +174,6 @@ The file is created automatically on first run. Command-line arguments override
| `--save-cover`, `-s` | Save cover as separate file | `false` |
| `--save-playlist` | Save M3U8 playlist file | `false` |
### Template Variables
**Tags for templates and exclude-tags:**
@@ -213,13 +203,9 @@ The file is created automatically on first run. Command-line arguments override
- `ytdlp`, `nm3u8dlre`
> [!NOTE]
>
> - **yt-dlp is only used as a file download library**. Media is still fetched directly from Apple Music's servers, and yt-dlp is only responsible for handling the file download process.
### Remux Mode
- `ffmpeg`
- `mp4box` - Preserve the original closed caption track in music videos and some other minor metadata
### Cover Format
- `jpg`
@@ -375,7 +361,7 @@ async def main():
# Download from URL
url = "https://music.apple.com/us/album/never-gonna-give-you-up-2022-remaster/1624945511?i=1624945512"
download_queue = []
async for media in downloader.get_download_item_from_url(url):
download_queue.append(media)
-4
View File
@@ -173,9 +173,6 @@ async def main(config: CliConfig):
output_path=config.output_path,
temp_path=config.temp_path,
nm3u8dlre_path=config.nm3u8dlre_path,
mp4decrypt_path=config.mp4decrypt_path,
ffmpeg_path=config.ffmpeg_path,
mp4box_path=config.mp4box_path,
download_mode=config.download_mode,
album_folder_template=config.album_folder_template,
compilation_folder_template=config.compilation_folder_template,
@@ -195,7 +192,6 @@ async def main(config: CliConfig):
)
music_video_downloader = AppleMusicMusicVideoDownloader(
base=base_downloader,
remux_mode=config.music_video_remux_mode,
remux_format=config.music_video_remux_format,
)
uploaded_video_downloader = AppleMusicUploadedVideoDownloader(
-33
View File
@@ -312,30 +312,6 @@ class CliConfig:
default=base_downloader_sig.parameters["nm3u8dlre_path"].default,
),
]
mp4decrypt_path: Annotated[
str,
option(
"--mp4decrypt-path",
help="mp4decrypt executable path",
default=base_downloader_sig.parameters["mp4decrypt_path"].default,
),
]
ffmpeg_path: Annotated[
str,
option(
"--ffmpeg-path",
help="FFmpeg executable path",
default=base_downloader_sig.parameters["ffmpeg_path"].default,
),
]
mp4box_path: Annotated[
str,
option(
"--mp4box-path",
help="MP4Box executable path",
default=base_downloader_sig.parameters["mp4box_path"].default,
),
]
download_mode: Annotated[
DownloadMode,
option(
@@ -437,15 +413,6 @@ class CliConfig:
),
]
# DownloaderMusicVideo specific options
music_video_remux_mode: Annotated[
RemuxMode,
option(
"--music-video-remux-mode",
help="Remux mode",
default=music_video_downloader_sig.parameters["remux_mode"].default,
type=RemuxMode,
),
]
music_video_remux_format: Annotated[
RemuxFormatMusicVideo,
option(
+1 -1
View File
@@ -1,4 +1,4 @@
from .amdecrypt import decrypt_file, decrypt_file_hex
from .amdecrypt import decrypt_file_hex, decrypt_wrapper, write_decrypted_media
from .base import AppleMusicBaseDownloader
from .downloader import AppleMusicDownloader
from .enums import *
File diff suppressed because it is too large Load Diff
-14
View File
@@ -24,9 +24,6 @@ class AppleMusicBaseDownloader:
output_path: str = "./Apple Music",
temp_path: str = ".",
nm3u8dlre_path: str = "N_m3u8DL-RE",
mp4decrypt_path: str = "mp4decrypt",
ffmpeg_path: str = "ffmpeg",
mp4box_path: str = "MP4Box",
download_mode: DownloadMode = DownloadMode.YTDLP,
album_folder_template: str = "{album_artist}/{album}",
compilation_folder_template: str = "Compilations/{album}",
@@ -45,9 +42,6 @@ class AppleMusicBaseDownloader:
self.output_path = output_path
self.temp_path = temp_path
self.nm3u8dlre_path = nm3u8dlre_path
self.mp4decrypt_path = mp4decrypt_path
self.ffmpeg_path = ffmpeg_path
self.mp4box_path = mp4box_path
self.download_mode = download_mode
self.album_folder_template = album_folder_template
self.compilation_folder_template = compilation_folder_template
@@ -68,16 +62,10 @@ class AppleMusicBaseDownloader:
log = logger.bind(action="initialize_binary_paths")
self.full_nm3u8dlre_path = shutil.which(self.nm3u8dlre_path)
self.full_mp4decrypt_path = shutil.which(self.mp4decrypt_path)
self.full_ffmpeg_path = shutil.which(self.ffmpeg_path)
self.full_mp4box_path = shutil.which(self.mp4box_path)
log = log.debug(
"success",
full_nm3u8dlre_path=self.full_nm3u8dlre_path,
full_mp4decrypt_path=self.full_mp4decrypt_path,
full_ffmpeg_path=self.full_ffmpeg_path,
full_mp4box_path=self.full_mp4box_path,
)
def get_temp_path(
@@ -244,8 +232,6 @@ class AppleMusicBaseDownloader:
"--no-log",
"--log-level",
"off",
"--ffmpeg-binary-path",
self.full_ffmpeg_path,
"--save-name",
download_path_obj.stem,
"--save-dir",
+1 -16
View File
@@ -6,7 +6,7 @@ import structlog
from ..interface.types import AppleMusicMedia
from .constants import TEMP_PATH_TEMPLATE
from .enums import DownloadMode, RemuxMode
from .enums import DownloadMode
from .exceptions import (
GamdlDownloaderDependencyNotFoundError,
GamdlDownloaderMediaFileExistsError,
@@ -215,21 +215,6 @@ class AppleMusicDownloader:
"music-videos",
"library-music-videos",
}:
if not self.base.full_mp4decrypt_path:
raise GamdlDownloaderDependencyNotFoundError("mp4decrypt")
if (
self.music_video.remux_mode == RemuxMode.FFMPEG
and not self.base.full_ffmpeg_path
):
raise GamdlDownloaderDependencyNotFoundError("FFmpeg")
if (
self.music_video.remux_mode == RemuxMode.MP4BOX
and not self.base.full_mp4box_path
):
raise GamdlDownloaderDependencyNotFoundError("MP4Box")
await self.music_video.download(item)
elif item.media.media_metadata["type"] in {"uploaded-videos"}:
+12 -102
View File
@@ -2,7 +2,7 @@ from pathlib import Path
from ..interface.enums import CoverFormat
from ..interface.types import AppleMusicMedia, DecryptionKeyAv
from ..utils import async_subprocess
from .amdecrypt import decrypt_file_hex, write_decrypted_media
from .base import AppleMusicBaseDownloader
from .enums import RemuxFormatMusicVideo, RemuxMode
from .types import DownloadItem
@@ -12,106 +12,30 @@ class AppleMusicMusicVideoDownloader:
def __init__(
self,
base: AppleMusicBaseDownloader,
remux_mode: RemuxMode = RemuxMode.FFMPEG,
remux_format: RemuxFormatMusicVideo = RemuxFormatMusicVideo.M4V,
):
self.base = base
self.remux_mode = remux_mode
self.remux_format = remux_format
async def _remux_mp4box(
self,
input_path_video: str,
input_path_audio: str,
output_path: str,
):
await async_subprocess(
self.base.full_mp4box_path,
"-quiet",
"-add",
input_path_audio,
"-add",
input_path_video,
"-itags",
"artist=placeholder",
"-keep-utc",
"-new",
output_path,
silent=self.base.silent,
)
async def _remux_ffmpeg(
self,
input_path_video: str,
input_path_audio: str,
output_path: str,
):
await async_subprocess(
self.base.full_ffmpeg_path,
"-loglevel",
"error",
"-y",
"-i",
input_path_video,
"-i",
input_path_audio,
"-c",
"copy",
"-c:s",
"mov_text",
"-movflags",
"+faststart",
output_path,
silent=self.base.silent,
)
async def _decrypt_mp4decrypt(
self,
input_path: str,
output_path: str,
decryption_key: str,
):
await async_subprocess(
self.base.full_mp4decrypt_path,
"--key",
f"1:{decryption_key}",
input_path,
output_path,
silent=self.base.silent,
)
async def stage(
self,
encrypted_path_video: str,
encrypted_path_audio: str,
decrypted_path_video: str,
decrypted_path_audio: str,
staged_path: str,
decryption_key: DecryptionKeyAv,
is_m4v: bool = False,
):
await self._decrypt_mp4decrypt(
encrypted_path_video,
decrypted_path_video,
decryption_key.video_track.key,
)
await self._decrypt_mp4decrypt(
encrypted_path_audio,
decrypted_path_audio,
decrypted_media = await decrypt_file_hex(
decryption_key.audio_track.key,
encrypted_path_audio,
decryption_key.video_track.key,
encrypted_path_video,
)
await write_decrypted_media(
decrypted_media,
staged_path,
m4v_brand=is_m4v,
)
if self.remux_mode == RemuxMode.MP4BOX:
await self._remux_mp4box(
decrypted_path_video,
decrypted_path_audio,
staged_path,
)
else:
await self._remux_ffmpeg(
decrypted_path_video,
decrypted_path_audio,
staged_path,
)
def get_cover_path(
self,
@@ -177,26 +101,12 @@ class AppleMusicMusicVideoDownloader:
encrypted_path_audio,
)
decrypted_path_video = self.base.get_temp_path(
download_item.media.media_metadata["id"],
download_item.uuid_,
"decrypted_video",
".mp4",
)
decrypted_path_audio = self.base.get_temp_path(
download_item.media.media_metadata["id"],
download_item.uuid_,
"decrypted_audio",
".m4a",
)
await self.stage(
encrypted_path_video,
encrypted_path_audio,
decrypted_path_video,
decrypted_path_audio,
download_item.staged_path,
download_item.media.decryption_key,
download_item.staged_path.endswith(".m4v"),
)
cover_bytes = (
+20 -20
View File
@@ -4,7 +4,7 @@ import structlog
from ..interface.enums import CoverFormat
from ..interface.types import AppleMusicMedia, DecryptionKeyAv
from .amdecrypt import decrypt_file, decrypt_file_hex
from .amdecrypt import decrypt_file_hex, decrypt_wrapper, write_decrypted_media
from .base import AppleMusicBaseDownloader
from .types import DownloadItem
@@ -55,30 +55,30 @@ class AppleMusicSongDownloader:
self,
input_path: str,
output_path: str,
media_id: str,
fairplay_key: str,
) -> None:
await decrypt_file(
self.base.interface.base.wrapper_url + "/decrypt",
media_id,
fairplay_key,
input_path,
output_path,
)
media_id: str,
fairplay_key: str,
) -> None:
decrypted_media = await decrypt_wrapper(
self.base.interface.base.wrapper_url + "/decrypt",
media_id,
input_path,
fairplay_key_audio=fairplay_key,
)
await write_decrypted_media(decrypted_media, output_path)
async def _decrypt_amdecrypt_hex(
self,
input_path: str,
output_path: str,
decryption_key: str,
legacy: bool = False,
) -> None:
await decrypt_file_hex(
input_path,
output_path,
decryption_key,
legacy=legacy,
)
decryption_key: str,
legacy: bool = False,
) -> None:
decrypted_media = await decrypt_file_hex(
decryption_key,
input_path,
legacy=legacy,
)
await write_decrypted_media(decrypted_media, output_path)
async def stage(
self,