Compare commits

..

17 Commits

Author SHA1 Message Date
Rafael Moraes f670fe8e95 Bump version to 3.5 2026-04-27 09:19:46 -03:00
Rafael Moraes 8f184fcb66 Remove '-28' from X-Apple-Store-Front header 2026-04-27 09:17:36 -03:00
Rafael Moraes 3765ef0df4 Set storefront_id None for non-US iTunes API 2026-04-27 08:56:43 -03:00
Rafael Moraes 4e28b7e9a3 Enable redirects and use correct storefront header 2026-04-27 08:54:22 -03:00
Rafael Moraes a009071a8d Bump version to 3.4 2026-04-27 06:35:39 -03:00
Rafael Moraes 64b1974232 Include filter result in exclusion error message 2026-04-27 06:35:00 -03:00
Rafael Moraes 37ede6572e Add overwrite flag to Database 2026-04-27 06:34:51 -03:00
Rafael Moraes 2e57216c3c Strip size suffix from Apple Music cover URLs 2026-04-27 06:25:55 -03:00
Rafael Moraes 5d242c89cd Remove 'level' and 'event' from event_dict 2026-04-26 11:41:47 -03:00
Rafael Moraes e5675f8874 Use CustomOutputWriter for structlog output 2026-04-26 00:38:08 -03:00
Rafael Moraes 716112c294 Use default_factory for DownloadItem uuid 2026-04-25 14:52:19 -03:00
Rafael Moraes 63ad0f2e07 Respect skip_cleanup when removing temp files 2026-04-25 14:28:09 -03:00
Rafael Moraes 939520b3f8 Stringify subprocess args in error message 2026-04-25 14:02:52 -03:00
Rafael Moraes df23276d3c Improve subprocess error message 2026-04-25 13:56:55 -03:00
Rafael Moraes a9227493ea Include subprocess output in async errors 2026-04-25 13:03:48 -03:00
Rafael Moraes 9375c2fccd Bump version to 3.3 2026-04-24 19:48:58 -03:00
Rafael Moraes c83e47df0c Remove total arg from media fetch calls 2026-04-24 19:48:27 -03:00
13 changed files with 83 additions and 38 deletions
+1 -1
View File
@@ -1 +1 @@
__version__ = "3.2"
__version__ = "3.5"
+2 -1
View File
@@ -77,6 +77,7 @@ class ItunesApi:
client = httpx.AsyncClient(
timeout=60.0,
follow_redirects=True,
)
return cls(
@@ -133,7 +134,7 @@ class ItunesApi:
response = await self.client.get(
ITUNES_PAGE_API_URL.format(media_type=media_type, media_id=media_id),
headers={
"X-Apple-Store-Front": f"{self.storefront_id}-1,32 t:music31",
"X-Apple-Store-Front": f"{self.storefront_id},32 t:music31",
},
)
response.raise_for_status()
+6 -14
View File
@@ -1,5 +1,4 @@
import asyncio
import logging
from functools import wraps
from pathlib import Path
@@ -38,7 +37,7 @@ from .cli_config import CliConfig
from .config_file import ConfigFile
from .database import Database
from .interactive_prompts import InteractivePrompts
from .utils import custom_structlog_formatter, prompt_path
from .utils import CustomOutputWriter, custom_structlog_formatter, prompt_path
logger = structlog.get_logger(__name__)
@@ -60,18 +59,10 @@ def make_sync(func):
async def main(config: CliConfig):
colorama.just_fix_windows_console()
root_logger = logging.getLogger(__name__.split(".")[0])
root_logger.setLevel(config.log_level)
root_logger.propagate = False
stream_handler = logging.StreamHandler()
stream_handler.setFormatter(logging.Formatter("%(message)s"))
root_logger.addHandler(stream_handler)
log_output = CustomOutputWriter()
if config.log_file:
file_handler = logging.FileHandler(config.log_file, encoding="utf-8")
file_handler.setFormatter(logging.Formatter("%(message)s"))
root_logger.addHandler(file_handler)
log_output.add_file(config.log_file)
structlog.configure(
processors=[
@@ -79,7 +70,8 @@ async def main(config: CliConfig):
structlog.processors.ExceptionPrettyPrinter(),
custom_structlog_formatter,
],
logger_factory=structlog.stdlib.LoggerFactory(),
logger_factory=structlog.PrintLoggerFactory(file=log_output),
wrapper_class=structlog.make_filtering_bound_logger(config.log_level),
)
logger.info(f"Starting Gamdl {__version__}")
@@ -127,7 +119,7 @@ async def main(config: CliConfig):
)
if config.database_path:
database = Database(config.database_path)
database = Database(config.database_path, config.overwrite)
flat_filter = database.flat_filter
else:
database = None
+12 -2
View File
@@ -3,7 +3,13 @@ from pathlib import Path
class Database:
def __init__(self, path: Path):
def __init__(
self,
path: Path,
overwrite: bool,
):
self.overwrite = overwrite
self.connection = sqlite3.connect(path)
self.cursor = self.connection.cursor()
self._create_tables()
@@ -45,4 +51,8 @@ class Database:
if not result:
return None
return result if Path(result).exists() else None
return (
"Registered in database"
if Path(result).exists() and not self.overwrite
else None
)
+25 -2
View File
@@ -1,3 +1,5 @@
import atexit
import sys
from datetime import datetime
from enum import Enum
from pathlib import Path
@@ -39,12 +41,33 @@ class Csv(click.ParamType):
return result
class CustomOutputWriter:
def __init__(
self,
streams: list[Any] = [sys.stdout],
):
self.streams = streams
def add_file(self, path: str):
file_stream = open(path, "a")
atexit.register(file_stream.close)
self.streams.append(file_stream)
def write(self, message: str):
for stream in self.streams:
stream.write(message)
def flush(self):
for stream in self.streams:
stream.flush()
def custom_structlog_formatter(
logger: Any,
name: str,
event_dict: dict[str, Any],
) -> str:
level = event_dict.get("level", "INFO").upper()
level = event_dict.pop("level", "INFO").upper()
timestamp = datetime.now().strftime("%H:%M:%S")
level_colors = {
@@ -63,7 +86,7 @@ def custom_structlog_formatter(
prefix += click.style(f" [{action}]", dim=True)
if level in {"INFO", "WARNING", "ERROR", "CRITICAL"}:
message = event_dict.get("event", "")
message = event_dict.pop("event", "")
return f"{prefix} {message}"
else:
return f"{prefix} {event_dict}"
+3 -2
View File
@@ -88,7 +88,8 @@ class AppleMusicDownloader:
await self._download(item)
await self._final_processing(item)
finally:
self._cleanup_temp(item.uuid_)
if not self.skip_cleanup:
self._cleanup_temp(item.uuid_)
def _update_playlist_file(
self,
@@ -263,6 +264,6 @@ class AppleMusicDownloader:
log = logger.bind(action="cleanup_temp", folder_tag=folder_tag)
temp_path = Path(self.base.temp_path) / TEMP_PATH_TEMPLATE.format(folder_tag)
if temp_path.exists() and temp_path.is_dir() and not self.skip_cleanup:
if temp_path.exists() and temp_path.is_dir():
shutil.rmtree(temp_path, ignore_errors=True)
log.debug("success")
+2 -2
View File
@@ -1,5 +1,5 @@
import uuid
from dataclasses import dataclass
from dataclasses import dataclass, field
from ..interface.types import AppleMusicMedia
@@ -7,7 +7,7 @@ from ..interface.types import AppleMusicMedia
@dataclass
class DownloadItem:
media: AppleMusicMedia
uuid_: str = uuid.uuid4().hex[:8]
uuid_: str = field(default_factory=lambda: uuid.uuid4().hex[:8])
staged_path: str = None
final_path: str = None
playlist_file_path: str = None
+13 -4
View File
@@ -133,6 +133,11 @@ class AppleMusicBaseInterface:
itunes_api = itunes_api or await ItunesApi.create(
storefront=apple_music_api.storefront,
language=apple_music_api.language,
**(
{"storefront_id": None}
if apple_music_api.storefront.lower() != "us"
else {}
),
)
cdm = cls.create_cdm(wvd_path)
@@ -223,12 +228,16 @@ class AppleMusicBaseInterface:
def _get_raw_cover_url(self, cover_url_template: str) -> str:
return re.sub(
r"image/thumb/",
r"/\{w\}x\{h\}bb\.jpg",
"",
re.sub(
r"is1-ssl",
"a1",
cover_url_template,
r"image/thumb/",
"",
re.sub(
r"is1-ssl",
"a1",
cover_url_template,
),
),
)
+3 -1
View File
@@ -46,6 +46,8 @@ class GamdlInterfaceArtistMediaTypeError(GamdlInterfaceError):
class GamdlInterfaceFlatFilterExcludedError(GamdlInterfaceError):
def __init__(self, media_id: str, result: Any):
super().__init__(f"Media excluded by flat filter: {media_id}")
super().__init__(
f"Media excluded by flat filter (media ID: {media_id}): {result}"
)
self.result = result
-2
View File
@@ -281,7 +281,6 @@ class AppleMusicInterface:
self._get_song_media(
media_id=track["id"],
index=index,
total=base_media.media_metadata["attributes"]["trackCount"],
media_metadata=track,
playlist_metadata=base_media.media_metadata,
)
@@ -289,7 +288,6 @@ class AppleMusicInterface:
else self._get_music_video_media(
media_id=track["id"],
index=index,
total=base_media.media_metadata["attributes"]["trackCount"],
media_metadata=track,
playlist_metadata=base_media.media_metadata,
)
+14 -5
View File
@@ -1,14 +1,13 @@
import asyncio
import string
import subprocess
import typing
async def async_subprocess(*args: str, silent: bool = False) -> None:
if silent:
additional_args = {
"stdout": subprocess.DEVNULL,
"stderr": subprocess.DEVNULL,
"stdout": asyncio.subprocess.PIPE,
"stderr": asyncio.subprocess.PIPE,
}
else:
additional_args = {}
@@ -17,10 +16,20 @@ async def async_subprocess(*args: str, silent: bool = False) -> None:
*args,
**additional_args,
)
await proc.communicate()
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
raise Exception(f'"{args[0]}" exited with code {proc.returncode}')
msg = (
f"Exited with code {proc.returncode}: {' '.join(str(arg) for arg in args)}"
)
if stdout:
msg += f"\nstdout:\n{stdout.decode()}"
if stderr:
msg += f"\nstderr:\n{stderr.decode()}"
raise Exception(msg)
async def safe_gather(
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "gamdl"
version = "3.2"
version = "3.5"
description = "A command-line app for downloading Apple Music songs, music videos and post videos."
readme = "README.md"
license = "MIT"
Generated
+1 -1
View File
@@ -223,7 +223,7 @@ wheels = [
[[package]]
name = "gamdl"
version = "3.2"
version = "3.5"
source = { virtual = "." }
dependencies = [
{ name = "async-lru" },