850 lines
56 KiB
Python
850 lines
56 KiB
Python
'''
|
|
Function:
|
|
Implementation of AppleMusicClient Utils (Refer To https://github.com/glomatico/gamdl)
|
|
Author:
|
|
Zhenchao Jin
|
|
WeChat Official Account (微信公众号):
|
|
Charles的皮卡丘
|
|
'''
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import os
|
|
import io
|
|
import m3u8
|
|
import uuid
|
|
import json
|
|
import base64
|
|
import shutil
|
|
import datetime
|
|
import requests
|
|
import subprocess
|
|
from enum import Enum
|
|
from typing import Any
|
|
from pathlib import Path
|
|
from xml.dom import minidom
|
|
from mutagen.mp4 import MP4
|
|
from xml.etree import ElementTree
|
|
from dataclasses import dataclass
|
|
from platformdirs import user_log_dir
|
|
from pywidevine import PSSH, Cdm, Device
|
|
from urllib.parse import parse_qs, urlparse
|
|
from .misc import safeextractfromdict, resp2json
|
|
from pywidevine.license_protocol_pb2 import WidevinePsshData
|
|
|
|
|
|
'''settings'''
|
|
FOURCC_MAP = {"h264": "avc1", "h265": "hvc1"}
|
|
MEDIA_TYPE_STR_MAP = {1: "Song", 6: "Music Video"}
|
|
LEGACY_SONG_CODECS = {"aac-legacy", "aac-he-legacy"}
|
|
IMAGE_FILE_EXTENSION_MAP = {"jpeg": ".jpg", "tiff": ".tif"}
|
|
MEDIA_RATING_STR_MAP = {0: "None", 1: "Explicit", 2: "Clean"}
|
|
MP4_FORMAT_CODECS = ["ec-3", "hvc1", "audio-atmos", "audio-ec3"]
|
|
SONG_MEDIA_TYPE = {"song", "songs", "library-songs"}
|
|
ALBUM_MEDIA_TYPE = {"album", "albums", "library-albums"}
|
|
MUSIC_VIDEO_MEDIA_TYPE = {"music-video", "music-videos", "library-music-videos"}
|
|
ARTIST_MEDIA_TYPE = {"artist", "artists", "library-artists"}
|
|
UPLOADED_VIDEO_MEDIA_TYPE = {"post", "uploaded-videos"}
|
|
PLAYLIST_MEDIA_TYPE = {"playlist", "playlists", "library-playlists"}
|
|
UPLOADED_VIDEO_QUALITY_RANK = ["1080pHdVideo", "720pHdVideo", "sdVideoWithPlusAudio", "sdVideo", "sd480pVideo", "provisionalUploadVideo"]
|
|
SONG_CODEC_REGEX_MAP = {
|
|
"aac": r"audio-stereo-\d+", "aac-he": r"audio-HE-stereo-\d+", "aac-binaural": r"audio-stereo-\d+-binaural", "aac-downmix": r"audio-stereo-\d+-downmix", "aac-he-binaural": r"audio-HE-stereo-\d+-binaural",
|
|
"aac-he-downmix": r"audio-HE-stereo-\d+-downmix", "atmos": r"audio-atmos-.*", "ac3": r"audio-ac3-.*", "alac": r"audio-alac-.*",
|
|
}
|
|
DRM_DEFAULT_KEY_MAPPING = {
|
|
"urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed": ("data:text/plain;base64,AAAAOHBzc2gAAAAA7e+LqXnWSs6jyCfc1R0h7QAAABgSEAAAAAAAAAAAczEvZTEgICBI88aJmwY="),
|
|
"com.microsoft.playready": ("data:text/plain;charset=UTF-16;base64,vgEAAAEAAQC0ATwAVwBSAE0ASABFAEEARABFAFIAIAB4AG0AbABuAHMAPQAiAGgAdAB0AHAAOgAvAC8AcwBjAGgAZQBtAGEAcwAuAG0AaQBjAHIAbwBzAG8AZgB0AC4AYwBvAG0ALwBEAFIATQAvADIAMAAwADcALwAwADMALwBQAGwAYQB5AFIAZQBhAGQAeQBIAGUAYQBkAGUAcgAiACAAdgBlAHIAcwBpAG8AbgA9ACIANAAuADMALgAwAC4AMAAiAD4APABEAEEAVABBAD4APABQAFIATwBUAEUAQwBUAEkATgBGAE8APgA8AEsASQBEAFMAPgA8AEsASQBEACAAQQBMAEcASQBEAD0AIgBBAEUAUwBDAEIAQwAiACAAVgBBAEwAVQBFAD0AIgBBAEEAQQBBAEEAQQBBAEEAQQBBAEIAegBNAFMAOQBsAE0AUwBBAGcASQBBAD0APQAiAD4APAAvAEsASQBEAD4APAAvAEsASQBEAFMAPgA8AC8AUABSAE8AVABFAEMAVABJAE4ARgBPAD4APAAvAEQAQQBUAEEAPgA8AC8AVwBSAE0ASABFAEEARABFAFIAPgA="),
|
|
"com.apple.streamingkeydelivery": "skd://itunes.apple.com/P000000000/s1/e1",
|
|
}
|
|
DEFAULT_SONG_DECRYPTION_KEY = "32b8ade1769e26b1ffb8986352793fc6"
|
|
HARDCODED_WVD = """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QrSyZcBqHO0QrdPcU35GBT7S7+IJr2AAXvnjqnb8yrtpPWN2ZW/IWUJN2z4vZ7/HV4aj3OZhkxC1DIMNyvsusUKoQQuf8gwKiEe8cFwbwFSicywlFk9la2IPe8oFShcxAzHLCCn/TIYUAvEL3/4LgaZvqWm80qCPYbgIP5HT8hPYkKWJ4WYknEWK+3InbnkzteFfGrQFCq4CCAESEGnj6Ji7LD+4o7MoHYT4jBQYjtW+kQUijgIwggEKAoIBAQDY9um1ifBRIOmkPtDZTqH+CZUBbb0eK0Cn3NHFf8MFUDzPEz+emK/OTub/hNxCJCao//pP5L8tRNUPFDrrvCBMo7Rn+iUb+mA/2yXiJ6ivqcN9Cu9i5qOU1ygon9SWZRsujFFB8nxVreY5Lzeq0283zn1Cg1stcX4tOHT7utPzFG/ReDFQt0O/GLlzVwB0d1sn3SKMO4XLjhZdncrtF9jljpg7xjMIlnWJUqxDo7TQkTytJmUl0kcM7bndBLerAdJFGaXc6oSY4eNy/IGDluLCQR3KZEQsy/mLeV1ggQ44MFr7XOM+rd+4/314q/deQbjHqjWFuVr8iIaKbq+R63ShAgMBAAEo8CISgAMii2Mw6z+Qs1bvvxGStie9tpcgoO2uAt5Zvv0CDXvrFlwnSbo+qR71Ru2IlZWVSbN5XYSIDwcwBzHjY8rNr3fgsXtSJty425djNQtF5+J2jrAhf3Q2m7EI5aohZGpD2E0cr+dVj9o8x0uJR2NWR8FVoVQSXZpad3M/4QzBLNto/tz+UKyZwa7Sc/eTQc2+ZcDS3ZEO3lGRsH864Kf/cEGvJRBBqcpJXKfG+ItqEW1AAPptjuggzmZEzRq5xTGf6or+bXrKjCpBS9G1SOyvCNF1k5z6lG8KsXhgQxL6ADHMoulxvUIihyPY5MpimdXfUdEQ5HA2EqNiNVNIO4qP007jW51yAeThOry4J22xs8RdkIClOGAauLIl0lLA4flMzW+VfQl5xYxP0E5tuhn0h+844DslU8ZF7U1dU2QprIApffXD9wgAACk26Rggy8e96z8i86/+YYyZQkc9hIdCAERrgEYCEbByzONrdRDs1MrS/ch1moV5pJv63BIKvQHGvLkaFwoMY29tcGFueV9uYW1lEgd1bmtub3duGioKCm1vZGVsX25hbWUSHEFuZHJvaWQgU0RLIGJ1aWx0IGZvciB4ODZfNjQaGwoRYXJjaGl0ZWN0dXJlX25hbWUSBng4Nl82NBodCgtkZXZpY2VfbmFtZRIOZ2VuZXJpY194ODZfNjQaIAoMcHJvZHVjdF9uYW1lEhBzZGtfcGhvbmVfeDg2XzY0GmMKCmJ1aWxkX2luZm8SVUFuZHJvaWQvc2RrX3Bob25lX3g4Nl82NC9nZW5lcmljX3g4Nl82NDo5L1BTUjEuMTgwNzIwLjAxMi80OTIzMjE0OnVzZXJkZWJ1Zy90ZXN0LWtleXMaHgoUd2lkZXZpbmVfY2RtX3ZlcnNpb24SBjE0LjAuMBokCh9vZW1fY3J5cHRvX3NlY3VyaXR5X3BhdGNoX2xldmVsEgEwMg4QASAAKA0wAEAASABQAA=="""
|
|
APPLE_MUSIC_COOKIE_DOMAIN = ".music.apple.com"
|
|
AMP_API_URL = "https://amp-api.music.apple.com"
|
|
ITUNES_PAGE_API_URL = "https://music.apple.com"
|
|
APPLE_MUSIC_HOMEPAGE_URL = "https://music.apple.com"
|
|
ITUNES_LOOKUP_API_URL = "https://itunes.apple.com/lookup"
|
|
WEBPLAYBACK_API_URL = "https://play.itunes.apple.com/WebObjects/MZPlay.woa/wa/webPlayback"
|
|
LICENSE_API_URL = "https://play.itunes.apple.com/WebObjects/MZPlay.woa/wa/acquireWebPlaybackLicense"
|
|
STOREFRONT_IDS = {
|
|
"AE": "143481-2,32", "AG": "143540-2,32", "AI": "143538-2,32", "AL": "143575-2,32", "AM": "143524-2,32", "AO": "143564-2,32", "AR": "143505-28,32", "AT": "143445-4,32",
|
|
"AU": "143460-27,32", "AZ": "143568-2,32", "BB": "143541-2,32", "BE": "143446-2,32", "BF": "143578-2,32", "BG": "143526-2,32", "BH": "143559-2,32", "BJ": "143576-2,32",
|
|
"BM": "143542-2,32", "BN": "143560-2,32", "BO": "143556-28,32", "BR": "143503-15,32", "BS": "143539-2,32", "BT": "143577-2,32", "BW": "143525-2,32", "BY": "143565-2,32",
|
|
"BZ": "143555-2,32", "CA": "143455-6,32", "CG": "143582-2,32", "CH": "143459-57,32", "CM": "143574-2,32", "CL": "143483-28,32", "CN": "143465-19,32", "CO": "143501-28,32",
|
|
"CR": "143495-28,32", "CV": "143580-2,32", "CY": "143557-2,32", "CZ": "143489-2,32", "DE": "143443-4,32", "DK": "143458-2,32", "DM": "143545-2,32", "DO": "143508-28,32",
|
|
"DZ": "143563-2,32", "EC": "143509-28,32", "EE": "143518-2,32", "EG": "143516-2,32", "ES": "143454-8,32", "FI": "143447-2,32", "FJ": "143583-2,32", "FM": "143591-2,32",
|
|
"FR": "143442-3,32", "GB": "143444-2,32", "GD": "143546-2,32", "GH": "143573-2,32", "GM": "143584-2,32", "GR": "143448-2,32", "GT": "143504-28,32", "GW": "143585-2,32",
|
|
"GY": "143553-2,32", "HK": "143463-45,32", "HN": "143510-28,32", "HR": "143494-2,32", "HU": "143482-2,32", "ID": "143476-2,32", "IE": "143449-2,32", "IL": "143491-2,32",
|
|
"IN": "143467-2,32", "IS": "143558-2,32", "IT": "143450-7,32", "JM": "143511-2,32", "JO": "143528-2,32", "JP": "143462-9,32", "KE": "143529-2,32", "KG": "143586-2,32",
|
|
"KH": "143579-2,32", "KN": "143548-2,32", "KR": "143466-13,32", "KW": "143493-2,32", "KY": "143544-2,32", "KZ": "143517-2,32", "LA": "143587-2,32", "LB": "143497-2,32",
|
|
"LC": "143549-2,32", "LK": "143486-2,32", "LR": "143588-2,32", "LT": "143520-2,32", "LU": "143451-2,32", "LV": "143519-2,32", "MD": "143523-2,32", "MG": "143531-2,32",
|
|
"MK": "143530-2,32", "ML": "143532-2,32", "MN": "143592-2,32", "MO": "143515-45,32", "MR": "143590-2,32", "MS": "143547-2,32", "MT": "143521-2,32", "MU": "143533-2,32",
|
|
"MW": "143589-2,32", "MX": "143468-28,32", "MY": "143473-2,32", "MZ": "143593-2,32", "NA": "143594-2,32", "NE": "143534-2,32", "NG": "143561-2,32", "NI": "143512-28,32",
|
|
"NL": "143452-10,32", "NO": "143457-2,32", "NP": "143484-2,32", "NZ": "143461-27,32", "OM": "143562-2,32", "PA": "143485-28,32", "PE": "143507-28,32", "PG": "143597-2,32",
|
|
"PH": "143474-2,32", "PK": "143477-2,32", "PL": "143478-2,32", "PT": "143453-24,32", "PW": "143595-2,32", "PY": "143513-28,32", "QA": "143498-2,32", "RO": "143487-2,32",
|
|
"RU": "143469-16,32", "SA": "143479-2,32", "SB": "143601-2,32", "SC": "143599-2,32", "SE": "143456-17,32", "SG": "143464-19,32", "SI": "143499-2,32", "SK": "143496-2,32",
|
|
"SL": "143600-2,32", "SN": "143535-2,32", "SR": "143554-2,32", "ST": "143598-2,32", "SV": "143506-28,32", "SZ": "143602-2,32", "TC": "143552-2,32", "TD": "143581-2,32",
|
|
"TH": "143475-2,32", "TJ": "143603-2,32", "TM": "143604-2,32", "TN": "143536-2,32", "TR": "143480-2,32", "TT": "143551-2,32", "TW": "143470-18,32", "TZ": "143572-2,32",
|
|
"UA": "143492-2,32", "UG": "143537-2,32", "US": "143441-1,32", "UY": "143514-2,32", "UZ": "143566-2,32", "VC": "143550-2,32", "VE": "143502-28,32", "VG": "143543-2,32",
|
|
"VN": "143471-2,32", "YE": "143571-2,32", "ZA": "143472-2,32", "ZW": "143605-2,32",
|
|
}
|
|
|
|
|
|
'''CoverFormat'''
|
|
class CoverFormat(Enum):
|
|
JPG = "jpg"
|
|
PNG = "png"
|
|
RAW = "raw"
|
|
|
|
|
|
'''RemuxFormatMusicVideo'''
|
|
class RemuxFormatMusicVideo(Enum):
|
|
M4V = "m4v"
|
|
MP4 = "mp4"
|
|
|
|
|
|
'''SyncedLyricsFormat'''
|
|
class SyncedLyricsFormat(Enum):
|
|
LRC = "lrc"
|
|
SRT = "srt"
|
|
TTML = "ttml"
|
|
|
|
|
|
'''MediaType'''
|
|
class MediaType(Enum):
|
|
SONG = 1
|
|
MUSIC_VIDEO = 6
|
|
def __str__(self): return MEDIA_TYPE_STR_MAP[self.value]
|
|
def __int__(self): return self.value
|
|
|
|
|
|
'''MediaRating'''
|
|
class MediaRating(Enum):
|
|
NONE = 0
|
|
EXPLICIT = 1
|
|
CLEAN = 2
|
|
def __str__(self): return MEDIA_RATING_STR_MAP[self.value]
|
|
def __int__(self): return self.value
|
|
|
|
|
|
'''MediaFileFormat'''
|
|
class MediaFileFormat(Enum):
|
|
MP4 = "mp4"
|
|
M4V = "m4v"
|
|
M4A = "m4a"
|
|
|
|
|
|
'''DownloadMode'''
|
|
class DownloadMode(Enum):
|
|
NM3U8DLRE = "nm3u8dlre"
|
|
|
|
|
|
'''RemuxMode'''
|
|
class RemuxMode(Enum):
|
|
FFMPEG = "ffmpeg"
|
|
MP4BOX = "mp4box"
|
|
|
|
|
|
'''SongCodec'''
|
|
class SongCodec(Enum):
|
|
AAC_LEGACY = "aac-legacy"
|
|
AAC_HE_LEGACY = "aac-he-legacy"
|
|
AAC = "aac"
|
|
AAC_HE = "aac-he"
|
|
AAC_BINAURAL = "aac-binaural"
|
|
AAC_DOWNMIX = "aac-downmix"
|
|
AAC_HE_BINAURAL = "aac-he-binaural"
|
|
AAC_HE_DOWNMIX = "aac-he-downmix"
|
|
ATMOS = "atmos"
|
|
AC3 = "ac3"
|
|
ALAC = "alac"
|
|
ASK = "ask"
|
|
def islegacy(self): return self.value in LEGACY_SONG_CODECS
|
|
|
|
|
|
'''MusicVideoCodec'''
|
|
class MusicVideoCodec(Enum):
|
|
H264 = "h264"
|
|
H265 = "h265"
|
|
ASK = "ask"
|
|
def fourcc(self): return FOURCC_MAP[self.value]
|
|
|
|
|
|
'''MusicVideoResolution'''
|
|
class MusicVideoResolution(Enum):
|
|
R240P = "240p"
|
|
R360P = "360p"
|
|
R480P = "480p"
|
|
R540P = "540p"
|
|
R720P = "720p"
|
|
R1080P = "1080p"
|
|
R1440P = "1440p"
|
|
R2160P = "2160p"
|
|
def __int__(self): return int(self.value[:-1])
|
|
|
|
|
|
'''Lyrics'''
|
|
@dataclass
|
|
class Lyrics:
|
|
synced: str = None
|
|
unsynced: str = None
|
|
|
|
|
|
'''MediaTags'''
|
|
@dataclass
|
|
class MediaTags:
|
|
album: str = None
|
|
album_artist: str = None
|
|
album_id: int = None
|
|
album_sort: str = None
|
|
artist: str = None
|
|
artist_id: int = None
|
|
artist_sort: str = None
|
|
comment: str = None
|
|
compilation: bool = None
|
|
composer: str = None
|
|
composer_id: int = None
|
|
composer_sort: str = None
|
|
copyright: str = None
|
|
date: datetime.date | str = None
|
|
disc: int = None
|
|
disc_total: int = None
|
|
gapless: bool = None
|
|
genre: str = None
|
|
genre_id: int = None
|
|
lyrics: str = None
|
|
media_type: MediaType = None
|
|
rating: MediaRating = None
|
|
storefront: str = None
|
|
title: str = None
|
|
title_id: int = None
|
|
title_sort: str = None
|
|
track: int = None
|
|
track_total: int = None
|
|
xid: str = None
|
|
'''asmp4tags'''
|
|
def asmp4tags(self, date_format: str = None):
|
|
disc_mp4 = [self.disc if self.disc is not None else 0, self.disc_total if self.disc_total is not None else 0]
|
|
if disc_mp4[0] == 0 and disc_mp4[1] == 0: disc_mp4 = None
|
|
track_mp4 = [self.track if self.track is not None else 0, self.track_total if self.track_total is not None else 0]
|
|
if track_mp4[0] == 0 and track_mp4[1] == 0: track_mp4 = None
|
|
if isinstance(self.date, datetime.date):
|
|
if date_format is None: date_mp4 = self.date.isoformat()
|
|
else: date_mp4 = self.date.strftime(date_format)
|
|
elif isinstance(self.date, str):
|
|
date_mp4 = self.date
|
|
else:
|
|
date_mp4 = None
|
|
mp4_tags = {
|
|
"\xa9alb": self.album, "aART": self.album_artist, "plID": self.album_id, "soal": self.album_sort, "\xa9ART": self.artist, "atID": self.artist_id, "soar": self.artist_sort, "\xa9cmt": self.comment, "xid": self.xid,
|
|
"cpil": bool(self.compilation) if self.compilation is not None else None, "\xa9wrt": self.composer, "cmID": self.composer_id, "soco": self.composer_sort, "cprt": self.copyright, "\xa9day": date_mp4, "trkn": track_mp4,
|
|
"disk": disc_mp4, "pgap": bool(self.gapless) if self.gapless is not None else None, "\xa9lyr": self.lyrics, "geID": self.genre_id, "stik": int(self.media_type) if self.media_type is not None else None, "\xa9nam": self.title,
|
|
"\xa9gen": self.genre, "rtng": int(self.rating) if self.rating is not None else None, "sfID": self.storefront, "cnID": self.title_id, "sonm": self.title_sort,
|
|
}
|
|
return {k: ([v] if not isinstance(v, bool) else v) for k, v in mp4_tags.items() if v is not None}
|
|
|
|
|
|
'''PlaylistTags'''
|
|
@dataclass
|
|
class PlaylistTags:
|
|
playlist_artist: str = None
|
|
playlist_id: int = None
|
|
playlist_title: str = None
|
|
playlist_track: int = None
|
|
|
|
|
|
'''StreamInfo'''
|
|
@dataclass
|
|
class StreamInfo:
|
|
stream_url: str = None
|
|
widevine_pssh: str = None
|
|
playready_pssh: str = None
|
|
fairplay_key: str = None
|
|
codec: str = None
|
|
width: int = None
|
|
height: int = None
|
|
|
|
|
|
'''StreamInfoAv'''
|
|
@dataclass
|
|
class StreamInfoAv:
|
|
media_id: str = None
|
|
video_track: StreamInfo = None
|
|
audio_track: StreamInfo = None
|
|
file_format: MediaFileFormat = None
|
|
|
|
|
|
'''DecryptionKey'''
|
|
@dataclass
|
|
class DecryptionKey:
|
|
kid: str = None
|
|
key: str = None
|
|
|
|
|
|
'''DecryptionKeyAv'''
|
|
@dataclass
|
|
class DecryptionKeyAv:
|
|
video_track: DecryptionKey = None
|
|
audio_track: DecryptionKey = None
|
|
|
|
|
|
'''DownloadItem'''
|
|
@dataclass
|
|
class DownloadItem:
|
|
media_metadata: dict = None
|
|
playlist_metadata: dict = None
|
|
random_uuid: str = None
|
|
lyrics: Lyrics = None
|
|
media_tags: MediaTags = None
|
|
extra_tags: dict = None
|
|
playlist_tags: PlaylistTags = None
|
|
stream_info: StreamInfoAv = None
|
|
decryption_key: DecryptionKeyAv = None
|
|
cover_url_template: str = None
|
|
cover_url: str = None
|
|
staged_path: str = None
|
|
final_path: str = None
|
|
playlist_file_path: str = None
|
|
synced_lyrics_path: str = None
|
|
cover_path: str = None
|
|
flat_filter_result: Any = None
|
|
error: Exception = None
|
|
|
|
|
|
'''UrlInfo'''
|
|
@dataclass
|
|
class UrlInfo:
|
|
storefront: str = None
|
|
type: str = None
|
|
slug: str = None
|
|
id: str = None
|
|
sub_id: str = None
|
|
library_storefront: str = None
|
|
library_type: str = None
|
|
library_id: str = None
|
|
|
|
|
|
'''AppleMusicClientAPIUtils'''
|
|
class AppleMusicClientAPIUtils:
|
|
def __init__(self, storefront: str = "us", language: str = "en-US", media_user_token: str | None = None, developer_token: str | None = None) -> None:
|
|
self.storefront = storefront
|
|
self.language = language
|
|
self.media_user_token = media_user_token
|
|
self.token = developer_token
|
|
@property
|
|
def active_subscription(self) -> bool: return safeextractfromdict(getattr(self, "account_info", {}), ['meta', 'subscription', 'active'], False)
|
|
@property
|
|
def account_restrictions(self) -> dict | None: return safeextractfromdict(getattr(self, "account_info", {}), ['data', 0, 'attributes', 'restrictions'], None)
|
|
'''createfromnetscapecookies'''
|
|
@classmethod
|
|
def createfromnetscapecookies(cls, cookies: dict, request_overrides: dict = None, *args, **kwargs) -> "AppleMusicClientAPIUtils":
|
|
request_overrides = request_overrides or {}
|
|
media_user_token = cookies.get('media-user-token')
|
|
if not media_user_token: raise ValueError('"media-user-token" is not configured in cookies.')
|
|
return cls.create(storefront=None, media_user_token=media_user_token, developer_token=None, request_overrides=request_overrides, *args, **kwargs)
|
|
'''createfromwrapper'''
|
|
@classmethod
|
|
def createfromwrapper(cls, wrapper_account_url: str = "http://127.0.0.1:30020/", request_overrides: dict = None, *args, **kwargs) -> "AppleMusicClientAPIUtils":
|
|
request_overrides = request_overrides or {}
|
|
wrapper_account_response = requests.get(wrapper_account_url)
|
|
wrapper_account_response.raise_for_status()
|
|
wrapper_account_info = wrapper_account_response.json()
|
|
return cls.create(storefront=None, media_user_token=wrapper_account_info["music_token"], developer_token=wrapper_account_info["dev_token"], request_overrides=request_overrides, *args, **kwargs)
|
|
'''create'''
|
|
@classmethod
|
|
def create(cls, storefront: str | None = "us", language: str = "en-US", media_user_token: str | None = None, developer_token: str | None = None, request_overrides: dict = None) -> "AppleMusicClientAPIUtils":
|
|
request_overrides = request_overrides or {}
|
|
api = cls(storefront=storefront, language=language, media_user_token=media_user_token, developer_token=developer_token)
|
|
api.initialize(request_overrides=request_overrides)
|
|
return api
|
|
'''initialize'''
|
|
def initialize(self, request_overrides: dict = None) -> None:
|
|
request_overrides = request_overrides or {}
|
|
self.initializeclient(); self.initializetoken(request_overrides=request_overrides); self.initializeaccountinfo(request_overrides=request_overrides)
|
|
'''initializeclient'''
|
|
def initializeclient(self) -> None:
|
|
self.client = requests.Session()
|
|
self.client.headers.update({
|
|
"accept": "*/*", "accept-language": "en-US", "origin": APPLE_MUSIC_HOMEPAGE_URL, "priority": "u=1, i", "referer": APPLE_MUSIC_HOMEPAGE_URL, "sec-ch-ua": '"Google Chrome";v="137", "Chromium";v="137", "Not/A)Brand";v="24"', "sec-ch-ua-mobile": "?0",
|
|
"sec-ch-ua-platform": '"Windows"', "sec-fetch-dest": "empty", "sec-fetch-mode": "cors", "sec-fetch-site": "same-site", "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36",
|
|
})
|
|
'''gettoken'''
|
|
def gettoken(self, request_overrides: dict = None) -> str:
|
|
request_overrides = request_overrides or {}
|
|
(resp := self.client.get(APPLE_MUSIC_HOMEPAGE_URL, params={"l": self.language}, allow_redirects=True, **request_overrides)).raise_for_status()
|
|
index_js_uri_match = re.search(r"/(assets/index-legacy[~-][^/\"]+\.js)", resp.text)
|
|
if not index_js_uri_match: raise Exception("index.js URI not found in Apple Music homepage")
|
|
index_js_uri = index_js_uri_match.group(1)
|
|
(resp := self.client.get(f"{APPLE_MUSIC_HOMEPAGE_URL}/{index_js_uri}", params={"l": self.language}, allow_redirects=True, **request_overrides)).raise_for_status()
|
|
token_match = re.search('(?=eyJh)(.*?)(?=")', resp.text)
|
|
if not token_match: raise Exception("Token not found in index.js page")
|
|
token = token_match.group(1)
|
|
return token
|
|
'''initializetoken'''
|
|
def initializetoken(self, request_overrides: dict = None) -> None:
|
|
request_overrides = request_overrides or {}
|
|
self.token = self.token or self.gettoken(request_overrides=request_overrides)
|
|
self.client.headers.update({"authorization": f"Bearer {self.token}"})
|
|
'''initializeaccountinfo'''
|
|
def initializeaccountinfo(self, request_overrides: dict = None) -> None:
|
|
request_overrides = request_overrides or {}
|
|
if not self.media_user_token: return
|
|
self.client.cookies.update({"media-user-token": self.media_user_token})
|
|
self.account_info = self.getaccountinfo(request_overrides=request_overrides)
|
|
self.storefront = safeextractfromdict(self.account_info, ['meta', 'subscription', 'storefront'], 'us')
|
|
'''getaccountinfo'''
|
|
def getaccountinfo(self, meta: str | None = "subscription", request_overrides: dict = None) -> dict:
|
|
request_overrides = request_overrides or {}
|
|
(resp := self.client.get(f"{AMP_API_URL}/v1/me/account", params={**({"meta": meta} if meta else {}), **{"l": self.language}}, allow_redirects=True, **request_overrides)).raise_for_status()
|
|
account_info = resp2json(resp=resp)
|
|
if not "data" in account_info or (meta and "meta" not in account_info): raise Exception("Error getting account info: ", resp.text)
|
|
return account_info
|
|
'''getsong'''
|
|
def getsong(self, song_id: str, extend: str = "extendedAssetUrls", include: str = "lyrics,albums", request_overrides: dict = None) -> dict | None:
|
|
request_overrides = request_overrides or {}
|
|
(resp := self.client.get(f"{AMP_API_URL}/v1/catalog/{self.storefront}/songs/{song_id}", params={"extend": extend, "include": include, "l": self.language}, allow_redirects=True, **request_overrides)).raise_for_status()
|
|
song = resp2json(resp=resp)
|
|
if not ("data" in song): raise Exception("Error getting song: ", resp.text)
|
|
return song
|
|
'''getmusicvideo'''
|
|
def getmusicvideo(self, music_video_id: str, include: str = "albums", request_overrides: dict = None) -> dict | None:
|
|
request_overrides = request_overrides or {}
|
|
(resp := self.client.get(f"{AMP_API_URL}/v1/catalog/{self.storefront}/music-videos/{music_video_id}", params={"include": include, "l": self.language}, allow_redirects=True, **request_overrides)).raise_for_status()
|
|
music_video = resp2json(resp=resp)
|
|
if not ("data" in music_video): raise Exception("Error getting music video: ", resp.text)
|
|
return music_video
|
|
'''getuploadedvideo'''
|
|
def getuploadedvideo(self, post_id: str, request_overrides: dict = None) -> dict | None:
|
|
request_overrides = request_overrides or {}
|
|
(resp := self.client.get(f"{AMP_API_URL}/v1/catalog/{self.storefront}/uploaded-videos/{post_id}", params={"l": self.language}, allow_redirects=True, **request_overrides)).raise_for_status()
|
|
uploaded_video = resp2json(resp=resp)
|
|
if not ("data" in uploaded_video): raise Exception("Error getting uploaded video: ", resp.text)
|
|
return uploaded_video
|
|
'''getalbum'''
|
|
def getalbum(self, album_id: str, extend: str = "extendedAssetUrls", request_overrides: dict = None) -> dict | None:
|
|
request_overrides = request_overrides or {}
|
|
(resp := self.client.get(f"{AMP_API_URL}/v1/catalog/{self.storefront}/albums/{album_id}", params={"extend": extend, "l": self.language}, allow_redirects=True, **request_overrides)).raise_for_status()
|
|
album = resp2json(resp=resp)
|
|
if not ("data" in album): raise Exception("Error getting album: ", resp.text)
|
|
return album
|
|
'''getplaylist'''
|
|
def getplaylist(self, playlist_id: str, limit_tracks: int = 300, extend: str = "extendedAssetUrls", request_overrides: dict = None) -> dict | None:
|
|
request_overrides = request_overrides or {}
|
|
(resp := self.client.get(f"{AMP_API_URL}/v1/catalog/{self.storefront}/playlists/{playlist_id}", params={"limit[tracks]": limit_tracks, "extend": extend, "l": self.language}, allow_redirects=True, **request_overrides)).raise_for_status()
|
|
playlist = resp2json(resp=resp)
|
|
if not ("data" in playlist): raise Exception("Error getting playlist: ", resp.text)
|
|
return playlist
|
|
'''getartist'''
|
|
def getartist(self, artist_id: str, include: str = "albums,music-videos", limit: int = 100, request_overrides: dict = None) -> dict | None:
|
|
request_overrides = request_overrides or {}
|
|
(resp := self.client.get(f"{AMP_API_URL}/v1/catalog/{self.storefront}/artists/{artist_id}", params={"include": include, "l": self.language, **{f"limit[{_include}]": limit for _include in include.split(",")}}, allow_redirects=True, **request_overrides)).raise_for_status()
|
|
artist = resp2json(resp=resp)
|
|
if not ("data" in artist): raise Exception("Error getting artist:", resp.text)
|
|
return artist
|
|
'''getlibraryalbum'''
|
|
def getlibraryalbum(self, album_id: str, extend: str = "extendedAssetUrls", request_overrides: dict = None) -> dict | None:
|
|
request_overrides = request_overrides or {}
|
|
(resp := self.client.get(f"{AMP_API_URL}/v1/me/library/albums/{album_id}", params={"extend": extend, "l": self.language}, allow_redirects=True, **request_overrides)).raise_for_status()
|
|
album = resp2json(resp=resp)
|
|
if not ("data" in album): raise Exception("Error getting library album: ", resp.text)
|
|
return album
|
|
'''getlibraryplaylist'''
|
|
def getlibraryplaylist(self, playlist_id: str, include: str = "tracks", limit: int = 100, extend: str = "extendedAssetUrls", request_overrides: dict = None) -> dict | None:
|
|
request_overrides = request_overrides or {}
|
|
(resp := self.client.get(f"{AMP_API_URL}/v1/me/library/playlists/{playlist_id}", params={"include": include, **{f"limit[{_include}]": limit for _include in include.split(",")}, "extend": extend, "l": self.language}, allow_redirects=True, **request_overrides)).raise_for_status()
|
|
playlist = resp2json(resp=resp)
|
|
if not ("data" in playlist): raise Exception("Error getting library playlist: ", resp.text)
|
|
return playlist
|
|
'''getsearchresults'''
|
|
def getsearchresults(self, term: str, types: str = "songs,music-videos,albums,playlists,artists", limit: int = 50, offset: int = 0, request_overrides: dict = None) -> dict:
|
|
request_overrides = request_overrides or {}
|
|
(resp := self.client.get(f"{AMP_API_URL}/v1/catalog/{self.storefront}/search", params={"term": term, "types": types, "limit": limit, "offset": offset, "l": self.language}, allow_redirects=True, **request_overrides)).raise_for_status()
|
|
search_results = resp2json(resp=resp)
|
|
if not ("results" in search_results): raise Exception("Error searching: ", resp.text)
|
|
return search_results
|
|
'''extendapidata'''
|
|
def extendapidata(self, api_response: dict, extend: str = "extendedAssetUrls", request_overrides: dict = None):
|
|
request_overrides = request_overrides or {}
|
|
next_uri: str = api_response.get("next")
|
|
if not next_uri: return
|
|
next_uri_params = parse_qs(urlparse(next_uri).query)
|
|
limit = int(next_uri_params["offset"][0])
|
|
while next_uri:
|
|
extended_api_data = self.getextendedapidata(next_uri, limit, extend, request_overrides)
|
|
yield extended_api_data
|
|
next_uri = extended_api_data.get("next")
|
|
'''getextendedapidata'''
|
|
def getextendedapidata(self, next_uri: str, limit: int, extend: str, request_overrides: dict = None) -> dict:
|
|
request_overrides = request_overrides or {}
|
|
(resp := self.client.get(AMP_API_URL + next_uri, params={"limit": limit, "extend": extend, "l": self.language, **parse_qs(urlparse(next_uri).query)}, allow_redirects=True, **request_overrides)).raise_for_status()
|
|
extended_api_data = resp2json(resp=resp)
|
|
if not ("data" in extended_api_data): raise Exception("Error getting extended API data: ", resp.text)
|
|
return extended_api_data
|
|
'''getwebplayback'''
|
|
def getwebplayback(self, track_id: str, request_overrides: dict = None) -> dict:
|
|
request_overrides = request_overrides or {}
|
|
(resp := self.client.post(WEBPLAYBACK_API_URL, json={"salableAdamId": track_id, "language": self.language}, params={"l": self.language}, allow_redirects=True, **request_overrides)).raise_for_status()
|
|
webplayback = resp2json(resp=resp)
|
|
if not ("songList" in webplayback): raise Exception("Error getting webplayback: ", resp.text)
|
|
return webplayback
|
|
'''getlicenseexchange'''
|
|
def getlicenseexchange(self, track_id: str, track_uri: str, challenge: str, key_system: str = "com.widevine.alpha", request_overrides: dict = None) -> dict:
|
|
request_overrides = request_overrides or {}
|
|
(resp := self.client.post(LICENSE_API_URL, json={"challenge": challenge, "key-system": key_system, "uri": track_uri, "adamId": track_id, "isLibrary": False, "user-initiated": True}, params={"l": self.language}, allow_redirects=True, **request_overrides)).raise_for_status()
|
|
license_exchange = resp2json(resp=resp)
|
|
if not ("license" in license_exchange): raise Exception("Error getting license exchange: ", resp.text)
|
|
return license_exchange
|
|
|
|
|
|
'''AppleMusicClientItunesApiUtils'''
|
|
class AppleMusicClientItunesApiUtils:
|
|
def __init__(self, storefront: str = "us", language: str = "en-US") -> None:
|
|
self.storefront = storefront
|
|
self.language = language
|
|
self.initialize()
|
|
'''initialize'''
|
|
def initialize(self) -> None:
|
|
self.initializestorefrontid()
|
|
self.initializeclient()
|
|
'''initializestorefrontid'''
|
|
def initializestorefrontid(self) -> None:
|
|
try: self.storefront_id = STOREFRONT_IDS[self.storefront.upper()]
|
|
except KeyError: raise Exception(f"No storefront id for {self.storefront}")
|
|
'''initializeclient'''
|
|
def initializeclient(self) -> None:
|
|
self.client = requests.Session()
|
|
self.client.headers.update({"X-Apple-Store-Front": f"{self.storefront_id} t:music31"})
|
|
'''getlookupresult'''
|
|
def getlookupresult(self, media_id: str, entity: str = "album", request_overrides: dict = None) -> dict:
|
|
request_overrides = request_overrides or {}
|
|
(resp := self.client.get(ITUNES_LOOKUP_API_URL, params={"id": media_id, "entity": entity, "country": self.storefront, "lang": self.language}, **request_overrides)).raise_for_status()
|
|
lookup_result = resp2json(resp)
|
|
if ("results" not in lookup_result): raise Exception("Error getting lookup result: ", resp.text)
|
|
return lookup_result
|
|
'''getitunespage'''
|
|
def getitunespage(self, media_type: str, media_id: str, request_overrides: dict = None) -> dict:
|
|
request_overrides = request_overrides or {}
|
|
(resp := self.client.get(f"{ITUNES_PAGE_API_URL}/{media_type}/{media_id}", params={"country": self.storefront, "lang": self.language}, **request_overrides)).raise_for_status()
|
|
itunes_page = resp2json(resp)
|
|
if ("storePlatformData" not in itunes_page): raise Exception("Error getting iTunes page: ", resp.text)
|
|
return itunes_page
|
|
|
|
|
|
'''AppleMusicClientDownloadSongUtils'''
|
|
class AppleMusicClientDownloadSongUtils:
|
|
cdm = Cdm.from_device(Device.loads(HARDCODED_WVD))
|
|
cdm.MAX_NUM_OF_SESSIONS = float("inf")
|
|
'''getrandomuuid4'''
|
|
@staticmethod
|
|
def getrandomuuid4() -> str: return uuid.uuid4().hex
|
|
'''parsedate'''
|
|
@staticmethod
|
|
def parsedate(date: str) -> datetime.datetime: return datetime.datetime.fromisoformat(date.split("Z")[0])
|
|
'''fixkeyid'''
|
|
@staticmethod
|
|
def fixkeyid(input_path: str):
|
|
count = 0
|
|
with open(input_path, "rb+") as f:
|
|
while (data := f.read(4096)):
|
|
pos, i = f.tell(), 0
|
|
while (tenc := max(0, data.find(b"tenc", i))):
|
|
kid = tenc + 12; f.seek(max(0, pos - 4096) + kid, 0); f.write(bytes.fromhex(f"{count:032}")); count += 1; i = kid + 1
|
|
f.seek(pos, 0)
|
|
'''getmediaidoflibrarymedia'''
|
|
@staticmethod
|
|
def getmediaidoflibrarymedia(library_media_metadata: dict) -> str:
|
|
play_params = safeextractfromdict(library_media_metadata, ['attributes', 'playParams'], {})
|
|
return play_params.get("catalogId", library_media_metadata["id"])
|
|
'''getlyrics'''
|
|
@staticmethod
|
|
def getlyrics(song_metadata: dict, synced_lyrics_format: SyncedLyricsFormat = SyncedLyricsFormat.LRC, apple_music_api: AppleMusicClientAPIUtils = None, request_overrides: dict = None) -> Lyrics | None:
|
|
# no lyrics
|
|
if not safeextractfromdict(song_metadata, ['attributes', 'hasLyrics'], None): return None
|
|
# init
|
|
parse_ttml_timestamp_func = lambda ts: datetime.datetime.fromtimestamp((lambda parts: (int(parts[-2]) * 60 + int(parts[-1])) if (len(parts) == 2 and ":" in ts) else (int(parts[-1]) / 1000) if (len(parts) == 1) else ((int(parts[-3]) * 60) if (len(parts) > 2) else 0) + float(f"{parts[-2]}.{parts[-1]}"))(re.findall(r"\d+", ts)), tz=datetime.timezone.utc)
|
|
get_lyrics_line_srt_func = lambda index, element: (f"{index}\n" f"{parse_ttml_timestamp_func(element.attrib.get('begin')).strftime('%H:%M:%S,%f')[:-3]} --> " f"{parse_ttml_timestamp_func(element.attrib.get('end')).strftime('%H:%M:%S,%f')[:-3]}\n" f"{element.text}\n")
|
|
get_lyrics_line_lrc_func = lambda element: ((lambda ts, text: (lambda ms_new: f"[{((ts + (datetime.timedelta(milliseconds=((int(ms_new[:2]) + 1) * 10))) - datetime.timedelta(microseconds=ts.microsecond)) if int(ms_new[-1]) >= 5 else ts).strftime('%M:%S.%f')[:-4]}]{text}")(ts.strftime("%f")[:-3]))(parse_ttml_timestamp_func(element.attrib.get("begin")), element.text))
|
|
# re-fetch lyrics if need
|
|
if ("relationships" not in song_metadata or "lyrics" not in song_metadata["relationships"]): song_metadata = (apple_music_api.getsong(AppleMusicClientDownloadSongUtils.getmediaidoflibrarymedia(song_metadata), request_overrides=request_overrides))["data"][0]
|
|
lyrics_ttml = safeextractfromdict(song_metadata, ['relationships', 'lyrics', 'data', 0, 'attributes', 'ttml'], None)
|
|
if not lyrics_ttml: return None
|
|
# refactor lyrics
|
|
lyrics_ttml_et, unsynced_lyrics, synced_lyrics, index = ElementTree.fromstring(lyrics_ttml), [], [], 1
|
|
for div in lyrics_ttml_et.iter("{http://www.w3.org/ns/ttml}div"):
|
|
stanza = []; unsynced_lyrics.append(stanza)
|
|
for p in div.iter("{http://www.w3.org/ns/ttml}p"):
|
|
if p.text is not None: stanza.append(p.text)
|
|
if p.attrib.get("begin"):
|
|
if synced_lyrics_format == SyncedLyricsFormat.LRC: synced_lyrics.append(get_lyrics_line_lrc_func(p))
|
|
if synced_lyrics_format == SyncedLyricsFormat.SRT: synced_lyrics.append(get_lyrics_line_srt_func(index, p))
|
|
if synced_lyrics_format == SyncedLyricsFormat.TTML:
|
|
if not synced_lyrics: synced_lyrics.append(minidom.parseString(lyrics_ttml).toprettyxml())
|
|
continue
|
|
index += 1
|
|
# return
|
|
return Lyrics(synced="\n".join(synced_lyrics + ["\n"]) if synced_lyrics else None, unsynced=("\n\n".join(["\n".join(lyric_group) for lyric_group in unsynced_lyrics]) if unsynced_lyrics else None))
|
|
'''getmediadate'''
|
|
@staticmethod
|
|
def getmediadate(media_id: str, itunes_api: AppleMusicClientItunesApiUtils, request_overrides: dict = None) -> datetime.datetime | None:
|
|
lookup_result = itunes_api.getlookupresult(media_id, request_overrides=request_overrides)
|
|
if not lookup_result["results"]: return None
|
|
release_date = safeextractfromdict(lookup_result, ['results', 0, 'releaseDate'], None)
|
|
if not release_date: return None
|
|
parsed_date = AppleMusicClientDownloadSongUtils.parsedate(release_date)
|
|
return parsed_date
|
|
'''gettags'''
|
|
@staticmethod
|
|
def gettags(webplayback: dict, lyrics: str | None = None, use_album_date: bool = False, itunes_api: AppleMusicClientItunesApiUtils = None, request_overrides: dict = None) -> MediaTags:
|
|
webplayback_metadata = safeextractfromdict(webplayback, ['songList', 0, 'assets', 0, 'metadata'], {})
|
|
tags = MediaTags(
|
|
album=webplayback_metadata["playlistName"], album_artist=webplayback_metadata["playlistArtistName"], album_id=int(webplayback_metadata["playlistId"]), album_sort=webplayback_metadata["sort-album"], disc=webplayback_metadata["discNumber"], track_total=webplayback_metadata["trackCount"],
|
|
artist=webplayback_metadata["artistName"], artist_id=int(webplayback_metadata["artistId"]), artist_sort=webplayback_metadata["sort-artist"], comment=webplayback_metadata.get("comments"), rating=MediaRating(webplayback_metadata["explicit"]), lyrics=lyrics if lyrics else None,
|
|
compilation=webplayback_metadata["compilation"], composer=webplayback_metadata.get("composerName"), composer_id=(int(webplayback_metadata.get("composerId")) if webplayback_metadata.get("composerId") else None), genre=webplayback_metadata.get("genre"), media_type=MediaType.SONG,
|
|
composer_sort=webplayback_metadata.get("sort-composer"), copyright=webplayback_metadata.get("copyright"), disc_total=webplayback_metadata["discCount"], gapless=webplayback_metadata["gapless"], genre_id=int(webplayback_metadata["genreId"]), xid=webplayback_metadata.get("xid"),
|
|
date=(AppleMusicClientDownloadSongUtils.getmediadate(webplayback_metadata["playlistId"], itunes_api, request_overrides) if use_album_date else (AppleMusicClientDownloadSongUtils.parsedate(webplayback_metadata["releaseDate"]) if webplayback_metadata.get("releaseDate") else None)),
|
|
track=webplayback_metadata["trackNumber"], storefront=webplayback_metadata["s"], title=webplayback_metadata["itemName"], title_id=int(webplayback_metadata["itemId"]), title_sort=webplayback_metadata["sort-name"],
|
|
)
|
|
return tags
|
|
'''getextratags'''
|
|
@staticmethod
|
|
def getextratags(song_metadata: dict, request_overrides: dict = None) -> dict:
|
|
request_overrides = request_overrides or {}
|
|
previews = safeextractfromdict(song_metadata, ['attributes', 'previews'], []) or []
|
|
if not previews: return {}
|
|
preview_url = previews[0]["url"]
|
|
preview_response = requests.get(preview_url, **request_overrides)
|
|
preview_bytes = preview_response.content
|
|
preview_tags = dict(MP4(io.BytesIO(preview_bytes)).tags)
|
|
return preview_tags
|
|
'''getplaylisttags'''
|
|
@staticmethod
|
|
def getplaylisttags(playlist_metadata: dict, media_metadata: dict) -> PlaylistTags:
|
|
playlist_track = (safeextractfromdict(playlist_metadata, ['relationships', 'tracks', 'data'], '').index(media_metadata) + 1)
|
|
return PlaylistTags(
|
|
playlist_artist=safeextractfromdict(playlist_metadata, ['attributes', 'curatorName'], 'Unknown'), playlist_id=playlist_metadata["attributes"]["playParams"]["id"],
|
|
playlist_title=playlist_metadata["attributes"]["name"], playlist_track=playlist_track,
|
|
)
|
|
'''getstreaminfolegacy'''
|
|
@staticmethod
|
|
def getstreaminfolegacy(webplayback: dict, codec: SongCodec, request_overrides: dict = None) -> StreamInfoAv:
|
|
request_overrides = request_overrides or {}
|
|
flavor = "32:ctrp64" if codec == SongCodec.AAC_HE_LEGACY else "28:ctrp256"
|
|
stream_info = StreamInfo()
|
|
stream_info.stream_url = next(i for i in webplayback["songList"][0]["assets"] if i["flavor"] == flavor)["URL"]
|
|
m3u8_obj = m3u8.loads(requests.get(stream_info.stream_url, **request_overrides).text)
|
|
stream_info.widevine_pssh = m3u8_obj.keys[0].uri
|
|
stream_info_av = StreamInfoAv(media_id=webplayback["songList"][0]["songId"], audio_track=stream_info, file_format=MediaFileFormat.M4A)
|
|
return stream_info_av
|
|
'''getdecryptionkeylegacy'''
|
|
@staticmethod
|
|
def getdecryptionkeylegacy(stream_info: StreamInfoAv, cdm: Cdm, apple_music_api: AppleMusicClientAPIUtils = None, request_overrides: dict = None) -> DecryptionKeyAv:
|
|
stream_info_audio, request_overrides = stream_info.audio_track, request_overrides or {}
|
|
try:
|
|
cdm_session = cdm.open(); widevine_pssh_data = WidevinePsshData()
|
|
widevine_pssh_data.algorithm = 1
|
|
widevine_pssh_data.key_ids.append(base64.b64decode(stream_info_audio.widevine_pssh.split(",")[1]))
|
|
pssh_obj = PSSH(widevine_pssh_data.SerializeToString())
|
|
challenge = base64.b64encode(cdm.get_license_challenge(cdm_session, pssh_obj)).decode()
|
|
license_resp = apple_music_api.getlicenseexchange(stream_info.media_id, stream_info.audio_track.widevine_pssh, challenge, request_overrides=request_overrides)
|
|
cdm.parse_license(cdm_session, license_resp["license"])
|
|
decryption_key = next(i for i in cdm.get_keys(cdm_session) if i.type == "CONTENT")
|
|
finally:
|
|
cdm.close(cdm_session)
|
|
decryption_key = DecryptionKeyAv(audio_track=DecryptionKey(kid=decryption_key.kid.hex, key=decryption_key.key.hex()))
|
|
return decryption_key
|
|
'''getdecryptionkey'''
|
|
@staticmethod
|
|
def getdecryptionkey(stream_info: StreamInfoAv, cdm: Cdm, apple_music_api: AppleMusicClientAPIUtils, request_overrides: dict = None) -> DecryptionKeyAv:
|
|
track_uri, track_id = stream_info.audio_track.widevine_pssh, stream_info.media_id
|
|
try:
|
|
cdm_session = cdm.open(); pssh_obj = PSSH(track_uri.split(",")[-1])
|
|
challenge = base64.b64encode(cdm.get_license_challenge(cdm_session, pssh_obj)).decode()
|
|
license = apple_music_api.getlicenseexchange(track_id, track_uri, challenge, request_overrides=request_overrides)
|
|
cdm.parse_license(cdm_session, license["license"])
|
|
decryption_key_info = next(i for i in cdm.get_keys(cdm_session) if i.type == "CONTENT")
|
|
finally:
|
|
cdm.close(cdm_session)
|
|
decryption_key = DecryptionKey(key=decryption_key_info.key.hex(), kid=decryption_key_info.kid.hex)
|
|
return DecryptionKeyAv(audio_track=decryption_key)
|
|
'''getplaylistfromcodec'''
|
|
@staticmethod
|
|
def getplaylistfromcodec(m3u8_data: dict, codec: SongCodec) -> dict | None:
|
|
matching_playlists = [playlist for playlist in m3u8_data["playlists"] if re.fullmatch(SONG_CODEC_REGEX_MAP[codec.value], playlist["stream_info"]["audio"])]
|
|
if not matching_playlists: return None
|
|
return max(matching_playlists, key=lambda x: x["stream_info"]["average_bandwidth"])
|
|
'''getm3u8metadata'''
|
|
@staticmethod
|
|
def getm3u8metadata(m3u8_data: dict, data_id: str):
|
|
for session_data in m3u8_data.get("session_data", []):
|
|
if session_data["data_id"] == data_id:
|
|
return json.loads(base64.b64decode(session_data["value"]).decode("utf-8"))
|
|
return None
|
|
'''getaudiosessionkeymetadata'''
|
|
@staticmethod
|
|
def getaudiosessionkeymetadata(m3u8_data: dict):
|
|
return AppleMusicClientDownloadSongUtils.getm3u8metadata(m3u8_data, "com.apple.hls.AudioSessionKeyInfo")
|
|
'''getassetmetadata'''
|
|
@staticmethod
|
|
def getassetmetadata(m3u8_data: dict):
|
|
return AppleMusicClientDownloadSongUtils.getm3u8metadata(m3u8_data, "com.apple.hls.audioAssetMetadata")
|
|
'''getdrmurifromsessionkey'''
|
|
@staticmethod
|
|
def getdrmurifromsessionkey(drm_infos: dict, drm_ids: list, drm_key: str) -> str | None:
|
|
for drm_id in drm_ids:
|
|
if drm_id != "1" and drm_key in drm_infos.get(drm_id, {}):
|
|
return drm_infos[drm_id][drm_key]["URI"]
|
|
return None
|
|
'''getdrmurifromm3u8keys'''
|
|
@staticmethod
|
|
def getdrmurifromm3u8keys(m3u8_obj: m3u8.M3U8, drm_key: str) -> str | None:
|
|
default_uri = DRM_DEFAULT_KEY_MAPPING[drm_key]
|
|
for key in m3u8_obj.keys:
|
|
if key.keyformat == drm_key and key.uri != default_uri: return key.uri
|
|
return None
|
|
'''getstreaminfo'''
|
|
@staticmethod
|
|
def getstreaminfo(song_metadata: dict, codec: SongCodec, request_overrides: dict = None) -> StreamInfoAv | None:
|
|
request_overrides = request_overrides or {}
|
|
m3u8_master_url: str = safeextractfromdict(song_metadata, ['attributes', 'extendedAssetUrls', 'enhancedHls'], None)
|
|
if not m3u8_master_url: return None
|
|
m3u8_master_obj = m3u8.loads(requests.get(m3u8_master_url, **request_overrides).text)
|
|
m3u8_master_data = m3u8_master_obj.data
|
|
playlist = AppleMusicClientDownloadSongUtils.getplaylistfromcodec(m3u8_master_data, codec)
|
|
if playlist is None: return None
|
|
stream_info = StreamInfo()
|
|
stream_info.stream_url = (f"{m3u8_master_url.rpartition('/')[0]}/{playlist['uri']}")
|
|
stream_info.codec = playlist["stream_info"]["codecs"]
|
|
is_mp4 = any(stream_info.codec.startswith(codec) for codec in MP4_FORMAT_CODECS)
|
|
session_key_metadata = AppleMusicClientDownloadSongUtils.getaudiosessionkeymetadata(m3u8_master_data)
|
|
if session_key_metadata:
|
|
asset_metadata = AppleMusicClientDownloadSongUtils.getassetmetadata(m3u8_master_data)
|
|
drm_ids = asset_metadata[playlist["stream_info"]["stable_variant_id"]]["AUDIO-SESSION-KEY-IDS"]
|
|
stream_info.widevine_pssh = AppleMusicClientDownloadSongUtils.getdrmurifromsessionkey(session_key_metadata, drm_ids, "urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed")
|
|
stream_info.playready_pssh = AppleMusicClientDownloadSongUtils.getdrmurifromsessionkey(session_key_metadata, drm_ids, "com.microsoft.playready")
|
|
stream_info.fairplay_key = AppleMusicClientDownloadSongUtils.getdrmurifromsessionkey(session_key_metadata, drm_ids, "com.apple.streamingkeydelivery")
|
|
else:
|
|
m3u8_obj = m3u8.loads(requests.get(stream_info.stream_url, **request_overrides).text)
|
|
stream_info.widevine_pssh = AppleMusicClientDownloadSongUtils.getdrmurifromm3u8keys(m3u8_obj, "urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed")
|
|
stream_info.playready_pssh = AppleMusicClientDownloadSongUtils.getdrmurifromm3u8keys(m3u8_obj, "com.microsoft.playready")
|
|
stream_info.fairplay_key = AppleMusicClientDownloadSongUtils.getdrmurifromm3u8keys(m3u8_obj, "com.apple.streamingkeydelivery")
|
|
stream_info_av = StreamInfoAv(audio_track=stream_info, file_format=MediaFileFormat.MP4 if is_mp4 else MediaFileFormat.M4A)
|
|
return stream_info_av
|
|
'''getrawcoverurl'''
|
|
@staticmethod
|
|
def getrawcoverurl(cover_url_template: str) -> str:
|
|
return re.sub(r"image/thumb/", "", re.sub(r"is1-ssl", "a1", cover_url_template))
|
|
'''getcoverurltemplate'''
|
|
@staticmethod
|
|
def getcoverurltemplate(metadata: dict, cover_format: CoverFormat) -> str:
|
|
if cover_format == CoverFormat.RAW:
|
|
cover_url_template = AppleMusicClientDownloadSongUtils.getrawcoverurl(metadata["attributes"]["artwork"]["url"])
|
|
cover_url_template = metadata["attributes"]["artwork"]["url"]
|
|
return cover_url_template
|
|
'''getcoverurl'''
|
|
@staticmethod
|
|
def getcoverurl(cover_url_template: str, cover_size: int, cover_format: CoverFormat) -> str:
|
|
cover_url = re.sub(r"\{w\}x\{h\}([a-z]{2})\.jpg", (f"{cover_size}x{cover_size}bb.{cover_format.value}" if cover_format != CoverFormat.RAW else ""), cover_url_template)
|
|
return cover_url
|
|
'''getdownloaditem'''
|
|
@staticmethod
|
|
def getdownloaditem(song_metadata: dict, playlist_metadata: dict, synced_lyrics_format: SyncedLyricsFormat = SyncedLyricsFormat.LRC, codec: SongCodec = SongCodec.AAC_LEGACY, apple_music_api: AppleMusicClientAPIUtils = None, itunes_api: AppleMusicClientItunesApiUtils = None, use_album_date: bool = False, fetch_extra_tags: bool = False, use_wrapper: bool = False, cover_format: CoverFormat = CoverFormat.JPG, cover_size: int = 1200, request_overrides: dict = None):
|
|
# init
|
|
request_overrides = request_overrides or {}
|
|
download_item = DownloadItem()
|
|
download_item.media_metadata, download_item.playlist_metadata = song_metadata, playlist_metadata
|
|
# lyrics
|
|
song_id = AppleMusicClientDownloadSongUtils.getmediaidoflibrarymedia(song_metadata)
|
|
download_item.lyrics = AppleMusicClientDownloadSongUtils.getlyrics(song_metadata, synced_lyrics_format=synced_lyrics_format, apple_music_api=apple_music_api, request_overrides=request_overrides)
|
|
# get media tags
|
|
webplayback = apple_music_api.getwebplayback(song_id, request_overrides=request_overrides)
|
|
download_item.media_tags = AppleMusicClientDownloadSongUtils.gettags(webplayback, download_item.lyrics.unsynced if download_item.lyrics else None, use_album_date, itunes_api, request_overrides)
|
|
if fetch_extra_tags: download_item.extra_tags = AppleMusicClientDownloadSongUtils.getextratags(song_metadata, request_overrides)
|
|
if playlist_metadata: download_item.playlist_tags = AppleMusicClientDownloadSongUtils.getplaylisttags(playlist_metadata, song_metadata)
|
|
# None for all paths as default value, auto set after searching
|
|
download_item.final_path = None; download_item.synced_lyrics_path = None; download_item.staged_path = None; download_item.playlist_file_path = None
|
|
# stream info and decryption key
|
|
if codec.islegacy():
|
|
download_item.stream_info = AppleMusicClientDownloadSongUtils.getstreaminfolegacy(webplayback, codec, request_overrides)
|
|
download_item.decryption_key = AppleMusicClientDownloadSongUtils.getdecryptionkeylegacy(download_item.stream_info, AppleMusicClientDownloadSongUtils.cdm, apple_music_api=apple_music_api, request_overrides=request_overrides)
|
|
else:
|
|
download_item.stream_info = AppleMusicClientDownloadSongUtils.getstreaminfo(song_metadata, codec, request_overrides=request_overrides)
|
|
if (not use_wrapper and download_item.stream_info and download_item.stream_info.audio_track.widevine_pssh):
|
|
download_item.decryption_key = AppleMusicClientDownloadSongUtils.getdecryptionkey(download_item.stream_info, AppleMusicClientDownloadSongUtils.cdm, apple_music_api=apple_music_api, request_overrides=request_overrides)
|
|
else:
|
|
download_item.decryption_key = None
|
|
# cover url
|
|
download_item.cover_url_template = AppleMusicClientDownloadSongUtils.getcoverurltemplate(song_metadata, cover_format)
|
|
download_item.cover_url = AppleMusicClientDownloadSongUtils.getcoverurl(download_item.cover_url_template, cover_size, cover_format)
|
|
# uuid for tmp results saving
|
|
download_item.random_uuid = AppleMusicClientDownloadSongUtils.getrandomuuid4()
|
|
# return
|
|
return download_item
|
|
'''remuxmp4box'''
|
|
@staticmethod
|
|
def remuxmp4box(input_path: str, output_path: str, silent: bool = False, artist: str = ''):
|
|
cmd = ["MP4Box", "-quiet", "-add", input_path, "-itags", f"artist={artist}", "-keep-utc", "-new", output_path]
|
|
capture_output = True if silent else False
|
|
ret = subprocess.run(cmd, check=True, capture_output=capture_output, text=True, encoding='utf-8', errors='ignore')
|
|
return (ret.returncode == 0)
|
|
'''remuxffmpeg'''
|
|
@staticmethod
|
|
def remuxffmpeg(input_path: str, output_path: str, decryption_key: str = None, silent: bool = False):
|
|
key = ["-decryption_key", decryption_key] if decryption_key else []
|
|
cmd = ['ffmpeg', "-loglevel", "error", "-y", *key, "-i", input_path, "-c", "copy", "-movflags", "+faststart", output_path]
|
|
capture_output = True if silent else False
|
|
ret = subprocess.run(cmd, check=True, capture_output=capture_output, text=True, encoding='utf-8', errors='ignore')
|
|
return (ret.returncode == 0)
|
|
'''decryptmp4decrypt'''
|
|
@staticmethod
|
|
def decryptmp4decrypt(input_path: str, output_path: str, decryption_key: str, legacy: bool, silent: bool = False):
|
|
if legacy: keys = ["--key", f"1:{decryption_key}"]
|
|
else: AppleMusicClientDownloadSongUtils.fixkeyid(input_path); keys = ["--key", "0" * 31 + "1" + f":{decryption_key}", "--key", "0" * 32 + f":{DEFAULT_SONG_DECRYPTION_KEY}"]
|
|
cmd = ["mp4decrypt", *keys, input_path, output_path]
|
|
capture_output = True if silent else False
|
|
ret = subprocess.run(cmd, check=True, capture_output=capture_output, text=True, encoding='utf-8', errors='ignore')
|
|
return (ret.returncode == 0)
|
|
'''decryptamdecrypt'''
|
|
@staticmethod
|
|
def decryptamdecrypt(input_path: str, output_path: str, media_id: str, fairplay_key: str, wrapper_decrypt_ip: str = "127.0.0.1:10020", silent: bool = False):
|
|
cmd = ['amdecrypt', wrapper_decrypt_ip, shutil.which('mp4decrypt'), media_id, fairplay_key, input_path, output_path]
|
|
capture_output = True if silent else False
|
|
ret = subprocess.run(cmd, check=True, capture_output=capture_output, text=True, encoding='utf-8', errors='ignore')
|
|
return (ret.returncode == 0)
|
|
'''stage'''
|
|
@staticmethod
|
|
def stage(encrypted_path: str, decrypted_path: str, staged_path: str, decryption_key: DecryptionKeyAv, codec: SongCodec, media_id: str, fairplay_key: str, remux_mode: RemuxMode = RemuxMode.MP4BOX, silent: bool = False, wrapper_decrypt_ip: str = "127.0.0.1:10020", artist: str = "", use_wrapper: bool = False):
|
|
if codec.islegacy() and remux_mode == RemuxMode.FFMPEG:
|
|
AppleMusicClientDownloadSongUtils.remuxffmpeg(encrypted_path, staged_path, decryption_key.audio_track.key, silent=silent)
|
|
elif codec.islegacy() or not use_wrapper:
|
|
AppleMusicClientDownloadSongUtils.decryptmp4decrypt(encrypted_path, decrypted_path, decryption_key.audio_track.key, codec.islegacy(), silent)
|
|
if remux_mode == RemuxMode.FFMPEG: AppleMusicClientDownloadSongUtils.remuxffmpeg(decrypted_path, staged_path, silent=silent)
|
|
else: AppleMusicClientDownloadSongUtils.remuxmp4box(decrypted_path, staged_path, silent=silent, artist=artist)
|
|
else:
|
|
AppleMusicClientDownloadSongUtils.decryptamdecrypt(encrypted_path, staged_path, media_id, fairplay_key, wrapper_decrypt_ip=wrapper_decrypt_ip, silent=silent)
|
|
'''downloadstreamwithnm3u8dlre'''
|
|
@staticmethod
|
|
def downloadstreamwithnm3u8dlre(stream_url: str, download_path: str, silent: bool = False, random_uuid: str = ''):
|
|
download_path_obj = Path(download_path)
|
|
download_path_obj.parent.mkdir(parents=True, exist_ok=True)
|
|
log_file_path = os.path.join(user_log_dir(appname='musicdl', appauthor='zcjin'), f"musicdl_{random_uuid}.log")
|
|
cmd = [
|
|
"N_m3u8DL-RE", stream_url, "--binary-merge", "--ffmpeg-binary-path", shutil.which('ffmpeg'), "--save-name", download_path_obj.stem,
|
|
"--save-dir", download_path_obj.parent, "--tmp-dir", download_path_obj.parent, "--log-file-path", log_file_path,
|
|
]
|
|
capture_output = True if silent else False
|
|
ret = subprocess.run(cmd, check=True, capture_output=capture_output, text=True, encoding='utf-8', errors='ignore')
|
|
return (ret.returncode == 0)
|
|
'''download'''
|
|
@staticmethod
|
|
def download(download_item: DownloadItem, work_dir: str = './', silent: bool = False, codec: SongCodec = SongCodec.AAC_LEGACY, wrapper_decrypt_ip: str = "127.0.0.1:10020", remux_mode: RemuxMode = RemuxMode.MP4BOX, artist: str = "", use_wrapper: bool = False):
|
|
ext = download_item.stream_info.file_format.value
|
|
encrypted_path = os.path.join(work_dir, f"{download_item.random_uuid}_encrypted.m4a")
|
|
is_success = AppleMusicClientDownloadSongUtils.downloadstreamwithnm3u8dlre(download_item.stream_info.audio_track.stream_url, encrypted_path, silent=silent, random_uuid=download_item.random_uuid)
|
|
decrypted_path = os.path.join(work_dir, f"{download_item.random_uuid}_decrypted.m4a")
|
|
download_item.staged_path = os.path.join(work_dir, f"{download_item.random_uuid}_staged.{ext}")
|
|
is_success = AppleMusicClientDownloadSongUtils.stage(
|
|
encrypted_path=encrypted_path, decrypted_path=decrypted_path, staged_path=download_item.staged_path, decryption_key=download_item.decryption_key,
|
|
codec=codec, media_id=download_item.media_metadata["id"], fairplay_key=download_item.stream_info.audio_track.fairplay_key, remux_mode=remux_mode,
|
|
silent=silent, wrapper_decrypt_ip=wrapper_decrypt_ip, artist=artist, use_wrapper=use_wrapper,
|
|
)
|
|
return is_success
|