mirror of
https://github.com/glomatico/gamdl.git
synced 2026-06-13 20:25:13 +03:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f6d726e466 | |||
| 61b1bf1e55 | |||
| 3ae6709ccb | |||
| 1f00e4fb9f | |||
| 714d47bb13 | |||
| 46e3a92d4f | |||
| 42b536d271 | |||
| dac8d5eed9 | |||
| 2956f20dfa | |||
| 8f76743a3b | |||
| 3096bbc79d | |||
| ed49d7bd5f | |||
| 0ea72d0b78 | |||
| ae490320ad | |||
| e40668e6ec | |||
| 62c695b5ff | |||
| 5d9c8c1f0b | |||
| 54d640230a | |||
| 3d272a6891 | |||
| e99ed0eb5a | |||
| 86b5029773 | |||
| 3df0a91d3f | |||
| d356596cf4 | |||
| cbd2df79b7 |
@@ -29,7 +29,7 @@ A Python script to download Apple Music songs/music videos/albums/playlists/post
|
||||
```bash
|
||||
pip install gamdl
|
||||
```
|
||||
2. Place your cookies in the same directory you will run the script from and name it as `cookies.txt`
|
||||
2. Place your cookies in the same directory you will run gamdl from and name it as `cookies.txt`
|
||||
|
||||
## Usage
|
||||
* Download a song
|
||||
@@ -144,8 +144,10 @@ The following codecs are available:
|
||||
* `aac-he-downmix`
|
||||
* `alac`
|
||||
* `atmos`
|
||||
* `ask`
|
||||
* When using this option, the script will ask you which **non-legacy** codec to use.
|
||||
|
||||
**Support for non-legacy codecs are not guaranteed, as most of the songs cannot be decrypted when using non-legacy codecs.**
|
||||
**Support for non-legacy codecs are not guaranteed, as most of the songs cannot be downloaded when using non-legacy codecs.**
|
||||
|
||||
### Music videos codecs
|
||||
The following codecs are available:
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
__version__ = "2.0"
|
||||
__version__ = "2.1.4"
|
||||
|
||||
+17
-16
@@ -419,6 +419,11 @@ def main(
|
||||
skip_mv = True
|
||||
else:
|
||||
skip_mv = False
|
||||
if codec_song not in LEGACY_CODECS:
|
||||
logger.warn(
|
||||
"You have chosen a non-legacy codec. Support for non-legacy codecs are not guaranteed, "
|
||||
"as most of the songs cannot be downloaded when using non-legacy codecs."
|
||||
)
|
||||
error_count = 0
|
||||
if read_urls_as_txt:
|
||||
urls = [url.strip() for url in Path(urls[0]).read_text().splitlines()]
|
||||
@@ -477,10 +482,7 @@ def main(
|
||||
f'({queue_progress}) Song already exists at "{final_path}", skipping'
|
||||
)
|
||||
else:
|
||||
if codec_song in (
|
||||
SongCodec.AAC_LEGACY,
|
||||
SongCodec.AAC_HE_LEGACY,
|
||||
):
|
||||
if codec_song in LEGACY_CODECS:
|
||||
logger.debug("Getting stream info")
|
||||
stream_info = downloader_song_legacy.get_stream_info(
|
||||
webplayback
|
||||
@@ -491,14 +493,10 @@ def main(
|
||||
)
|
||||
else:
|
||||
stream_info = downloader_song.get_stream_info(track)
|
||||
if not stream_info.pssh:
|
||||
if not stream_info.stream_url or not stream_info.pssh:
|
||||
logger.warning(
|
||||
f"({queue_progress}) Song does not contain Widevine DRM, skipping"
|
||||
)
|
||||
continue
|
||||
elif not stream_info.stream_url:
|
||||
logger.warning(
|
||||
f"({queue_progress}) Song is not available with the selected codec, skipping"
|
||||
f"({queue_progress}) Song is not downloadable or is not"
|
||||
" available in the chosen codec, skipping"
|
||||
)
|
||||
continue
|
||||
logger.debug("Getting decryption key")
|
||||
@@ -510,10 +508,7 @@ def main(
|
||||
remuxed_path = downloader_song.get_remuxed_path(track["id"])
|
||||
logger.debug(f"Downloading to {encrypted_path}")
|
||||
downloader.download(encrypted_path, stream_info.stream_url)
|
||||
if codec_song in (
|
||||
SongCodec.AAC_LEGACY,
|
||||
SongCodec.AAC_HE_LEGACY,
|
||||
):
|
||||
if codec_song in LEGACY_CODECS:
|
||||
logger.debug(f"Remuxing/Decrypting to {remuxed_path}")
|
||||
downloader_song_legacy.remux(
|
||||
encrypted_path,
|
||||
@@ -527,7 +522,11 @@ def main(
|
||||
encrypted_path, decrypted_path, decryption_key
|
||||
)
|
||||
logger.debug(f"Remuxing to {final_path}")
|
||||
downloader_song.remux(decrypted_path, remuxed_path)
|
||||
downloader_song.remux(
|
||||
decrypted_path,
|
||||
remuxed_path,
|
||||
stream_info.codec,
|
||||
)
|
||||
logger.debug("Applying tags")
|
||||
downloader.apply_tags(remuxed_path, tags, cover_url)
|
||||
logger.debug(f"Moving to {final_path}")
|
||||
@@ -635,6 +634,8 @@ def main(
|
||||
decrypted_path_video,
|
||||
decrypted_path_audio,
|
||||
remuxed_path,
|
||||
stream_info_video.codec,
|
||||
stream_info_audio.codec,
|
||||
)
|
||||
logger.debug("Applying tags")
|
||||
downloader.apply_tags(remuxed_path, tags, cover_url)
|
||||
|
||||
+4
-1
@@ -218,4 +218,7 @@ EXCLUDED_CONFIG_FILE_PARAMS = (
|
||||
|
||||
X_NOT_FOUND_STRING = '{} not found at "{}"'
|
||||
|
||||
AMP_API_HOSTNAME = "https://amp-api.music.apple.com"
|
||||
LEGACY_CODECS = [
|
||||
SongCodec.AAC_LEGACY,
|
||||
SongCodec.AAC_HE_LEGACY,
|
||||
]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import re
|
||||
import subprocess
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
@@ -13,6 +14,8 @@ from .models import StreamInfo
|
||||
|
||||
|
||||
class DownloaderMusicVideo:
|
||||
MP4_FORMAT_CODECS = ["hvc1", "ec-3"]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
downloader: Downloader,
|
||||
@@ -33,10 +36,10 @@ class DownloaderMusicVideo:
|
||||
).geturl()
|
||||
return m3u8.load(stream_url_master_new).data
|
||||
|
||||
def get_stream_url_video(
|
||||
def get_playlist_video(
|
||||
self,
|
||||
playlists: list[dict],
|
||||
):
|
||||
) -> dict:
|
||||
playlists_filtered = [
|
||||
playlist
|
||||
for playlist in playlists
|
||||
@@ -53,12 +56,12 @@ class DownloaderMusicVideo:
|
||||
)
|
||||
]
|
||||
playlists_filtered.sort(key=lambda x: x["stream_info"]["bandwidth"])
|
||||
return playlists_filtered[-1]["uri"]
|
||||
return playlists_filtered[-1]
|
||||
|
||||
def get_stream_url_video_from_user(
|
||||
def get_playlist_video_from_user(
|
||||
self,
|
||||
playlists: list[dict],
|
||||
):
|
||||
) -> dict:
|
||||
table = [
|
||||
[
|
||||
i,
|
||||
@@ -76,12 +79,12 @@ class DownloaderMusicVideo:
|
||||
)
|
||||
except click.exceptions.Abort:
|
||||
raise KeyboardInterrupt()
|
||||
return playlists[choice]["uri"]
|
||||
return playlists[choice]
|
||||
|
||||
def get_stream_url_audio(
|
||||
def get_playlist_audio(
|
||||
self,
|
||||
playlists: list[dict],
|
||||
) -> str:
|
||||
) -> dict:
|
||||
stream_url = next(
|
||||
(
|
||||
playlist
|
||||
@@ -89,13 +92,13 @@ class DownloaderMusicVideo:
|
||||
if playlist["group_id"] == "audio-stereo-256"
|
||||
),
|
||||
None,
|
||||
)["uri"]
|
||||
)
|
||||
return stream_url
|
||||
|
||||
def get_stream_url_audio_from_user(
|
||||
def get_playlist_audio_from_user(
|
||||
self,
|
||||
playlists: list[dict],
|
||||
):
|
||||
) -> dict:
|
||||
table = [
|
||||
[
|
||||
i,
|
||||
@@ -113,7 +116,7 @@ class DownloaderMusicVideo:
|
||||
)
|
||||
except click.exceptions.Abort:
|
||||
raise KeyboardInterrupt()
|
||||
return playlists[choice]["uri"]
|
||||
return playlists[choice]
|
||||
|
||||
def get_pssh(self, m3u8_data: dict):
|
||||
return next(
|
||||
@@ -128,13 +131,11 @@ class DownloaderMusicVideo:
|
||||
def get_stream_info_video(self, m3u8_master_data: dict) -> StreamInfo:
|
||||
stream_info = StreamInfo()
|
||||
if self.codec != MusicVideoCodec.ASK:
|
||||
stream_info.stream_url = self.get_stream_url_video(
|
||||
m3u8_master_data["playlists"]
|
||||
)
|
||||
playlist = self.get_playlist_video(m3u8_master_data["playlists"])
|
||||
else:
|
||||
stream_info.stream_url = self.get_stream_url_video_from_user(
|
||||
m3u8_master_data["playlists"]
|
||||
)
|
||||
playlist = self.get_playlist_video_from_user(m3u8_master_data["playlists"])
|
||||
stream_info.stream_url = playlist["uri"]
|
||||
stream_info.codec = playlist["stream_info"]["codecs"]
|
||||
m3u8_data = m3u8.load(stream_info.stream_url).data
|
||||
stream_info.pssh = self.get_pssh(m3u8_data)
|
||||
return stream_info
|
||||
@@ -142,13 +143,13 @@ class DownloaderMusicVideo:
|
||||
def get_stream_info_audio(self, m3u8_master_data: dict) -> StreamInfo:
|
||||
stream_info = StreamInfo()
|
||||
if self.codec != MusicVideoCodec.ASK:
|
||||
stream_info.stream_url = self.get_stream_url_audio(
|
||||
m3u8_master_data["media"]
|
||||
)
|
||||
playlist = self.get_playlist_audio(m3u8_master_data["media"])
|
||||
else:
|
||||
stream_info.stream_url = self.get_stream_url_audio_from_user(
|
||||
m3u8_master_data["media"]
|
||||
)
|
||||
playlist = self.get_playlist_audio_from_user(m3u8_master_data["media"])
|
||||
stream_info.stream_url = playlist["uri"]
|
||||
stream_info.codec = re.search(r"_([^_]+)\.m3u8", stream_info.stream_url).group(
|
||||
1
|
||||
)
|
||||
m3u8_data = m3u8.load(stream_info.stream_url).data
|
||||
stream_info.pssh = self.get_pssh(m3u8_data)
|
||||
return stream_info
|
||||
@@ -255,7 +256,12 @@ class DownloaderMusicVideo:
|
||||
decrypted_path_video: Path,
|
||||
decrypte_path_audio: Path,
|
||||
fixed_path: Path,
|
||||
codec_video: str,
|
||||
codec_audio: str,
|
||||
):
|
||||
use_mp4_flag = any(
|
||||
codec_video.startswith(codec) for codec in self.MP4_FORMAT_CODECS
|
||||
) or any(codec_audio.startswith(codec) for codec in self.MP4_FORMAT_CODECS)
|
||||
subprocess.run(
|
||||
[
|
||||
self.downloader.ffmpeg_path_full,
|
||||
@@ -269,7 +275,7 @@ class DownloaderMusicVideo:
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
"-f",
|
||||
"mp4",
|
||||
"mp4" if use_mp4_flag else "ipod",
|
||||
"-c",
|
||||
"copy",
|
||||
"-c:s",
|
||||
@@ -284,6 +290,8 @@ class DownloaderMusicVideo:
|
||||
decrypted_path_video: Path,
|
||||
decrypted_path_audio: Path,
|
||||
remuxed_path: Path,
|
||||
codec_video: str,
|
||||
codec_audio: str,
|
||||
):
|
||||
if self.downloader.remux_mode == RemuxMode.MP4BOX:
|
||||
self.remux_mp4box(
|
||||
@@ -296,6 +304,8 @@ class DownloaderMusicVideo:
|
||||
decrypted_path_video,
|
||||
decrypted_path_audio,
|
||||
remuxed_path,
|
||||
codec_video,
|
||||
codec_audio,
|
||||
)
|
||||
|
||||
def get_cover_path(self, final_path: Path) -> Path:
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import datetime
|
||||
import json
|
||||
@@ -19,6 +21,7 @@ from .models import Lyrics, StreamInfo
|
||||
|
||||
class DownloaderSong:
|
||||
DEFAULT_DECRYPTION_KEY = "32b8ade1769e26b1ffb8986352793fc6"
|
||||
MP4_FORMAT_CODECS = ["ec-3"]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -40,7 +43,7 @@ class DownloaderSong:
|
||||
None,
|
||||
)
|
||||
if not drm_info_raw:
|
||||
raise Exception("DRM info not found")
|
||||
return None
|
||||
return json.loads(base64.b64decode(drm_info_raw["value"]).decode("utf-8"))
|
||||
|
||||
def get_asset_infos(self, m3u8_data: dict) -> dict:
|
||||
@@ -111,6 +114,8 @@ class DownloaderSong:
|
||||
m3u8_obj = m3u8.load(m3u8_url)
|
||||
m3u8_data = m3u8_obj.data
|
||||
drm_infos = self.get_drm_infos(m3u8_data)
|
||||
if not drm_infos:
|
||||
return stream_info
|
||||
asset_infos = self.get_asset_infos(m3u8_data)
|
||||
if self.codec == SongCodec.ASK:
|
||||
playlist = self.get_playlist_from_user(m3u8_data)
|
||||
@@ -123,6 +128,7 @@ class DownloaderSong:
|
||||
drm_ids = asset_infos[variant_id]["AUDIO-SESSION-KEY-IDS"]
|
||||
pssh = self.get_pssh(drm_infos, drm_ids)
|
||||
stream_info.pssh = pssh
|
||||
stream_info.codec = playlist["stream_info"]["codecs"]
|
||||
return stream_info
|
||||
|
||||
@staticmethod
|
||||
@@ -294,13 +300,13 @@ class DownloaderSong:
|
||||
check=True,
|
||||
)
|
||||
|
||||
def remux(self, decrypted_path: Path, remuxed_path: Path) -> None:
|
||||
def remux(self, decrypted_path: Path, remuxed_path: Path, codec: str):
|
||||
if self.downloader.remux_mode == RemuxMode.MP4BOX:
|
||||
self.remux_mp4box(decrypted_path, remuxed_path)
|
||||
elif self.downloader.remux_mode == RemuxMode.FFMPEG:
|
||||
self.remux_ffmpeg(decrypted_path, remuxed_path)
|
||||
self.remux_ffmpeg(decrypted_path, remuxed_path, codec)
|
||||
|
||||
def remux_mp4box(self, decrypted_path: Path, remuxed_path: Path) -> None:
|
||||
def remux_mp4box(self, decrypted_path: Path, remuxed_path: Path):
|
||||
subprocess.run(
|
||||
[
|
||||
self.downloader.mp4box_path_full,
|
||||
@@ -315,7 +321,16 @@ class DownloaderSong:
|
||||
check=True,
|
||||
)
|
||||
|
||||
def remux_ffmpeg(self, decrypted_path: Path, remuxed_path: Path) -> None:
|
||||
def remux_ffmpeg(
|
||||
self,
|
||||
decrypted_path: Path,
|
||||
remuxed_path: Path,
|
||||
codec: str,
|
||||
):
|
||||
use_mp4_format = any(
|
||||
codec.startswith(possible_codec)
|
||||
for possible_codec in self.MP4_FORMAT_CODECS
|
||||
)
|
||||
subprocess.run(
|
||||
[
|
||||
self.downloader.ffmpeg_path_full,
|
||||
@@ -326,6 +341,8 @@ class DownloaderSong:
|
||||
decrypted_path,
|
||||
"-c",
|
||||
"copy",
|
||||
"-f",
|
||||
"mp4" if use_mp4_format else "ipod",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
remuxed_path,
|
||||
|
||||
@@ -23,3 +23,4 @@ class Lyrics:
|
||||
class StreamInfo:
|
||||
stream_url: str = None
|
||||
pssh: str = None
|
||||
codec: str = None
|
||||
|
||||
Reference in New Issue
Block a user