Skip to content

Commit 2d0bc19

Browse files
Merge pull request #101 from uright008/feature/quality
Feature: add quality command and retrytime & Refactor: move batch download into a separate function
2 parents c7d9b74 + e49da73 commit 2d0bc19

9 files changed

Lines changed: 177 additions & 42 deletions

File tree

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,14 @@ https://music.apple.com/jp/playlist/bocchi-the-rock/pl.u-Ympg5s39LRqp
2929
https://music.apple.com/jp/album/nameless-name-single/1688539265
3030
# Download multiple songs in one line
3131
dl https://music.apple.com/jp/playlist/bocchi-the-rock/pl.u-Ympg5s39LRqp https://music.apple.com/jp/album/nameless-name-single/1688539265
32+
# Check the available quality of the song
33+
quality https://music.apple.com/jp/playlist/bocchi-the-rock/pl.u-Ympg5s39LRqp
34+
# Or a shorter command
35+
qa https://music.apple.com/jp/playlist/bocchi-the-rock/pl.u-Ympg5s39LRqp
36+
# You can hide a column by enabling it in the options.
37+
qa --codec-id https://music.apple.com/jp/playlist/bocchi-the-rock/pl.u-Ympg5s39LRqp
38+
# When you add --invert, it works the opposite way, showing the column for whichever option you enable.
39+
qa --invert --codec-id https://music.apple.com/jp/playlist/bocchi-the-rock/pl.u-Ympg5s39LRqp
3240
```
3341

3442
# Support Codec

config.example.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# DO NOT EDIT IT
2-
version = "0.0.9"
2+
version = "0.0.10"
33

44
[instance]
55
url = "127.0.0.1:8080"
@@ -106,6 +106,8 @@ maxBitDepth = 24
106106
# Example: "\"C:\\Program Files\\DAUM\\PotPlayer\\PotPlayerMini64.exe\" \"{filename}\""
107107
# Pay attention to escaping issues
108108
afterDownloaded = ""
109+
retryTime = 8
110+
maxWaitTime = 30
109111

110112
[metadata]
111113
# Metadata to be written to the song

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ creart = "^0.3.0"
2727
mutagen = "^1.47.0"
2828
hishel = "^0.1.5"
2929
async-lru = "^2.0.5"
30+
tabulate = "^0.9.0"
3031

3132
[build-system]
3233
requires = ["poetry-core"]

src/api.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,8 @@ def __init__(self, proxy: str, parallel_num: int):
5959
self.request_lock = asyncio.Semaphore(256)
6060

6161
@retry(retry=retry_if_exception_type((httpx.HTTPError, SSLError, FileNotFoundError)),
62-
wait=wait_random_exponential(multiplier=1, max=60),
63-
stop=stop_after_attempt(32))
62+
wait=wait_random_exponential(multiplier=1, max=it(Config).download.maxWaitTime),
63+
stop=stop_after_attempt(it(Config).download.retryTime))
6464
def _set_token(self):
6565
with httpx.Client() as client:
6666
resp = client.get("https://music.apple.com", follow_redirects=True)
@@ -73,15 +73,15 @@ def init(self):
7373
pass
7474

7575
@retry(retry=retry_if_exception_type((httpx.HTTPError, SSLError, FileNotFoundError)),
76-
wait=wait_random_exponential(multiplier=1, max=60),
77-
stop=stop_after_attempt(32), before_sleep=before_sleep_log(it(GlobalLogger).logger, "WARNING"))
76+
wait=wait_random_exponential(multiplier=1, max=it(Config).download.maxWaitTime),
77+
stop=stop_after_attempt(it(Config).download.retryTime), before_sleep=before_sleep_log(it(GlobalLogger).logger, "WARNING"))
7878
async def _request(self, *args, **kwargs):
7979
async with self.request_lock:
8080
return await self.client.request(*args, **kwargs)
8181

8282
@retry(retry=retry_if_exception_type((httpx.HTTPError, SSLError, FileNotFoundError)),
83-
wait=wait_random_exponential(multiplier=1, max=60),
84-
stop=stop_after_attempt(32), before_sleep=before_sleep_log(it(GlobalLogger).logger, "WARNING"))
83+
wait=wait_random_exponential(multiplier=1, max=it(Config).download.maxWaitTime),
84+
stop=stop_after_attempt(it(Config).download.retryTime), before_sleep=before_sleep_log(it(GlobalLogger).logger, "WARNING"))
8585
async def download_song(self, url: str) -> bytes:
8686
async with self.download_lock:
8787
result = BytesIO()

src/cmd.py

Lines changed: 90 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import argparse
22
import asyncio
3+
import copy
34
import sys
45
import os
56

@@ -19,6 +20,7 @@
1920
from src.rip import on_decrypt_success, on_decrypt_failed, rip_song, rip_album, rip_artist, rip_playlist
2021
from src.url import AppleMusicURL, URLType
2122
from src.utils import check_dep, run_sync, safely_create_task, config_outdated
23+
from src.quality import print_song_quality, print_album_quality, print_playlist_quality, key_to_Headers
2224

2325

2426
class InteractiveShell:
@@ -60,7 +62,8 @@ def __init__(self, loop: asyncio.AbstractEventLoop):
6062
self.parser = argparse.ArgumentParser(exit_on_error=False)
6163
subparser = self.parser.add_subparsers()
6264
download_parser = subparser.add_parser("download", aliases=["dl"])
63-
download_parser.add_argument("url", nargs='*' ,type=str)
65+
quality_parser = subparser.add_parser("quality", aliases=["qa"])
66+
download_parser.add_argument("url", nargs='*', type=str)
6467
download_parser.add_argument("-c", "--codec",
6568
choices=["alac", "ec3", "aac", "aac-binaural", "aac-downmix", "aac-legacy", "ac3"],
6669
default="alac")
@@ -69,12 +72,23 @@ def __init__(self, loop: asyncio.AbstractEventLoop):
6972
download_parser.add_argument("-l", "--language", default=it(Config).region.language, action="store")
7073
download_parser.add_argument("--include-participate-songs", default=False, dest="include", action="store_true")
7174

75+
quality_parser.add_argument("url", nargs='*', type=str)
76+
quality_parser.add_argument("-i","--invert", default=False, action="store_true")
77+
quality_parser.add_argument("--codec-id", default=True, action="store_false")
78+
quality_parser.add_argument("--codec", default=True, action="store_false")
79+
quality_parser.add_argument("--bitrate", default=True, action="store_false")
80+
quality_parser.add_argument("--average-bitrate", default=True, action="store_false")
81+
quality_parser.add_argument("--channels", default=True, action="store_false")
82+
quality_parser.add_argument("--sample-rate", default=True, action="store_false")
83+
quality_parser.add_argument("--bit-depth", default=True, action="store_false")
84+
quality_parser.add_argument("-b", "--batch", default=False, action="store_true")
85+
7286
subparser.add_parser("status")
7387
subparser.add_parser("login")
7488
subparser.add_parser("logout")
7589
subparser.add_parser("exit")
7690

77-
self.batch_download_mode = False
91+
self.batch_mode = False
7892

7993
async def show_status(self):
8094
it(WrapperManager).status.cache_invalidate()
@@ -83,36 +97,50 @@ async def show_status(self):
8397
it(GlobalLogger).logger.error("The currently used wrapper-manager instance has no available account. Please execute login command to log in.")
8498
it(GlobalLogger).logger.info(f"Regions available on wrapper-manager instance: {', '.join(st_resp.regions)}")
8599

100+
async def handle_batch_mode(self, args, cmds):
101+
try:
102+
if args.batch:
103+
self.batch_mode = True
104+
self.batch_args = args
105+
self.batch_command = cmds[0]
106+
it(GlobalLogger).logger.info("Entering batch mode. Enter one or more URLs per line (space-separated), type 'exit' to quit")
107+
except:
108+
pass
109+
110+
async def batch_mode_parser(self, cmds: str):
111+
args=self.batch_args
112+
args.url=copy.deepcopy(cmds)
113+
if cmds[0]!="exit":
114+
cmds[0] = self.batch_command
115+
return cmds,args
116+
86117
async def command_parser(self, cmd: str):
87118
if not cmd.strip():
88119
return
89120
cmds = cmd.split(" ")
90-
if self.batch_download_mode:
91-
if cmds[0]=="exit":
92-
self.batch_download_mode=False
93-
it(GlobalLogger).logger.info("Batch mode exited. Returning to normal command mode.")
121+
if self.batch_mode:
122+
cmds, args = await self.batch_mode_parser(cmds)
123+
else:
124+
try:
125+
args = self.parser.parse_args(cmds)
126+
except (argparse.ArgumentError, argparse.ArgumentTypeError, SystemExit):
127+
it(GlobalLogger).logger.warning(f"Unknown command: {cmd}")
94128
return
95-
args=self.batch_download_args
96-
await self.do_download(cmds, args.codec, args.force, args.language, args.include)
97-
return
98-
try:
99-
args = self.parser.parse_args(cmds)
100-
except (argparse.ArgumentError, argparse.ArgumentTypeError, SystemExit):
101-
it(GlobalLogger).logger.warning(f"Unknown command: {cmd}")
102-
return
129+
await self.handle_batch_mode(args, cmds)
103130
match cmds[0]:
104131
case "download" | "dl":
105-
if args.batch:
106-
self.batch_download_mode = True
107-
self.batch_download_args = args
108-
it(GlobalLogger).logger.info("Entering batch mode. Enter one or more URLs per line (space-separated), type 'exit' to quit")
109-
return
110-
111-
await self.do_download(args.url, args.codec, args.force, args.language, args.include)
132+
safely_create_task(self.do_download(args.url, args.codec, args.force, args.language, args.include))
112133
case "status":
113134
await self.show_status()
114135
case "exit":
115-
self.handle_exit()
136+
if self.batch_mode:
137+
self.batch_mode=False
138+
it(GlobalLogger).logger.info("Batch mode exited. Returning to normal command mode.")
139+
else:
140+
self.handle_exit()
141+
case "quality" | "qa":
142+
safely_create_task(self.do_quality(args.url, args))
143+
116144

117145
async def do_download(self, raw_urls: list[str], codec: str, force_download: bool, language: str, include: bool = False):
118146
for raw_url in raw_urls:
@@ -133,6 +161,34 @@ async def do_download(self, raw_urls: list[str], codec: str, force_download: boo
133161
include_participate_in_works=include)))
134162
case URLType.Playlist:
135163
safely_create_task(rip_playlist(url, codec, Flags(force_save=force_download, language=language)))
164+
case _:
165+
it(GlobalLogger).logger.error(f"Unsupported URLType - {raw_url}")
166+
continue
167+
168+
async def do_quality(self, raw_urls: list[str], args):
169+
all_fields = list(key_to_Headers.keys())
170+
show_fields = []
171+
for field in all_fields:
172+
if args.invert:
173+
show_fields = [f for f in key_to_Headers if not getattr(args, f)]
174+
else:
175+
show_fields = [f for f in key_to_Headers if getattr(args, f)]
176+
177+
for raw_url in raw_urls:
178+
url = AppleMusicURL.parse_url(raw_url)
179+
if not url:
180+
real_url = await it(WebAPI).get_real_url(raw_url)
181+
url = AppleMusicURL.parse_url(real_url)
182+
if not url:
183+
it(GlobalLogger).logger.error(f"Illegal URL! - {raw_url}")
184+
continue
185+
match url.type:
186+
case URLType.Song:
187+
safely_create_task(print_song_quality(url, show_fields))
188+
case URLType.Album:
189+
safely_create_task(print_album_quality(url, show_fields))
190+
case URLType.Playlist:
191+
safely_create_task(print_playlist_quality(url, show_fields))
136192
case _:
137193
it(GlobalLogger).logger.error(f"Unsupported URLType - {raw_url}")
138194
continue
@@ -143,6 +199,7 @@ def bottom_toolbar(self):
143199
def completer(self):
144200
mycompleter = {
145201
"dl": {
202+
"--batch": None,
146203
"--codec": {
147204
"ec3": None,
148205
"aac": None,
@@ -164,6 +221,17 @@ def completer(self):
164221
},
165222
"--include-participate-songs": None
166223
},
224+
"qa": {
225+
"--invert": None,
226+
"--codec-id": None,
227+
"--codec": None,
228+
"--bitrate": None,
229+
"--average-bitrate": None,
230+
"--channels": None,
231+
"--sample-rate": None,
232+
"--bit-depth": None,
233+
"--batch": None
234+
},
167235
"status": None,
168236
"login": None,
169237
"logout": None,

src/config.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from creart.creator import AbstractCreator, CreateTargetInfo
66
from pydantic import BaseModel
77

8-
CONFIG_VERSION = "0.0.9"
8+
CONFIG_VERSION = "0.0.10"
99

1010

1111
class Instance(BaseModel):
@@ -51,6 +51,8 @@ class Download(BaseModel):
5151
maxSampleRate: int = 192000
5252
maxBitDepth: int = 24
5353
afterDownloaded: str = ""
54+
retryTime: int = 8
55+
maxWaitTime: int = 30
5456

5557

5658
class Metadata(BaseModel):

src/grpc/manager.py

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from src.grpc.manager_pb2 import *
1414
from src.grpc.manager_pb2_grpc import WrapperManagerServiceStub, google_dot_protobuf_dot_empty__pb2
1515
from src.logger import GlobalLogger
16+
from src.config import Config
1617
from src.utils import safely_create_task
1718

1819

@@ -129,8 +130,8 @@ async def _decrypt_keepalive(self):
129130

130131
@retry(retry=((retry_if_exception_type(WrapperManagerException)) & (
131132
retry_if_not_exception_message('no available instance'))),
132-
wait=wait_random_exponential(multiplier=1, max=60),
133-
stop=stop_after_attempt(32), before_sleep=before_sleep_log(it(GlobalLogger).logger, "WARNING"))
133+
wait=wait_random_exponential(multiplier=1, max=it(Config).download.maxWaitTime),
134+
stop=stop_after_attempt(it(Config).download.retryTime), before_sleep=before_sleep_log(it(GlobalLogger).logger, "WARNING"))
134135
async def m3u8(self, adam_id: str) -> str:
135136
resp: M3U8Reply = await self._stub.M3U8(M3U8Request(data=M3U8DataRequest(adam_id=adam_id)))
136137
if resp.header.code != 0:
@@ -139,8 +140,8 @@ async def m3u8(self, adam_id: str) -> str:
139140

140141
@retry(retry=((retry_if_exception_type(WrapperManagerException)) & (
141142
retry_if_not_exception_message('no such account'))),
142-
wait=wait_random_exponential(multiplier=1, max=60),
143-
stop=stop_after_attempt(32), before_sleep=before_sleep_log(it(GlobalLogger).logger, "WARNING"))
143+
wait=wait_random_exponential(multiplier=1, max=it(Config).download.maxWaitTime),
144+
stop=stop_after_attempt(it(Config).download.retryTime), before_sleep=before_sleep_log(it(GlobalLogger).logger, "WARNING"))
144145
async def logout(self, username: str):
145146
resp: LogoutReply = await self._stub.Logout(LogoutRequest(data=LogoutData(username=username)))
146147
if resp.header.code != 0:
@@ -149,8 +150,8 @@ async def logout(self, username: str):
149150

150151
@retry(retry=((retry_if_exception_type(WrapperManagerException)) & (
151152
retry_if_not_exception_message('no available instance'))),
152-
wait=wait_random_exponential(multiplier=1, max=60),
153-
stop=stop_after_attempt(32), before_sleep=before_sleep_log(it(GlobalLogger).logger, "WARNING"))
153+
wait=wait_random_exponential(multiplier=1, max=it(Config).download.maxWaitTime),
154+
stop=stop_after_attempt(it(Config).download.retryTime), before_sleep=before_sleep_log(it(GlobalLogger).logger, "WARNING"))
154155
async def lyrics(self, adam_id: str, language: str, region: str) -> str:
155156
resp: LyricsReply = await self._stub.Lyrics(LyricsRequest(
156157
data=LyricsDataRequest(adam_id=adam_id, language=language, region=region)))
@@ -160,8 +161,8 @@ async def lyrics(self, adam_id: str, language: str, region: str) -> str:
160161

161162
@retry(retry=((retry_if_exception_type(WrapperManagerException)) & (
162163
retry_if_not_exception_message('no available instance'))),
163-
wait=wait_random_exponential(multiplier=1, max=60),
164-
stop=stop_after_attempt(32), before_sleep=before_sleep_log(it(GlobalLogger).logger, "WARNING"))
164+
wait=wait_random_exponential(multiplier=1, max=it(Config).download.maxWaitTime),
165+
stop=stop_after_attempt(it(Config).download.retryTime), before_sleep=before_sleep_log(it(GlobalLogger).logger, "WARNING"))
165166
async def webPlayback(self, adam_id: str) -> str:
166167
resp: WebPlaybackReply = await self._stub.WebPlayback(WebPlaybackRequest(
167168
data=WebPlaybackDataRequest(adam_id=adam_id)
@@ -172,8 +173,8 @@ async def webPlayback(self, adam_id: str) -> str:
172173

173174
@retry(retry=((retry_if_exception_type(WrapperManagerException)) & (
174175
retry_if_not_exception_message('no available instance'))),
175-
wait=wait_random_exponential(multiplier=1, max=60),
176-
stop=stop_after_attempt(32), before_sleep=before_sleep_log(it(GlobalLogger).logger, "WARNING"))
176+
wait=wait_random_exponential(multiplier=1, max=it(Config).download.maxWaitTime),
177+
stop=stop_after_attempt(it(Config).download.retryTime), before_sleep=before_sleep_log(it(GlobalLogger).logger, "WARNING"))
177178
async def license(self, adam_id: str, challenge: str, kid: str) -> str:
178179
resp: LicenseReply = await self._stub.License(LicenseRequest(
179180
data=LicenseDataRequest(adam_id=adam_id, challenge=challenge, uri=kid)

src/qemu.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ class QGAClient:
4141
writer: asyncio.StreamWriter
4242

4343
@retry(retry=retry_if_exception_type((asyncio.TimeoutError)),
44-
wait=wait_random_exponential(multiplier=1, max=60),
44+
wait=wait_random_exponential(multiplier=1, max=it(Config).download.maxWaitTime),
4545
stop=stop_after_attempt(8), before_sleep=before_sleep_log(it(GlobalLogger).logger, "WARNING"))
4646
async def init(self):
4747
self.reader, self.writer = await asyncio.open_connection("127.0.0.1", 32766)

0 commit comments

Comments
 (0)