mirror of
https://github.com/glomatico/gamdl.git
synced 2026-06-13 20:25:13 +03:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c653db00cf | |||
| cdd574a349 | |||
| afbe65707a | |||
| 3998b698e0 | |||
| a67c81bd22 | |||
| 9b0a2acc6f | |||
| 4d904e2e7c | |||
| 2d3b2b6b1f | |||
| 1ee8e2aa13 | |||
| fd6d8a0689 | |||
| 50904e9c08 | |||
| 66556eac0a | |||
| d97445ec9e | |||
| d6f30aa0a2 | |||
| 42a17ca90f | |||
| 3ee0d28727 | |||
| 7b8875250c | |||
| 16734b8b64 | |||
| 475bddb5f7 | |||
| 63ba4b0824 | |||
| 9d67e8f0f0 | |||
| fcbe596a80 |
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 Glomatico
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -15,7 +15,7 @@ A Python CLI app for downloading Apple Music songs/music videos/posts.
|
||||
## Prerequisites
|
||||
* Python 3.8 or higher
|
||||
* The cookies file of your Apple Music browser session in Netscape format (requires an active subscription)
|
||||
* You can get your cookies by using one of the following extensions on your browser of choice at the Apple Music website with your account signed in:
|
||||
* To export your cookies, use one of the following browser extensions while signed in to Apple Music:
|
||||
* Firefox: https://addons.mozilla.org/addon/export-cookies-txt
|
||||
* Chromium based browsers: https://chrome.google.com/webstore/detail/gdocmgbfkjnnpapoeobnolbbkoibbcif
|
||||
* FFmpeg on your system PATH
|
||||
@@ -23,22 +23,38 @@ A Python CLI app for downloading Apple Music songs/music videos/posts.
|
||||
* Up to date binaries can be obtained from the links below:
|
||||
* Windows: https://github.com/AnimMouse/ffmpeg-stable-autobuild/releases
|
||||
* Linux: https://johnvansickle.com/ffmpeg/
|
||||
* (Optional) mp4decrypt on your system PATH
|
||||
* Required to download music videos and songs in non-legacy formats.
|
||||
* Binaries can be obtained from here: https://www.bento4.com/downloads/.
|
||||
|
||||
### Optional dependencies
|
||||
The following tools are optional but required for specific features. Add them to your system’s PATH or specify their paths using command-line arguments or the config file.
|
||||
* [MP4Box](https://gpac.io/downloads/gpac-nightly-builds/)
|
||||
* Required when setting `mp4box` as remux mode.
|
||||
* [mp4decrypt](https://www.bento4.com/downloads/)
|
||||
* Required when setting `mp4box` as remux mode, for downloading songs in non-legacy formats and for downloading music videos.
|
||||
* [N_m3u8DL-RE](https://github.com/nilaoda/N_m3u8DL-RE/releases/latest)
|
||||
* Required when setting `nm3u8dlre` as download mode.
|
||||
|
||||
## Installation
|
||||
1. Install the package `gamdl` using pip
|
||||
```bash
|
||||
pip install gamdl
|
||||
```
|
||||
2. Place your cookies file in the directory from which you will be running gamdl and name it `cookies.txt`.
|
||||
2. Set up the cookies file.
|
||||
* You can either move to the current directory from which you will be running Gamdl as `cookies.txt` or specify its path using the command-line arguments/config file.
|
||||
|
||||
## Usage
|
||||
```bash
|
||||
gamdl [OPTIONS] URLS...
|
||||
```
|
||||
|
||||
### Supported URL types
|
||||
Gamdl supports the following types of URLs:
|
||||
* Song
|
||||
* Album
|
||||
* Playlist
|
||||
* Music video
|
||||
* Artist
|
||||
* Post video/extra video
|
||||
|
||||
### Examples
|
||||
* Download a song
|
||||
```bash
|
||||
@@ -60,9 +76,9 @@ gamdl [OPTIONS] URLS...
|
||||
* Enter - Confirm selection
|
||||
|
||||
## Configuration
|
||||
gamdl can be configured by using the command line arguments or the config file.
|
||||
Gamdl can be configured 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.
|
||||
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.
|
||||
| Command line argument / Config file key | Description | Default value |
|
||||
@@ -178,20 +194,20 @@ The following codecs are also available, **but are not guaranteed to work**, as
|
||||
* `ac3`
|
||||
* `alac`
|
||||
* `ask`
|
||||
* When using this option, gamdl will ask you which codec from this list to use that is available for the song.
|
||||
* When using this option, Gamdl will ask you which codec from this list to use that is available for the song.
|
||||
|
||||
### Music videos codecs
|
||||
The following codecs are available:
|
||||
* `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.
|
||||
* 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` (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.
|
||||
* When using this option, Gamdl will ask you which video quality to use that is available for the video.
|
||||
|
||||
Post videos doesn't require remuxing and are limited to `ytdlp` download mode.
|
||||
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
__version__ = "2.3.2"
|
||||
__version__ = "2.3.7"
|
||||
|
||||
+38
-25
@@ -125,9 +125,9 @@ def load_config_file(
|
||||
help="Log level.",
|
||||
)
|
||||
@click.option(
|
||||
"--print-exceptions",
|
||||
"--no-exceptions",
|
||||
is_flag=True,
|
||||
help="Print exceptions.",
|
||||
help="Don't print exceptions.",
|
||||
)
|
||||
# API specific options
|
||||
@click.option(
|
||||
@@ -318,7 +318,7 @@ def main(
|
||||
no_synced_lyrics: bool,
|
||||
config_path: Path,
|
||||
log_level: str,
|
||||
print_exceptions: bool,
|
||||
no_exceptions: bool,
|
||||
cookies_path: Path,
|
||||
language: str,
|
||||
output_path: Path,
|
||||
@@ -354,7 +354,7 @@ def main(
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.setLevel(log_level)
|
||||
logger.debug("Starting downloader")
|
||||
logger.info("Starting Gamdl")
|
||||
if not cookies_path.exists():
|
||||
logger.critical(X_NOT_FOUND_STRING.format("Cookies file", cookies_path))
|
||||
return
|
||||
@@ -470,7 +470,7 @@ def main(
|
||||
error_count += 1
|
||||
logger.error(
|
||||
f'({url_progress}) Failed to check "{url}"',
|
||||
exc_info=print_exceptions,
|
||||
exc_info=not no_exceptions,
|
||||
)
|
||||
continue
|
||||
for download_index, track_metadata in enumerate(
|
||||
@@ -609,13 +609,24 @@ def main(
|
||||
itunes_page = itunes_api.get_itunes_page(
|
||||
"music-video", music_video_id_alt
|
||||
)
|
||||
stream_url_master = downloader_music_video.get_stream_url_master(
|
||||
itunes_page
|
||||
)
|
||||
if music_video_id_alt == track_metadata["id"]:
|
||||
stream_url = (
|
||||
downloader_music_video.get_stream_url_from_itunes_page(
|
||||
itunes_page
|
||||
)
|
||||
)
|
||||
else:
|
||||
logger.debug("Getting webplayback")
|
||||
webplayback = apple_music_api.get_webplayback(
|
||||
track_metadata["id"]
|
||||
)
|
||||
stream_url = (
|
||||
downloader_music_video.get_stream_url_from_webplayback(
|
||||
webplayback
|
||||
)
|
||||
)
|
||||
logger.debug("Getting M3U8 data")
|
||||
m3u8_master_data = downloader_music_video.get_m3u8_master_data(
|
||||
stream_url_master
|
||||
)
|
||||
m3u8_data = downloader_music_video.get_m3u8_master_data(stream_url)
|
||||
tags = downloader_music_video.get_tags(
|
||||
music_video_id_alt,
|
||||
itunes_page,
|
||||
@@ -643,12 +654,8 @@ def main(
|
||||
else:
|
||||
logger.debug("Getting stream info")
|
||||
stream_info_video, stream_info_audio = (
|
||||
downloader_music_video.get_stream_info_video(
|
||||
m3u8_master_data
|
||||
),
|
||||
downloader_music_video.get_stream_info_audio(
|
||||
m3u8_master_data
|
||||
),
|
||||
downloader_music_video.get_stream_info_video(m3u8_data),
|
||||
downloader_music_video.get_stream_info_audio(m3u8_data),
|
||||
)
|
||||
decryption_key_video = downloader.get_decryption_key(
|
||||
stream_info_video.pssh, track_metadata["id"]
|
||||
@@ -727,7 +734,7 @@ def main(
|
||||
)
|
||||
logger.debug(f'Downloading to "{remuxed_path}"')
|
||||
downloader.download_ytdlp(remuxed_path, stream_url)
|
||||
if not save_cover:
|
||||
if synced_lyrics_only or not save_cover:
|
||||
pass
|
||||
elif cover_path.exists() and not overwrite:
|
||||
logger.debug(f'Cover already exists at "{cover_path}", skipping')
|
||||
@@ -739,17 +746,23 @@ def main(
|
||||
downloader.apply_tags(remuxed_path, tags, cover_url)
|
||||
logger.debug(f'Moving to "{final_path}"')
|
||||
downloader.move_to_output_path(remuxed_path, final_path)
|
||||
if save_playlist and download_queue.playlist_attributes:
|
||||
playlist_file_path = downloader.get_playlist_file_path(tags)
|
||||
logger.debug(
|
||||
f'Updating M3U8 playlist from "{playlist_file_path}"'
|
||||
)
|
||||
downloader.update_playlist_file(playlist_file_path, final_path)
|
||||
if (
|
||||
not synced_lyrics_only
|
||||
and save_playlist
|
||||
and download_queue.playlist_attributes
|
||||
):
|
||||
playlist_file_path = downloader.get_playlist_file_path(tags)
|
||||
logger.debug(f'Updating M3U8 playlist from "{playlist_file_path}"')
|
||||
downloader.update_playlist_file(
|
||||
playlist_file_path,
|
||||
final_path,
|
||||
playlist_track,
|
||||
)
|
||||
except Exception as e:
|
||||
error_count += 1
|
||||
logger.error(
|
||||
f'({queue_progress}) Failed to download "{track_metadata["attributes"]["name"]}"',
|
||||
exc_info=print_exceptions,
|
||||
exc_info=not no_exceptions,
|
||||
)
|
||||
finally:
|
||||
if temp_path.exists():
|
||||
|
||||
+36
-25
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import datetime
|
||||
import functools
|
||||
import io
|
||||
import re
|
||||
@@ -9,7 +10,6 @@ import subprocess
|
||||
import typing
|
||||
from pathlib import Path
|
||||
|
||||
import ciso8601
|
||||
import requests
|
||||
from InquirerPy import inquirer
|
||||
from InquirerPy.base.control import Choice
|
||||
@@ -51,7 +51,7 @@ class Downloader:
|
||||
template_file_multi_disc: str = "{disc}-{track:02d} {title}",
|
||||
template_folder_no_album: str = "{artist}/Unknown Album",
|
||||
template_file_no_album: str = "{title}",
|
||||
template_file_playlist: str = "Playlists/{playlist_title}",
|
||||
template_file_playlist: str = "Playlists/{playlist_artist}/{playlist_title}",
|
||||
template_date: str = "%Y-%m-%dT%H:%M:%SZ",
|
||||
exclude_tags: str = None,
|
||||
cover_size: int = 1200,
|
||||
@@ -266,43 +266,52 @@ class Downloader:
|
||||
self,
|
||||
tags: dict,
|
||||
):
|
||||
template_folder = self.template_file_playlist.split("/")
|
||||
template_file = self.template_file_playlist.split("/")
|
||||
template_final = template_folder + template_file
|
||||
return self.output_path.joinpath(
|
||||
return Path(
|
||||
self.output_path,
|
||||
*[
|
||||
self.get_sanitized_string(i.format(**tags), True)
|
||||
for i in template_final[0:-1]
|
||||
]
|
||||
).joinpath(
|
||||
for i in template_file[0:-1]
|
||||
],
|
||||
*[
|
||||
self.get_sanitized_string(template_final[-1].format(**tags), False)
|
||||
self.get_sanitized_string(template_file[-1].format(**tags), False)
|
||||
+ ".m3u8"
|
||||
]
|
||||
],
|
||||
)
|
||||
|
||||
def update_playlist_file(
|
||||
self,
|
||||
playlist_file_path: Path,
|
||||
final_path: Path,
|
||||
playlist_track: int,
|
||||
):
|
||||
playlist_file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with playlist_file_path.open("a", encoding="utf8") as playlist_file:
|
||||
playlist_file.write(
|
||||
final_path.relative_to(
|
||||
playlist_file_path.parent, walk_up=True
|
||||
).as_posix()
|
||||
+ "\n"
|
||||
playlist_file_path_parent_parts_len = len(playlist_file_path.parent.parts)
|
||||
output_path_parts_len = len(self.output_path.parts)
|
||||
final_path_relative = Path(
|
||||
("../" * (playlist_file_path_parent_parts_len - output_path_parts_len)),
|
||||
*final_path.parts[output_path_parts_len:],
|
||||
)
|
||||
playlist_file_lines = (
|
||||
playlist_file_path.open("r", encoding="utf8").readlines()
|
||||
if playlist_file_path.exists()
|
||||
else []
|
||||
)
|
||||
if len(playlist_file_lines) < playlist_track:
|
||||
playlist_file_lines.extend(
|
||||
"\n" for _ in range(playlist_track - len(playlist_file_lines))
|
||||
)
|
||||
playlist_file_lines[playlist_track - 1] = final_path_relative.as_posix() + "\n"
|
||||
with playlist_file_path.open("w", encoding="utf8") as playlist_file:
|
||||
playlist_file.writelines(playlist_file_lines)
|
||||
|
||||
@staticmethod
|
||||
def millis_to_min_sec(millis):
|
||||
def millis_to_min_sec(millis) -> str:
|
||||
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)
|
||||
def sanitize_date(self, date: str) -> datetime.datetime:
|
||||
return datetime.datetime.fromisoformat(date[:-1]).strftime(self.template_date)
|
||||
|
||||
def get_decryption_key(self, pssh: str, track_id: str) -> str:
|
||||
try:
|
||||
@@ -398,14 +407,16 @@ class Downloader:
|
||||
template_folder = self.template_folder_no_album.split("/")
|
||||
template_file = self.template_file_no_album.split("/")
|
||||
template_final = template_folder + template_file
|
||||
return self.output_path.joinpath(
|
||||
return Path(
|
||||
self.output_path,
|
||||
*[
|
||||
self.get_sanitized_string(i.format(**tags), True)
|
||||
for i in template_final[0:-1]
|
||||
]
|
||||
).joinpath(
|
||||
self.get_sanitized_string(template_final[-1].format(**tags), False)
|
||||
+ file_extension
|
||||
],
|
||||
(
|
||||
self.get_sanitized_string(template_final[-1].format(**tags), False)
|
||||
+ file_extension
|
||||
),
|
||||
)
|
||||
|
||||
def get_cover_file_extension(self, cover_url: str) -> str:
|
||||
|
||||
@@ -25,17 +25,20 @@ class DownloaderMusicVideo:
|
||||
self.downloader = downloader
|
||||
self.codec = codec
|
||||
|
||||
def get_stream_url_master(self, itunes_page: dict) -> str:
|
||||
return itunes_page["offers"][0]["assets"][0]["hlsUrl"]
|
||||
def get_stream_url_from_webplayback(self, webplayback: dict) -> str:
|
||||
return webplayback["hls-playlist-url"]
|
||||
|
||||
def get_m3u8_master_data(self, stream_url_master: str) -> dict:
|
||||
url_parts = urllib.parse.urlparse(stream_url_master)
|
||||
def get_stream_url_from_itunes_page(self, itunes_page: dict) -> dict:
|
||||
stream_url = itunes_page["offers"][0]["assets"][0]["hlsUrl"]
|
||||
url_parts = urllib.parse.urlparse(stream_url)
|
||||
query = urllib.parse.parse_qs(url_parts.query, keep_blank_values=True)
|
||||
query.update({"aec": "HD", "dsid": "1"})
|
||||
stream_url_master_new = url_parts._replace(
|
||||
return url_parts._replace(
|
||||
query=urllib.parse.urlencode(query, doseq=True)
|
||||
).geturl()
|
||||
return m3u8.load(stream_url_master_new).data
|
||||
|
||||
def get_m3u8_master_data(self, stream_url_master: str) -> dict:
|
||||
return m3u8.load(stream_url_master).data
|
||||
|
||||
def get_playlist_video(
|
||||
self,
|
||||
|
||||
@@ -248,7 +248,7 @@ class DownloaderSong:
|
||||
"disc": tags_raw["discNumber"],
|
||||
"disc_total": tags_raw["discCount"],
|
||||
"gapless": tags_raw["gapless"],
|
||||
"genre": tags_raw["genre"],
|
||||
"genre": tags_raw.get("genre"),
|
||||
"genre_id": tags_raw["genreId"],
|
||||
"lyrics": lyrics_unsynced if lyrics_unsynced else None,
|
||||
"media_type": 1,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ description = "A Python CLI app for downloading Apple Music songs/music videos/p
|
||||
requires-python = ">=3.8"
|
||||
authors = [{ name = "glomatico" }]
|
||||
dependencies = [
|
||||
"ciso8601",
|
||||
"click",
|
||||
"inquirerpy",
|
||||
"m3u8",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
ciso8601
|
||||
click
|
||||
inquirerpy
|
||||
m3u8
|
||||
|
||||
Reference in New Issue
Block a user