mirror of
https://github.com/glomatico/gamdl.git
synced 2026-06-13 20:25:13 +03:00
Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d7cb3ada4 | |||
| b87d406ffa | |||
| f6efdb3332 | |||
| 29a006c304 | |||
| a49a9c90cc | |||
| 43fd1dd2e3 | |||
| 2ed6ac05ba | |||
| 0f0e17f4cd | |||
| 8c4d2713f7 | |||
| 1baca4151b | |||
| 6f08a4b2f9 | |||
| 38f708e2e9 | |||
| f27adf98df | |||
| 9f0b25e1d1 | |||
| 3590d99063 | |||
| b200dade5a | |||
| 118d23e9db | |||
| 7bc8c6668f | |||
| 0e9fb3702d | |||
| e706f0fa82 | |||
| 3014fb112d | |||
| d7f17b8b6f | |||
| 947e2df81a | |||
| c8fe96b31d | |||
| 83a3efc1fa | |||
| 345afbf174 | |||
| c35051a7ec | |||
| b286ee84e2 | |||
| 9094f2c7b4 | |||
| 5feb5b274a | |||
| 1375af929c | |||
| d280f1fad2 | |||
| 3a04d7927e | |||
| 942a812308 | |||
| 66e01293e6 | |||
| c0561da592 | |||
| 56d238fb1b | |||
| d3a53bf93b | |||
| bb7a3ff77e | |||
| bf6293a0a0 |
@@ -1,5 +1,7 @@
|
||||
# Glomatico's Apple Music Downloader
|
||||
A Python script to download Apple Music songs/music videos/albums/playlists/post videos.
|
||||
A Python CLI app for downloading Apple Music songs/music videos/albums/playlists/posts.
|
||||
|
||||
**Discord Server:** https://discord.gg/aBjMEZ9tnq
|
||||
|
||||
## Features
|
||||
* Download songs in AAC/Spatial AAC/Dolby Atmos/ALAC*
|
||||
@@ -8,6 +10,7 @@ A Python script to download Apple Music songs/music videos/albums/playlists/post
|
||||
* Choose between FFmpeg and MP4Box for remuxing
|
||||
* Choose between yt-dlp and N_m3u8DL-RE for downloading
|
||||
* Highly customizable
|
||||
* Use artist links to download all of their albums or music videos
|
||||
|
||||
## Prerequisites
|
||||
* Python 3.8 or higher
|
||||
@@ -45,6 +48,10 @@ gamdl [OPTIONS] URLS...
|
||||
```bash
|
||||
gamdl "https://music.apple.com/us/album/whenever-you-need-somebody-2022-remaster/1624945511"
|
||||
```
|
||||
* Choose which albums or music videos to download from an artist
|
||||
```bash
|
||||
gamdl "https://music.apple.com/us/artist/rick-astley/669771"
|
||||
```
|
||||
|
||||
## Configuration
|
||||
You can configure gamdl by using the command line arguments or the config file. The config file is created automatically when you run gamdl for the first time at `~/.gamdl/config.json` on Linux and `%USERPROFILE%\.gamdl\config.json` on Windows. Config file values can be overridden using command line arguments.
|
||||
@@ -53,7 +60,7 @@ You can configure gamdl by using the command line arguments or the config file.
|
||||
| `--disable-music-video-skip` / `disable_music_video_skip` | Don't skip downloading music videos in albums/playlists. | `false` |
|
||||
| `--save-cover`, `-s` / `save_cover` | Save cover as a separate file. | `false` |
|
||||
| `--overwrite` / `overwrite` | Overwrite existing files. | `false` |
|
||||
| `--read-urls-as-txt`, `-r` / - | Interpret URLs as paths to text files containing URLs. | `false` |
|
||||
| `--read-urls-as-txt`, `-r` / - | Interpret URLs as paths to text files containing URLs separated by newlines. | `false` |
|
||||
| `--synced-lyrics-only` / `synced_lyrics_only` | Download only the synced lyrics. | `false` |
|
||||
| `--no-synced-lyrics` / `no_synced_lyrics` | Don't download the synced lyrics. | `false` |
|
||||
| `--config-path` / - | Path to config file. | `<home>/.spotify-web-downloader/config.json` |
|
||||
@@ -83,7 +90,7 @@ You can configure gamdl by using the command line arguments or the config file.
|
||||
| `--truncate` / `truncate` | Maximum length of the file/folder names. | `40` |
|
||||
| `--codec-song` / `codec_song` | Song codec. | `aac-legacy` |
|
||||
| `--synced-lyrics-format` / `synced_lyrics_format` | Synced lyrics format. | `lrc` |
|
||||
| `--codec-music-video` / `codec_music_video` | Music video codec. | `h264-best` |
|
||||
| `--codec-music-video` / `codec_music_video` | Music video codec. | `h264` |
|
||||
| `--quality-post` / `quality_post` | Post video quality. | `best` |
|
||||
| `--no-config-file`, `-n` / - | Do not use a config file. | `false` |
|
||||
|
||||
@@ -159,14 +166,14 @@ The following codecs are available:
|
||||
|
||||
### Music videos codecs
|
||||
The following codecs are available:
|
||||
* `h264-best` (with AAC 256kbps, up to 1080p)
|
||||
* `h265-best` (With AAC 256kpbs, up to 2160p)
|
||||
* `h264` (up to 1080p, with AAC 256kbps)
|
||||
* `h265` (up to 2160p, with AAC 256kpbs)
|
||||
* `ask`
|
||||
* When using this option, gamdl will ask you which audio and video codec to use that is available for the music video.
|
||||
|
||||
### Post videos/extra videos qualities
|
||||
The following qualities are available:
|
||||
* `best` (with AAC 256kbps, up to 1080p)
|
||||
* `best` (up to 1080p, with AAC 256kbps)
|
||||
* `ask`
|
||||
* When using this option, gamdl will ask you which video quality to use that is available for the video.
|
||||
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
__version__ = "2.1.7"
|
||||
__version__ = "2.2"
|
||||
|
||||
+45
-16
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import functools
|
||||
import re
|
||||
import time
|
||||
import typing
|
||||
from http.cookiejar import MozillaCookieJar
|
||||
from pathlib import Path
|
||||
|
||||
@@ -85,6 +86,31 @@ class AppleMusicApi:
|
||||
):
|
||||
self._raise_response_exception(response)
|
||||
|
||||
def get_artist(
|
||||
self,
|
||||
artist_id: str,
|
||||
include: str = "albums,music-videos",
|
||||
limit: int = 100,
|
||||
fetch_all: bool = True,
|
||||
) -> dict:
|
||||
response = self.session.get(
|
||||
f"{self.AMP_API_URL}/v1/catalog/{self.storefront}/artists/{artist_id}",
|
||||
params={
|
||||
"include": include,
|
||||
**{f"limit[{_include}]": limit for _include in include.split(",")},
|
||||
},
|
||||
)
|
||||
self._check_amp_api_response(response)
|
||||
artist = response.json()["data"][0]
|
||||
if fetch_all:
|
||||
for _include in include.split(","):
|
||||
for additional_data in self._extend_api_data(
|
||||
artist["relationships"][_include],
|
||||
limit,
|
||||
):
|
||||
artist["relationships"][_include]["data"].extend(additional_data)
|
||||
return artist
|
||||
|
||||
def get_song(
|
||||
self,
|
||||
song_id: str,
|
||||
@@ -146,7 +172,7 @@ class AppleMusicApi:
|
||||
is_library: bool = False,
|
||||
limit_tracks: int = 300,
|
||||
extend: str = "extendedAssetUrls",
|
||||
full_playlist: bool = True,
|
||||
fetch_all: bool = True,
|
||||
) -> dict:
|
||||
response = self.session.get(
|
||||
f"{self.AMP_API_URL}/v1/{'me' if is_library else 'catalog'}/{self.storefront}/playlists/{playlist_id}",
|
||||
@@ -157,28 +183,31 @@ class AppleMusicApi:
|
||||
)
|
||||
self._check_amp_api_response(response)
|
||||
playlist = response.json()["data"][0]
|
||||
if full_playlist:
|
||||
playlist = self._extend_playlists_tracks(playlist, limit_tracks)
|
||||
if fetch_all:
|
||||
for additional_data in self._extend_api_data(
|
||||
playlist["relationships"]["tracks"],
|
||||
limit_tracks,
|
||||
):
|
||||
playlist["relationships"]["tracks"]["data"].extend(additional_data)
|
||||
return playlist
|
||||
|
||||
def _extend_playlists_tracks(
|
||||
def _extend_api_data(
|
||||
self,
|
||||
playlist: dict,
|
||||
limit_tracks: int,
|
||||
) -> dict:
|
||||
playlist_next_uri = playlist["relationships"]["tracks"].get("next")
|
||||
while playlist_next_uri:
|
||||
playlist_next = self._get_playlist_next(playlist_next_uri, limit_tracks)
|
||||
playlist["relationships"]["tracks"]["data"].extend(playlist_next["data"])
|
||||
playlist_next_uri = playlist_next.get("next")
|
||||
api_response: dict,
|
||||
limit: int,
|
||||
) -> typing.Generator[list[dict], None, None]:
|
||||
next_uri = api_response.get("next")
|
||||
while next_uri:
|
||||
playlist_next = self._get_next_uri_response(next_uri, limit)
|
||||
yield playlist_next["data"]
|
||||
next_uri = playlist_next.get("next")
|
||||
time.sleep(self.WAIT_TIME)
|
||||
return playlist
|
||||
|
||||
def _get_playlist_next(self, playlist_next_uri: str, limit_tracks: int) -> dict:
|
||||
def _get_next_uri_response(self, next_uri: str, limit: int) -> dict:
|
||||
response = self.session.get(
|
||||
self.AMP_API_URL + playlist_next_uri,
|
||||
self.AMP_API_URL + next_uri,
|
||||
params={
|
||||
"limit[tracks]": limit_tracks,
|
||||
"limit": limit,
|
||||
},
|
||||
)
|
||||
self._check_amp_api_response(response)
|
||||
|
||||
+9
-4
@@ -35,7 +35,7 @@ def get_param_string(param: click.Parameter) -> str:
|
||||
return param.default
|
||||
|
||||
|
||||
def write_default_config_file(ctx: click.Context) -> None:
|
||||
def write_default_config_file(ctx: click.Context):
|
||||
ctx.params["config_path"].parent.mkdir(parents=True, exist_ok=True)
|
||||
config_file = {
|
||||
param.name: get_param_string(param)
|
||||
@@ -95,7 +95,7 @@ def load_config_file(
|
||||
"--read-urls-as-txt",
|
||||
"-r",
|
||||
is_flag=True,
|
||||
help="Interpret URLs as paths to text files containing URLs.",
|
||||
help="Interpret URLs as paths to text files containing URLs separated by newlines",
|
||||
)
|
||||
@click.option(
|
||||
"--synced-lyrics-only",
|
||||
@@ -440,10 +440,15 @@ def main(
|
||||
)
|
||||
error_count = 0
|
||||
if read_urls_as_txt:
|
||||
urls = [url.strip() for url in Path(urls[0]).read_text().splitlines()]
|
||||
_urls = []
|
||||
for url in urls:
|
||||
if Path(url).exists():
|
||||
_urls.extend(Path(url).read_text().splitlines())
|
||||
urls = _urls
|
||||
for url_index, url in enumerate(urls, start=1):
|
||||
url_progress = f"URL {url_index}/{len(urls)}"
|
||||
try:
|
||||
logger.info(f'({url_progress}) Checking "{url}"')
|
||||
url_info = downloader.get_url_info(url)
|
||||
download_queue = downloader.get_download_queue(url_info)
|
||||
except Exception as e:
|
||||
@@ -496,8 +501,8 @@ def main(
|
||||
f'({queue_progress}) Song already exists at "{final_path}", skipping'
|
||||
)
|
||||
else:
|
||||
logger.debug("Getting stream info")
|
||||
if codec_song in LEGACY_CODECS:
|
||||
logger.debug("Getting stream info")
|
||||
stream_info = downloader_song_legacy.get_stream_info(
|
||||
webplayback
|
||||
)
|
||||
|
||||
+2
-2
@@ -197,8 +197,8 @@ SONG_CODEC_REGEX_MAP = {
|
||||
}
|
||||
|
||||
MUSIC_VIDEO_CODEC_MAP = {
|
||||
MusicVideoCodec.H264_BEST: "avc1",
|
||||
MusicVideoCodec.H265_BEST: "hvc1",
|
||||
MusicVideoCodec.H264: "avc1",
|
||||
MusicVideoCodec.H265: "hvc1",
|
||||
}
|
||||
|
||||
SYNCED_LYRICS_FILE_EXTENSION_MAP = {
|
||||
|
||||
+93
-2
@@ -9,6 +9,8 @@ from pathlib import Path
|
||||
|
||||
import ciso8601
|
||||
import requests
|
||||
from InquirerPy import inquirer
|
||||
from InquirerPy.base.control import Choice
|
||||
from mutagen.mp4 import MP4, MP4Cover
|
||||
from pywidevine import PSSH, Cdm, Device
|
||||
from yt_dlp import YoutubeDL
|
||||
@@ -112,7 +114,7 @@ class Downloader:
|
||||
def get_url_info(self, url: str) -> UrlInfo:
|
||||
url_info = UrlInfo()
|
||||
url_regex_result = re.search(
|
||||
r"/([a-z]{2})/(album|playlist|song|music-video|post)/([^/]*)(?:/([^/?]*))?(?:\?i=)?([0-9a-z]*)?",
|
||||
r"/([a-z]{2})/(artist|album|playlist|song|music-video|post)/([^/]*)(?:/([^/?]*))?(?:\?i=)?([0-9a-z]*)?",
|
||||
url,
|
||||
)
|
||||
url_info.storefront = url_regex_result.group(1)
|
||||
@@ -131,7 +133,10 @@ class Downloader:
|
||||
|
||||
def _get_download_queue(self, url_type: str, id: str) -> list[DownloadQueueItem]:
|
||||
download_queue = []
|
||||
if url_type == "song":
|
||||
if url_type == "artist":
|
||||
artist = self.apple_music_api.get_artist(id)
|
||||
download_queue.extend(self.get_download_queue_from_artist(artist))
|
||||
elif url_type == "song":
|
||||
download_queue.append(DownloadQueueItem(self.apple_music_api.get_song(id)))
|
||||
elif url_type == "album":
|
||||
album = self.apple_music_api.get_album(id)
|
||||
@@ -156,6 +161,92 @@ class Downloader:
|
||||
raise Exception(f"Invalid url type: {url_type}")
|
||||
return download_queue
|
||||
|
||||
def get_download_queue_from_artist(self, artist: dict) -> list[DownloadQueueItem]:
|
||||
media_type = inquirer.select(
|
||||
message=f'Select which type to download for artist "{artist["attributes"]["name"]}":',
|
||||
choices=[
|
||||
Choice(name="Albums", value="albums"),
|
||||
Choice(
|
||||
name="Music Videos",
|
||||
value="music-videos",
|
||||
),
|
||||
],
|
||||
validate=lambda result: artist["relationships"].get(result, {}).get("data"),
|
||||
invalid_message="The artist doesn't have any items of this type",
|
||||
).execute()
|
||||
if media_type == "albums":
|
||||
return self.select_albums_from_artist(
|
||||
artist["relationships"]["albums"]["data"]
|
||||
)
|
||||
elif media_type == "music-videos":
|
||||
return self.select_music_videos_from_artist(
|
||||
artist["relationships"]["music-videos"]["data"]
|
||||
)
|
||||
|
||||
def select_albums_from_artist(
|
||||
self,
|
||||
albums: list[dict],
|
||||
) -> list[DownloadQueueItem]:
|
||||
choices = [
|
||||
Choice(
|
||||
name=" | ".join(
|
||||
[
|
||||
f'{album["attributes"]["trackCount"]:03d}',
|
||||
f'{album["attributes"]["releaseDate"]:<10}',
|
||||
f'{album["attributes"].get("contentRating", "None").title():<8}',
|
||||
f'{album["attributes"]["name"]}',
|
||||
]
|
||||
),
|
||||
value=album,
|
||||
)
|
||||
for album in albums
|
||||
]
|
||||
selected = inquirer.select(
|
||||
message="Select which albums to download: (Track Count | Release Date | Rating | Title)",
|
||||
choices=choices,
|
||||
multiselect=True,
|
||||
).execute()
|
||||
download_queue = []
|
||||
for album in selected:
|
||||
download_queue.extend(
|
||||
DownloadQueueItem(track)
|
||||
for track in self.apple_music_api.get_album(album["id"])[
|
||||
"relationships"
|
||||
]["tracks"]["data"]
|
||||
)
|
||||
return download_queue
|
||||
|
||||
def select_music_videos_from_artist(
|
||||
self,
|
||||
music_videos: list[dict],
|
||||
) -> list[DownloadQueueItem]:
|
||||
choices = [
|
||||
Choice(
|
||||
name=" | ".join(
|
||||
[
|
||||
self.millis_to_min_sec(
|
||||
music_video["attributes"]["durationInMillis"]
|
||||
),
|
||||
f'{music_video["attributes"].get("contentRating", "None").title():<8}',
|
||||
music_video["attributes"]["name"],
|
||||
],
|
||||
),
|
||||
value=music_video,
|
||||
)
|
||||
for music_video in music_videos
|
||||
]
|
||||
selected = inquirer.select(
|
||||
message="Select which music videos to download: (Duration | Rating | Title)",
|
||||
choices=choices,
|
||||
multiselect=True,
|
||||
).execute()
|
||||
return [DownloadQueueItem(music_video) for music_video in selected]
|
||||
|
||||
@staticmethod
|
||||
def millis_to_min_sec(millis):
|
||||
minutes, seconds = divmod(millis // 1000, 60)
|
||||
return f"{minutes:02d}:{seconds:02d}"
|
||||
|
||||
def sanitize_date(self, date: str):
|
||||
datetime_obj = ciso8601.parse_datetime(date)
|
||||
return datetime_obj.strftime(self.template_date)
|
||||
|
||||
@@ -4,10 +4,9 @@ import re
|
||||
import subprocess
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
import m3u8
|
||||
from tabulate import tabulate
|
||||
from InquirerPy import inquirer
|
||||
from InquirerPy.base.control import Choice
|
||||
|
||||
from .constants import MUSIC_VIDEO_CODEC_MAP
|
||||
from .downloader import Downloader
|
||||
@@ -21,7 +20,7 @@ class DownloaderMusicVideo:
|
||||
def __init__(
|
||||
self,
|
||||
downloader: Downloader,
|
||||
codec: MusicVideoCodec = MusicVideoCodec.H264_BEST,
|
||||
codec: MusicVideoCodec = MusicVideoCodec.H264,
|
||||
):
|
||||
self.downloader = downloader
|
||||
self.codec = codec
|
||||
@@ -54,7 +53,7 @@ class DownloaderMusicVideo:
|
||||
playlist
|
||||
for playlist in playlists
|
||||
if playlist["stream_info"]["codecs"].startswith(
|
||||
MUSIC_VIDEO_CODEC_MAP[MusicVideoCodec.H264_BEST]
|
||||
MUSIC_VIDEO_CODEC_MAP[MusicVideoCodec.H264]
|
||||
)
|
||||
]
|
||||
playlists_filtered.sort(key=lambda x: x["stream_info"]["bandwidth"])
|
||||
@@ -64,24 +63,24 @@ class DownloaderMusicVideo:
|
||||
self,
|
||||
playlists: list[dict],
|
||||
) -> dict:
|
||||
table = [
|
||||
[
|
||||
i,
|
||||
playlist["stream_info"]["codecs"],
|
||||
playlist["stream_info"]["resolution"],
|
||||
playlist["stream_info"]["bandwidth"],
|
||||
]
|
||||
for i, playlist in enumerate(playlists, 1)
|
||||
]
|
||||
print(tabulate(table))
|
||||
try:
|
||||
choice = (
|
||||
click.prompt("Choose a video codec", type=click.IntRange(1, len(table)))
|
||||
- 1
|
||||
choices = [
|
||||
Choice(
|
||||
name=" | ".join(
|
||||
[
|
||||
playlist["stream_info"]["codecs"][:4],
|
||||
playlist["stream_info"]["resolution"],
|
||||
str(playlist["stream_info"]["bandwidth"]),
|
||||
]
|
||||
),
|
||||
value=playlist,
|
||||
)
|
||||
except click.exceptions.Abort:
|
||||
raise KeyboardInterrupt()
|
||||
return playlists[choice]
|
||||
for playlist in playlists
|
||||
]
|
||||
selected = inquirer.select(
|
||||
message="Select which video codec to download: (Codec | Resolution | Bitrate)",
|
||||
choices=choices,
|
||||
).execute()
|
||||
return selected
|
||||
|
||||
def get_playlist_audio(
|
||||
self,
|
||||
@@ -101,24 +100,18 @@ class DownloaderMusicVideo:
|
||||
self,
|
||||
playlists: list[dict],
|
||||
) -> dict:
|
||||
table = [
|
||||
[
|
||||
i,
|
||||
playlist["group_id"],
|
||||
]
|
||||
for i, playlist in enumerate(playlists, 1)
|
||||
]
|
||||
print(tabulate(table))
|
||||
try:
|
||||
choice = (
|
||||
click.prompt(
|
||||
"Choose an audio codec", type=click.IntRange(1, len(table))
|
||||
)
|
||||
- 1
|
||||
choices = [
|
||||
Choice(
|
||||
name=playlist["group_id"],
|
||||
value=playlist,
|
||||
)
|
||||
except click.exceptions.Abort:
|
||||
raise KeyboardInterrupt()
|
||||
return playlists[choice]
|
||||
for playlist in playlists
|
||||
]
|
||||
selected = inquirer.select(
|
||||
message="Select which audio codec to download:",
|
||||
choices=choices,
|
||||
).execute()
|
||||
return selected
|
||||
|
||||
def get_pssh(self, m3u8_data: dict):
|
||||
return next(
|
||||
@@ -237,7 +230,7 @@ class DownloaderMusicVideo:
|
||||
decrypted_path_audio: Path,
|
||||
decrypted_path_video: Path,
|
||||
fixed_path: Path,
|
||||
) -> None:
|
||||
):
|
||||
subprocess.run(
|
||||
[
|
||||
self.downloader.mp4box_path_full,
|
||||
@@ -248,6 +241,7 @@ class DownloaderMusicVideo:
|
||||
decrypted_path_video,
|
||||
"-itags",
|
||||
"artist=placeholder",
|
||||
"-keep-utc",
|
||||
"-new",
|
||||
fixed_path,
|
||||
],
|
||||
|
||||
+14
-15
@@ -1,7 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
from tabulate import tabulate
|
||||
from InquirerPy import inquirer
|
||||
from InquirerPy.base.control import Choice
|
||||
|
||||
from .downloader import Downloader
|
||||
from .enums import PostQuality
|
||||
@@ -38,21 +40,18 @@ class DownloaderPost:
|
||||
|
||||
def get_stream_url_from_user(self, metadata: dict) -> str:
|
||||
qualities = list(metadata["attributes"]["assetTokens"].keys())
|
||||
table = [
|
||||
[index, quality]
|
||||
for index, quality in enumerate(
|
||||
qualities,
|
||||
start=1,
|
||||
choices = [
|
||||
Choice(
|
||||
name=quality,
|
||||
value=quality,
|
||||
)
|
||||
for quality in qualities
|
||||
]
|
||||
print(tabulate(table))
|
||||
try:
|
||||
choice = (
|
||||
click.prompt("Choose a quality", type=click.IntRange(1, len(table))) - 1
|
||||
)
|
||||
except click.exceptions.Abort:
|
||||
raise KeyboardInterrupt()
|
||||
return metadata["attributes"]["assetTokens"][qualities[choice]]
|
||||
selected = inquirer.select(
|
||||
message="Select which quality to download:",
|
||||
choices=choices,
|
||||
).execute()
|
||||
return metadata["attributes"]["assetTokens"][selected]
|
||||
|
||||
def get_stream_url(self, metadata: dict) -> str:
|
||||
if self.quality == PostQuality.BEST:
|
||||
|
||||
+23
-20
@@ -9,9 +9,9 @@ from pathlib import Path
|
||||
from xml.dom import minidom
|
||||
from xml.etree import ElementTree
|
||||
|
||||
import click
|
||||
from InquirerPy import inquirer
|
||||
from InquirerPy.base.control import Choice
|
||||
import m3u8
|
||||
from tabulate import tabulate
|
||||
|
||||
from .constants import SONG_CODEC_REGEX_MAP, SYNCED_LYRICS_FILE_EXTENSION_MAP
|
||||
from .downloader import Downloader
|
||||
@@ -72,18 +72,18 @@ class DownloaderSong:
|
||||
|
||||
def get_playlist_from_user(self, m3u8_data: dict) -> dict | None:
|
||||
m3u8_master_playlists = [playlist for playlist in m3u8_data["playlists"]]
|
||||
table = [
|
||||
[i, playlist["stream_info"]["audio"]]
|
||||
for i, playlist in enumerate(m3u8_master_playlists, 1)
|
||||
]
|
||||
print(tabulate(table))
|
||||
try:
|
||||
choice = (
|
||||
click.prompt("Choose a codec", type=click.IntRange(1, len(table))) - 1
|
||||
choices = [
|
||||
Choice(
|
||||
name=playlist["stream_info"]["audio"],
|
||||
value=playlist,
|
||||
)
|
||||
except click.exceptions.Abort:
|
||||
raise KeyboardInterrupt()
|
||||
return m3u8_master_playlists[choice]
|
||||
for playlist in m3u8_master_playlists
|
||||
]
|
||||
selected = inquirer.select(
|
||||
message="Select which codec to download:",
|
||||
choices=choices,
|
||||
).execute()
|
||||
return selected
|
||||
|
||||
def get_pssh(
|
||||
self,
|
||||
@@ -178,22 +178,24 @@ class DownloaderSong:
|
||||
return f"{index}\n{timestamp_srt_start} --> {timestamp_srt_end}\n{text}\n"
|
||||
|
||||
def get_lyrics(self, track_metadata: dict) -> Lyrics:
|
||||
lyrics = Lyrics()
|
||||
if not track_metadata["attributes"]["hasLyrics"]:
|
||||
return Lyrics()
|
||||
return lyrics
|
||||
elif track_metadata.get("relationships") is None:
|
||||
track_metadata = self.downloader.apple_music_api.get_song(
|
||||
track_metadata["id"]
|
||||
)
|
||||
if track_metadata["relationships"]["lyrics"]["data"] and track_metadata[
|
||||
"relationships"
|
||||
]["lyrics"]["data"][0].get("attributes"):
|
||||
return self._get_lyrics(
|
||||
if (
|
||||
track_metadata["relationships"].get("lyrics")
|
||||
and track_metadata["relationships"]["lyrics"].get("data")
|
||||
and track_metadata["relationships"]["lyrics"]["data"][0].get("attributes")
|
||||
):
|
||||
lyrics = self._get_lyrics(
|
||||
track_metadata["relationships"]["lyrics"]["data"][0]["attributes"][
|
||||
"ttml"
|
||||
]
|
||||
)
|
||||
else:
|
||||
return Lyrics()
|
||||
return lyrics
|
||||
|
||||
def _get_lyrics(self, lyrics_ttml: str) -> Lyrics:
|
||||
lyrics = Lyrics("", "")
|
||||
@@ -320,6 +322,7 @@ class DownloaderSong:
|
||||
decrypted_path,
|
||||
"-itags",
|
||||
"artist=placeholder",
|
||||
"-keep-utc",
|
||||
"-new",
|
||||
remuxed_path,
|
||||
],
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
@@ -65,7 +67,7 @@ class DownloaderSongLegacy(DownloaderSong):
|
||||
**self.downloader.subprocess_additional_args,
|
||||
)
|
||||
|
||||
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,
|
||||
@@ -74,6 +76,7 @@ class DownloaderSongLegacy(DownloaderSong):
|
||||
decrypted_path,
|
||||
"-itags",
|
||||
"artist=placeholder",
|
||||
"-keep-utc",
|
||||
"-new",
|
||||
remuxed_path,
|
||||
],
|
||||
|
||||
+2
-2
@@ -33,8 +33,8 @@ class SyncedLyricsFormat(Enum):
|
||||
|
||||
|
||||
class MusicVideoCodec(Enum):
|
||||
H264_BEST = "h264-best"
|
||||
H265_BEST = "h265-best"
|
||||
H264 = "h264"
|
||||
H265 = "h265"
|
||||
ASK = "ask"
|
||||
|
||||
|
||||
|
||||
+2
-2
@@ -1,13 +1,13 @@
|
||||
[project]
|
||||
name = "gamdl"
|
||||
description = "A Python script to download Apple Music songs/music videos/albums/playlists/post videos."
|
||||
description = "A Python CLI app for downloading Apple Music songs/music videos/albums/playlists/posts."
|
||||
requires-python = ">=3.8"
|
||||
authors = [{ name = "glomatico" }]
|
||||
dependencies = [
|
||||
"ciso8601",
|
||||
"click",
|
||||
"inquirerpy",
|
||||
"m3u8",
|
||||
"tabulate",
|
||||
"pywidevine",
|
||||
"pyyaml",
|
||||
"yt-dlp",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
ciso8601
|
||||
click
|
||||
inquirerpy
|
||||
m3u8
|
||||
tabulate
|
||||
pywidevine
|
||||
pyyaml
|
||||
yt-dlp
|
||||
|
||||
Reference in New Issue
Block a user