pylrclib-cli 0.4.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pylrclib/__init__.py +4 -0
- pylrclib/__main__.py +3 -0
- pylrclib/api/__init__.py +3 -0
- pylrclib/api/client.py +93 -0
- pylrclib/api/http.py +64 -0
- pylrclib/api/pow.py +17 -0
- pylrclib/api/publish.py +92 -0
- pylrclib/api/retry.py +28 -0
- pylrclib/cli/__init__.py +0 -0
- pylrclib/cli/main.py +52 -0
- pylrclib/commands/__init__.py +0 -0
- pylrclib/commands/cleanse.py +56 -0
- pylrclib/commands/doctor.py +91 -0
- pylrclib/commands/down.py +107 -0
- pylrclib/commands/inspect.py +65 -0
- pylrclib/commands/search.py +67 -0
- pylrclib/commands/up.py +143 -0
- pylrclib/config.py +124 -0
- pylrclib/discovery.py +33 -0
- pylrclib/exceptions.py +10 -0
- pylrclib/fs/__init__.py +4 -0
- pylrclib/fs/cleaner.py +17 -0
- pylrclib/fs/mover.py +32 -0
- pylrclib/i18n.py +28 -0
- pylrclib/interaction.py +73 -0
- pylrclib/logging_utils.py +19 -0
- pylrclib/lrc/__init__.py +29 -0
- pylrclib/lrc/matcher.py +114 -0
- pylrclib/lrc/parser.py +175 -0
- pylrclib/lyrics/__init__.py +18 -0
- pylrclib/lyrics/loader.py +234 -0
- pylrclib/lyrics/writer.py +60 -0
- pylrclib/models/__init__.py +10 -0
- pylrclib/models/lyrics.py +97 -0
- pylrclib/models/track.py +137 -0
- pylrclib/workflows/down.py +98 -0
- pylrclib/workflows/search.py +54 -0
- pylrclib/workflows/up.py +278 -0
- pylrclib_cli-0.4.1.dist-info/METADATA +474 -0
- pylrclib_cli-0.4.1.dist-info/RECORD +44 -0
- pylrclib_cli-0.4.1.dist-info/WHEEL +5 -0
- pylrclib_cli-0.4.1.dist-info/entry_points.txt +2 -0
- pylrclib_cli-0.4.1.dist-info/licenses/LICENSE +21 -0
- pylrclib_cli-0.4.1.dist-info/top_level.txt +1 -0
pylrclib/__init__.py
ADDED
pylrclib/__main__.py
ADDED
pylrclib/api/__init__.py
ADDED
pylrclib/api/client.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from ..config import CommonOptions
|
|
6
|
+
from ..models import LookupResult, LyricsRecord, TrackMeta
|
|
7
|
+
from .http import http_request_json
|
|
8
|
+
from .publish import build_publish_payload, publish_with_retry
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ApiClient:
|
|
12
|
+
def __init__(self, options: CommonOptions) -> None:
|
|
13
|
+
self.options = options
|
|
14
|
+
|
|
15
|
+
def _lookup(self, meta: TrackMeta, endpoint: str, source: str) -> LookupResult:
|
|
16
|
+
data = http_request_json(
|
|
17
|
+
self.options,
|
|
18
|
+
method='GET',
|
|
19
|
+
url=f'{self.options.lrclib_base}/{endpoint}',
|
|
20
|
+
label=source,
|
|
21
|
+
params={
|
|
22
|
+
'track_name': meta.track,
|
|
23
|
+
'artist_name': meta.artist,
|
|
24
|
+
'album_name': meta.album,
|
|
25
|
+
'duration': meta.duration,
|
|
26
|
+
},
|
|
27
|
+
)
|
|
28
|
+
if not data:
|
|
29
|
+
return LookupResult(record=None, duration_diff=None, duration_ok=False, source=source)
|
|
30
|
+
record = LyricsRecord.from_api(data)
|
|
31
|
+
diff = None
|
|
32
|
+
ok = True
|
|
33
|
+
if record.duration is not None and meta.duration > 0:
|
|
34
|
+
diff = abs(record.duration - meta.duration)
|
|
35
|
+
ok = diff <= 2
|
|
36
|
+
return LookupResult(record=record, duration_diff=diff, duration_ok=ok, source=source)
|
|
37
|
+
|
|
38
|
+
def get_cached(self, meta: TrackMeta) -> LookupResult:
|
|
39
|
+
return self._lookup(meta, 'get-cached', 'cache')
|
|
40
|
+
|
|
41
|
+
def get_external(self, meta: TrackMeta) -> LookupResult:
|
|
42
|
+
return self._lookup(meta, 'get', 'external')
|
|
43
|
+
|
|
44
|
+
def get_by_id(self, lrclib_id: int) -> LyricsRecord | None:
|
|
45
|
+
data = http_request_json(
|
|
46
|
+
self.options,
|
|
47
|
+
method='GET',
|
|
48
|
+
url=f'{self.options.lrclib_base}/get/{int(lrclib_id)}',
|
|
49
|
+
label=f'get_by_id:{lrclib_id}',
|
|
50
|
+
treat_404_as_none=True,
|
|
51
|
+
)
|
|
52
|
+
if not isinstance(data, dict):
|
|
53
|
+
return None
|
|
54
|
+
return LyricsRecord.from_api(data)
|
|
55
|
+
|
|
56
|
+
def search(
|
|
57
|
+
self,
|
|
58
|
+
*,
|
|
59
|
+
query: str | None = None,
|
|
60
|
+
track_name: str | None = None,
|
|
61
|
+
artist_name: str | None = None,
|
|
62
|
+
album_name: str | None = None,
|
|
63
|
+
) -> list[LyricsRecord]:
|
|
64
|
+
if not query and not track_name:
|
|
65
|
+
raise ValueError('Either query or track_name is required for search')
|
|
66
|
+
data = http_request_json(
|
|
67
|
+
self.options,
|
|
68
|
+
method='GET',
|
|
69
|
+
url=f'{self.options.lrclib_base}/search',
|
|
70
|
+
label='search',
|
|
71
|
+
params={
|
|
72
|
+
'q': query,
|
|
73
|
+
'track_name': track_name,
|
|
74
|
+
'artist_name': artist_name,
|
|
75
|
+
'album_name': album_name,
|
|
76
|
+
},
|
|
77
|
+
treat_404_as_none=True,
|
|
78
|
+
)
|
|
79
|
+
if data is None:
|
|
80
|
+
return []
|
|
81
|
+
if isinstance(data, list):
|
|
82
|
+
return [LyricsRecord.from_api(item) for item in data if isinstance(item, dict)]
|
|
83
|
+
if isinstance(data, dict):
|
|
84
|
+
return [LyricsRecord.from_api(data)]
|
|
85
|
+
return []
|
|
86
|
+
|
|
87
|
+
def upload_lyrics(self, meta: TrackMeta, plain: str, synced: str) -> bool:
|
|
88
|
+
payload = build_publish_payload(meta, plain, synced, instrumental=False)
|
|
89
|
+
return publish_with_retry(self.options, meta, payload, 'upload lyrics')
|
|
90
|
+
|
|
91
|
+
def upload_instrumental(self, meta: TrackMeta) -> bool:
|
|
92
|
+
payload = build_publish_payload(meta, None, None, instrumental=True)
|
|
93
|
+
return publish_with_retry(self.options, meta, payload, 'upload instrumental')
|
pylrclib/api/http.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
from requests import RequestException
|
|
8
|
+
|
|
9
|
+
from ..config import CommonOptions
|
|
10
|
+
from ..logging_utils import log_warn
|
|
11
|
+
from .retry import calculate_backoff, is_retryable_status, parse_retry_after
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def http_request_json(
|
|
15
|
+
options: CommonOptions,
|
|
16
|
+
method: str,
|
|
17
|
+
url: str,
|
|
18
|
+
label: str,
|
|
19
|
+
*,
|
|
20
|
+
params: Optional[dict[str, Any]] = None,
|
|
21
|
+
json_data: Optional[dict[str, Any]] = None,
|
|
22
|
+
timeout: int = 20,
|
|
23
|
+
max_retries: Optional[int] = None,
|
|
24
|
+
treat_404_as_none: bool = True,
|
|
25
|
+
) -> Any:
|
|
26
|
+
retries = max_retries if max_retries is not None else options.max_http_retries
|
|
27
|
+
for attempt in range(1, retries + 1):
|
|
28
|
+
try:
|
|
29
|
+
response = requests.request(
|
|
30
|
+
method=method,
|
|
31
|
+
url=url,
|
|
32
|
+
params=params,
|
|
33
|
+
json=json_data,
|
|
34
|
+
timeout=timeout,
|
|
35
|
+
headers={"User-Agent": options.user_agent},
|
|
36
|
+
)
|
|
37
|
+
except RequestException as exc:
|
|
38
|
+
if attempt == retries:
|
|
39
|
+
log_warn(f"{label} failed after {attempt} attempts: {exc}")
|
|
40
|
+
return None
|
|
41
|
+
delay = calculate_backoff(attempt)
|
|
42
|
+
log_warn(f"{label} request error on attempt {attempt}/{retries}: {exc}; retrying in {delay:.1f}s")
|
|
43
|
+
time.sleep(delay)
|
|
44
|
+
continue
|
|
45
|
+
|
|
46
|
+
if response.status_code == 404 and treat_404_as_none:
|
|
47
|
+
return None
|
|
48
|
+
if 200 <= response.status_code < 300:
|
|
49
|
+
try:
|
|
50
|
+
return response.json()
|
|
51
|
+
except ValueError:
|
|
52
|
+
log_warn(f"{label} returned invalid JSON: {response.text[:200]}")
|
|
53
|
+
return None
|
|
54
|
+
if is_retryable_status(response.status_code):
|
|
55
|
+
if attempt == retries:
|
|
56
|
+
log_warn(f"{label} failed after {attempt} attempts: HTTP {response.status_code} {response.text[:200]}")
|
|
57
|
+
return None
|
|
58
|
+
delay = parse_retry_after(response.headers.get("Retry-After")) or calculate_backoff(attempt)
|
|
59
|
+
log_warn(f"{label} failed with HTTP {response.status_code}; retrying in {delay:.1f}s")
|
|
60
|
+
time.sleep(delay)
|
|
61
|
+
continue
|
|
62
|
+
log_warn(f"{label} failed with HTTP {response.status_code}: {response.text[:200]}")
|
|
63
|
+
return None
|
|
64
|
+
return None
|
pylrclib/api/pow.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
|
|
5
|
+
from ..exceptions import PoWError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def solve_pow(prefix: str, target_hex: str) -> str:
|
|
9
|
+
if not prefix or not target_hex:
|
|
10
|
+
raise PoWError(f"invalid PoW params: prefix={prefix!r}, target={target_hex!r}")
|
|
11
|
+
target = int(target_hex, 16)
|
|
12
|
+
nonce = 0
|
|
13
|
+
while True:
|
|
14
|
+
digest = hashlib.sha256(f"{prefix}{nonce}".encode("utf-8")).hexdigest()
|
|
15
|
+
if int(digest, 16) <= target:
|
|
16
|
+
return str(nonce)
|
|
17
|
+
nonce += 1
|
pylrclib/api/publish.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
from requests import RequestException
|
|
8
|
+
|
|
9
|
+
from ..config import CommonOptions
|
|
10
|
+
from ..logging_utils import log_warn
|
|
11
|
+
from ..models import TrackMeta
|
|
12
|
+
from .http import http_request_json
|
|
13
|
+
from .pow import solve_pow
|
|
14
|
+
from .retry import calculate_backoff, is_retryable_status, parse_retry_after
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def request_publish_token(options: CommonOptions) -> Optional[str]:
|
|
18
|
+
data = http_request_json(
|
|
19
|
+
options,
|
|
20
|
+
method="POST",
|
|
21
|
+
url=f"{options.lrclib_base}/request-challenge",
|
|
22
|
+
label="request publish challenge",
|
|
23
|
+
treat_404_as_none=False,
|
|
24
|
+
)
|
|
25
|
+
if not data:
|
|
26
|
+
return None
|
|
27
|
+
prefix = data.get("prefix")
|
|
28
|
+
target = data.get("target")
|
|
29
|
+
if not prefix or not target:
|
|
30
|
+
return None
|
|
31
|
+
nonce = solve_pow(prefix, target)
|
|
32
|
+
return f"{prefix}:{nonce}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def build_publish_payload(meta: TrackMeta, plain: Optional[str], synced: Optional[str], *, instrumental: bool = False) -> dict[str, Any]:
|
|
36
|
+
payload: dict[str, Any] = {
|
|
37
|
+
"trackName": meta.track,
|
|
38
|
+
"artistName": meta.artist,
|
|
39
|
+
"albumName": meta.album,
|
|
40
|
+
"duration": meta.duration,
|
|
41
|
+
}
|
|
42
|
+
if instrumental:
|
|
43
|
+
return payload
|
|
44
|
+
plain_text = (plain or "").strip()
|
|
45
|
+
synced_text = (synced or "").strip()
|
|
46
|
+
if plain_text:
|
|
47
|
+
payload["plainLyrics"] = plain_text
|
|
48
|
+
if synced_text:
|
|
49
|
+
payload["syncedLyrics"] = synced_text
|
|
50
|
+
return payload
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def publish_with_retry(options: CommonOptions, meta: TrackMeta, payload: dict[str, Any], label: str) -> bool:
|
|
54
|
+
url = f"{options.lrclib_base}/publish"
|
|
55
|
+
retries = options.max_http_retries
|
|
56
|
+
for attempt in range(1, retries + 1):
|
|
57
|
+
token = request_publish_token(options)
|
|
58
|
+
if not token:
|
|
59
|
+
if attempt == retries:
|
|
60
|
+
return False
|
|
61
|
+
delay = calculate_backoff(attempt)
|
|
62
|
+
time.sleep(delay)
|
|
63
|
+
continue
|
|
64
|
+
try:
|
|
65
|
+
response = requests.post(
|
|
66
|
+
url,
|
|
67
|
+
json=payload,
|
|
68
|
+
headers={
|
|
69
|
+
"User-Agent": options.user_agent,
|
|
70
|
+
"Content-Type": "application/json",
|
|
71
|
+
"X-Publish-Token": token,
|
|
72
|
+
},
|
|
73
|
+
timeout=30,
|
|
74
|
+
)
|
|
75
|
+
except RequestException as exc:
|
|
76
|
+
if attempt == retries:
|
|
77
|
+
log_warn(f"{label} failed after {attempt} attempts: {exc}")
|
|
78
|
+
return False
|
|
79
|
+
delay = calculate_backoff(attempt)
|
|
80
|
+
time.sleep(delay)
|
|
81
|
+
continue
|
|
82
|
+
if response.status_code == 201:
|
|
83
|
+
return True
|
|
84
|
+
if is_retryable_status(response.status_code):
|
|
85
|
+
if attempt == retries:
|
|
86
|
+
return False
|
|
87
|
+
delay = parse_retry_after(response.headers.get("Retry-After")) or calculate_backoff(attempt)
|
|
88
|
+
time.sleep(delay)
|
|
89
|
+
continue
|
|
90
|
+
log_warn(f"{label} failed with HTTP {response.status_code}: {response.text[:200]}")
|
|
91
|
+
return False
|
|
92
|
+
return False
|
pylrclib/api/retry.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import random
|
|
4
|
+
from email.utils import parsedate_to_datetime
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
RETRYABLE_STATUSES = {408, 425, 429, 500, 502, 503, 504}
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def calculate_backoff(attempt: int, base: float = 1.0, max_delay: float = 30.0) -> float:
|
|
11
|
+
return min(base * (2 ** (attempt - 1)) + random.uniform(0, 1), max_delay)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def parse_retry_after(value: Optional[str]) -> Optional[float]:
|
|
15
|
+
if not value:
|
|
16
|
+
return None
|
|
17
|
+
try:
|
|
18
|
+
return max(0.0, float(value))
|
|
19
|
+
except ValueError:
|
|
20
|
+
try:
|
|
21
|
+
dt = parsedate_to_datetime(value)
|
|
22
|
+
return max(0.0, (dt - dt.now(dt.tzinfo)).total_seconds())
|
|
23
|
+
except Exception:
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def is_retryable_status(status: int) -> bool:
|
|
28
|
+
return status in RETRYABLE_STATUSES
|
pylrclib/cli/__init__.py
ADDED
|
File without changes
|
pylrclib/cli/main.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from .. import __version__
|
|
7
|
+
from ..commands import cleanse, doctor, down, inspect, search, up
|
|
8
|
+
from ..exceptions import CLIUsageError
|
|
9
|
+
from ..i18n import setup_i18n
|
|
10
|
+
from ..logging_utils import log_error
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _detect_lang(argv: list[str]) -> str:
|
|
14
|
+
for index, value in enumerate(argv):
|
|
15
|
+
if value in {"--lang", "--language"} and index + 1 < len(argv):
|
|
16
|
+
return argv[index + 1]
|
|
17
|
+
if value.startswith("--lang="):
|
|
18
|
+
return value.split("=", 1)[1]
|
|
19
|
+
if value.startswith("--language="):
|
|
20
|
+
return value.split("=", 1)[1]
|
|
21
|
+
return "auto"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def build_parser(lang: str) -> argparse.ArgumentParser:
|
|
25
|
+
setup_i18n(lang)
|
|
26
|
+
parser = argparse.ArgumentParser(prog="pylrclib", description="Upload, download, inspect, and cleanse lyrics around LRCLIB.")
|
|
27
|
+
parser.add_argument("--lang", "--language", default=lang, choices=["auto", "en_US", "zh_CN"])
|
|
28
|
+
parser.add_argument("--version", action="version", version=f"pylrclib {__version__}")
|
|
29
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
30
|
+
up.add_parser(subparsers)
|
|
31
|
+
down.add_parser(subparsers)
|
|
32
|
+
search.add_parser(subparsers)
|
|
33
|
+
cleanse.add_parser(subparsers)
|
|
34
|
+
inspect.add_parser(subparsers)
|
|
35
|
+
doctor.add_parser(subparsers)
|
|
36
|
+
return parser
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def main(argv: list[str] | None = None) -> int:
|
|
40
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
41
|
+
detected_lang = _detect_lang(argv)
|
|
42
|
+
parser = build_parser(detected_lang)
|
|
43
|
+
args = parser.parse_args(argv)
|
|
44
|
+
lang = setup_i18n(args.lang)
|
|
45
|
+
try:
|
|
46
|
+
return int(args.command_handler(args, lang) or 0)
|
|
47
|
+
except CLIUsageError as exc:
|
|
48
|
+
log_error(str(exc))
|
|
49
|
+
return 2
|
|
50
|
+
except KeyboardInterrupt:
|
|
51
|
+
log_error("interrupted by user")
|
|
52
|
+
return 130
|
|
File without changes
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from argparse import ArgumentParser, Namespace
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from ..config import PREVIEW_LINES_DEFAULT, UNSET, resolve_int
|
|
7
|
+
from ..logging_utils import log_info, log_warn
|
|
8
|
+
from ..lrc import cleanse_lrc_file
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def add_parser(subparsers) -> ArgumentParser:
|
|
12
|
+
parser = subparsers.add_parser("cleanse", help="cleanse LRC files without uploading")
|
|
13
|
+
parser.add_argument("paths", nargs="*", default=[])
|
|
14
|
+
parser.add_argument("--lrc-dir", default=UNSET)
|
|
15
|
+
parser.add_argument("--write", action="store_true")
|
|
16
|
+
parser.add_argument("--preview-lines", default=UNSET)
|
|
17
|
+
parser.set_defaults(command_handler=run)
|
|
18
|
+
return parser
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _collect_paths(args: Namespace) -> list[Path]:
|
|
22
|
+
if args.paths:
|
|
23
|
+
raw_paths = [Path(value).expanduser().resolve() for value in args.paths]
|
|
24
|
+
elif args.lrc_dir is not UNSET:
|
|
25
|
+
raw_paths = [Path(str(args.lrc_dir)).expanduser().resolve()]
|
|
26
|
+
else:
|
|
27
|
+
raw_paths = [Path.cwd().resolve()]
|
|
28
|
+
files: list[Path] = []
|
|
29
|
+
for path in raw_paths:
|
|
30
|
+
if path.is_file() and path.suffix.lower() == ".lrc":
|
|
31
|
+
files.append(path)
|
|
32
|
+
elif path.is_dir():
|
|
33
|
+
files.extend(sorted(path.rglob("*.lrc")))
|
|
34
|
+
return files
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def run(args: Namespace, lang: str) -> int:
|
|
38
|
+
del lang
|
|
39
|
+
preview_lines = resolve_int(args.preview_lines, "PYLRCLIB_PREVIEW_LINES", PREVIEW_LINES_DEFAULT)
|
|
40
|
+
files = _collect_paths(args)
|
|
41
|
+
if not files:
|
|
42
|
+
log_warn("no LRC files found")
|
|
43
|
+
return 0
|
|
44
|
+
stats = {"updated": 0, "unchanged": 0, "invalid": 0, "failed": 0}
|
|
45
|
+
for path in files:
|
|
46
|
+
result = cleanse_lrc_file(path, write=args.write)
|
|
47
|
+
stats[result.status] = stats.get(result.status, 0) + 1
|
|
48
|
+
log_info(f"{path}: {result.status}")
|
|
49
|
+
if result.cleaned_text:
|
|
50
|
+
lines = result.cleaned_text.splitlines()
|
|
51
|
+
for line in lines[:preview_lines]:
|
|
52
|
+
print(line)
|
|
53
|
+
if len(lines) > preview_lines:
|
|
54
|
+
print(f"... ({len(lines)} lines total)")
|
|
55
|
+
log_info("summary: " + ", ".join(f"{key}={value}" for key, value in sorted(stats.items())))
|
|
56
|
+
return 0
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from argparse import ArgumentParser, Namespace
|
|
4
|
+
|
|
5
|
+
from ..config import SUPPORTED_AUDIO_EXTENSIONS, SUPPORTED_YAML_EXTENSIONS, SUPPORTED_PLAIN_EXTENSIONS, SUPPORTED_SYNCED_EXTENSIONS, UNSET
|
|
6
|
+
from ..logging_utils import log_info, log_warn
|
|
7
|
+
from ..discovery import discover_inputs
|
|
8
|
+
from .up import _build_config
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def add_parser(subparsers) -> ArgumentParser:
|
|
12
|
+
parser = subparsers.add_parser("doctor", help="diagnose the current workspace and resolved configuration")
|
|
13
|
+
parser.add_argument("--tracks", default=UNSET)
|
|
14
|
+
parser.add_argument("--lyrics-dir", default=UNSET)
|
|
15
|
+
parser.add_argument("--plain-dir", default=UNSET)
|
|
16
|
+
parser.add_argument("--synced-dir", default=UNSET)
|
|
17
|
+
parser.add_argument("--done-tracks", default=UNSET)
|
|
18
|
+
parser.add_argument("--done-lrc", default=UNSET)
|
|
19
|
+
parser.add_argument("-f", "--follow", action="store_true")
|
|
20
|
+
parser.add_argument("-r", "--rename", action="store_true")
|
|
21
|
+
parser.add_argument("-c", "--cleanse", action="store_true")
|
|
22
|
+
parser.add_argument("--lyrics-mode", default="auto", choices=["auto", "plain", "synced", "mixed", "instrumental"])
|
|
23
|
+
parser.add_argument("-d", "--default", nargs=2, metavar=("TRACKS_DIR", "LYRICS_DIR"))
|
|
24
|
+
parser.add_argument("-m", "--match", action="store_true")
|
|
25
|
+
parser.add_argument("--preview-lines", default=UNSET)
|
|
26
|
+
parser.add_argument("--max-retries", default=UNSET)
|
|
27
|
+
parser.add_argument("--user-agent", default=UNSET)
|
|
28
|
+
parser.add_argument("--api-base", default=UNSET)
|
|
29
|
+
parser.set_defaults(command_handler=run)
|
|
30
|
+
return parser
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def run(args: Namespace, lang: str) -> int:
|
|
34
|
+
args.cleanse_write = False
|
|
35
|
+
args.allow_non_lrc = False
|
|
36
|
+
args.ignore_duration_mismatch = False
|
|
37
|
+
args.yes = False
|
|
38
|
+
args.non_interactive = True
|
|
39
|
+
args.allow_derived_plain = True
|
|
40
|
+
config = _build_config(args, lang)
|
|
41
|
+
print("Resolved configuration:")
|
|
42
|
+
print(f" tracks_dir={config.tracks_dir}")
|
|
43
|
+
print(f" lyrics_dir={config.lyrics_dir}")
|
|
44
|
+
print(f" plain_dir={config.plain_dir}")
|
|
45
|
+
print(f" synced_dir={config.synced_dir}")
|
|
46
|
+
print(f" done_tracks_dir={config.done_tracks_dir}")
|
|
47
|
+
print(f" done_lrc_dir={config.done_lrc_dir}")
|
|
48
|
+
print(f" follow_track={config.follow_track}")
|
|
49
|
+
print(f" rename_lrc={config.rename_lrc}")
|
|
50
|
+
print(f" cleanse={config.cleanse}")
|
|
51
|
+
print(f" lyrics_mode={config.lyrics_mode}")
|
|
52
|
+
print(f" mode={config.mode}")
|
|
53
|
+
print(f" api_base={config.common.lrclib_base}")
|
|
54
|
+
print(f" max_retries={config.common.max_http_retries}")
|
|
55
|
+
|
|
56
|
+
errors = 0
|
|
57
|
+
if not config.tracks_dir.exists():
|
|
58
|
+
log_warn(f"tracks_dir does not exist: {config.tracks_dir}")
|
|
59
|
+
errors += 1
|
|
60
|
+
if config.follow_track and config.done_lrc_dir:
|
|
61
|
+
log_warn("follow_track and done_lrc_dir should not be combined")
|
|
62
|
+
errors += 1
|
|
63
|
+
|
|
64
|
+
audio_count = 0
|
|
65
|
+
yaml_count = 0
|
|
66
|
+
plain_count = 0
|
|
67
|
+
synced_count = 0
|
|
68
|
+
if config.tracks_dir.exists():
|
|
69
|
+
for path in config.tracks_dir.rglob("*"):
|
|
70
|
+
if path.suffix.lower() in SUPPORTED_AUDIO_EXTENSIONS:
|
|
71
|
+
audio_count += 1
|
|
72
|
+
elif path.suffix.lower() in SUPPORTED_YAML_EXTENSIONS:
|
|
73
|
+
yaml_count += 1
|
|
74
|
+
for directory, exts, counter_name in [
|
|
75
|
+
(config.lyrics_dir, SUPPORTED_PLAIN_EXTENSIONS, "plain"),
|
|
76
|
+
(config.plain_dir, SUPPORTED_PLAIN_EXTENSIONS, "plain"),
|
|
77
|
+
(config.lyrics_dir, SUPPORTED_SYNCED_EXTENSIONS, "synced"),
|
|
78
|
+
(config.synced_dir, SUPPORTED_SYNCED_EXTENSIONS, "synced"),
|
|
79
|
+
]:
|
|
80
|
+
if directory and directory.exists():
|
|
81
|
+
count = sum(1 for path in directory.rglob("*") if path.suffix.lower() in exts)
|
|
82
|
+
if counter_name == "plain":
|
|
83
|
+
plain_count += count
|
|
84
|
+
else:
|
|
85
|
+
synced_count += count
|
|
86
|
+
items = discover_inputs(config.tracks_dir)
|
|
87
|
+
print(f"Found audio={audio_count}, yaml={yaml_count}, plain={plain_count}, synced={synced_count}, valid_inputs={len(items)}")
|
|
88
|
+
if errors:
|
|
89
|
+
return 1
|
|
90
|
+
log_info("doctor finished without fatal issues")
|
|
91
|
+
return 0
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from argparse import ArgumentParser, Namespace
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from ..config import (
|
|
7
|
+
DEFAULT_USER_AGENT,
|
|
8
|
+
LRCLIB_BASE,
|
|
9
|
+
MAX_HTTP_RETRIES_DEFAULT,
|
|
10
|
+
PREVIEW_LINES_DEFAULT,
|
|
11
|
+
UNSET,
|
|
12
|
+
CommonOptions,
|
|
13
|
+
DownConfig,
|
|
14
|
+
resolve_int,
|
|
15
|
+
resolve_optional_int,
|
|
16
|
+
resolve_optional_str,
|
|
17
|
+
resolve_path,
|
|
18
|
+
resolve_str,
|
|
19
|
+
)
|
|
20
|
+
from ..exceptions import CLIUsageError
|
|
21
|
+
from ..workflows.down import run_down
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
SAVE_MODES = ['auto', 'plain', 'synced', 'both']
|
|
25
|
+
NAMING_MODES = ['auto', 'track-basename', 'artist-title']
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def add_parser(subparsers) -> ArgumentParser:
|
|
29
|
+
parser = subparsers.add_parser('down', help='download lyrics from LRCLIB')
|
|
30
|
+
parser.add_argument('--tracks', default=UNSET, help='audio/YAML directory to query from')
|
|
31
|
+
parser.add_argument('--artist', default=UNSET)
|
|
32
|
+
parser.add_argument('--title', default=UNSET)
|
|
33
|
+
parser.add_argument('--album', default=UNSET)
|
|
34
|
+
parser.add_argument('--duration', default=UNSET)
|
|
35
|
+
parser.add_argument('--lrclib-id', default=UNSET)
|
|
36
|
+
parser.add_argument('--output-dir', default=UNSET)
|
|
37
|
+
parser.add_argument('--plain-dir', default=UNSET)
|
|
38
|
+
parser.add_argument('--synced-dir', default=UNSET)
|
|
39
|
+
parser.add_argument('--save-mode', default='auto', choices=SAVE_MODES)
|
|
40
|
+
parser.add_argument('--naming', default='auto', choices=NAMING_MODES)
|
|
41
|
+
parser.add_argument('--skip-existing', action='store_true')
|
|
42
|
+
parser.add_argument('--overwrite', action='store_true')
|
|
43
|
+
parser.add_argument('--allow-derived-plain', action='store_true', default=True)
|
|
44
|
+
parser.add_argument('--no-derived-plain', dest='allow_derived_plain', action='store_false')
|
|
45
|
+
parser.add_argument('--preview-lines', default=UNSET)
|
|
46
|
+
parser.add_argument('--max-retries', default=UNSET)
|
|
47
|
+
parser.add_argument('--user-agent', default=UNSET)
|
|
48
|
+
parser.add_argument('--api-base', default=UNSET)
|
|
49
|
+
parser.add_argument('--yes', action='store_true')
|
|
50
|
+
parser.add_argument('--non-interactive', action='store_true')
|
|
51
|
+
parser.set_defaults(command_handler=run)
|
|
52
|
+
return parser
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _validate(args: Namespace) -> None:
|
|
56
|
+
manual = args.artist is not UNSET or args.title is not UNSET
|
|
57
|
+
by_id = args.lrclib_id is not UNSET
|
|
58
|
+
if by_id and (manual or args.tracks is not UNSET):
|
|
59
|
+
raise CLIUsageError('--lrclib-id cannot be combined with --tracks or manual --artist/--title mode')
|
|
60
|
+
if manual and args.tracks is not UNSET:
|
|
61
|
+
raise CLIUsageError('--tracks cannot be combined with manual --artist/--title mode')
|
|
62
|
+
if manual and (args.artist is UNSET or args.title is UNSET):
|
|
63
|
+
raise CLIUsageError('manual mode requires both --artist and --title')
|
|
64
|
+
if not by_id and not manual and args.tracks is UNSET:
|
|
65
|
+
raise CLIUsageError('either --lrclib-id, --tracks, or both --artist/--title are required')
|
|
66
|
+
if args.skip_existing and args.overwrite:
|
|
67
|
+
raise CLIUsageError('--skip-existing and --overwrite cannot be used together')
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _build_config(args: Namespace, lang: str) -> DownConfig:
|
|
71
|
+
_validate(args)
|
|
72
|
+
cwd = Path.cwd().resolve()
|
|
73
|
+
tracks_dir = resolve_path(args.tracks, 'PYLRCLIB_TRACKS_DIR') if args.tracks is not UNSET else None
|
|
74
|
+
output_dir = resolve_path(args.output_dir, 'PYLRCLIB_OUTPUT_DIR', cwd) or cwd
|
|
75
|
+
plain_dir = resolve_path(args.plain_dir, 'PYLRCLIB_PLAIN_DIR')
|
|
76
|
+
synced_dir = resolve_path(args.synced_dir, 'PYLRCLIB_SYNCED_DIR')
|
|
77
|
+
common = CommonOptions(
|
|
78
|
+
lang=lang,
|
|
79
|
+
preview_lines=resolve_int(args.preview_lines, 'PYLRCLIB_PREVIEW_LINES', PREVIEW_LINES_DEFAULT),
|
|
80
|
+
max_http_retries=resolve_int(args.max_retries, 'PYLRCLIB_MAX_HTTP_RETRIES', MAX_HTTP_RETRIES_DEFAULT),
|
|
81
|
+
user_agent=resolve_str(args.user_agent, 'PYLRCLIB_USER_AGENT', DEFAULT_USER_AGENT),
|
|
82
|
+
lrclib_base=resolve_str(args.api_base, 'PYLRCLIB_API_BASE', LRCLIB_BASE),
|
|
83
|
+
interactive=not (args.non_interactive or args.yes),
|
|
84
|
+
assume_yes=args.yes,
|
|
85
|
+
)
|
|
86
|
+
return DownConfig(
|
|
87
|
+
tracks_dir=tracks_dir,
|
|
88
|
+
output_dir=output_dir,
|
|
89
|
+
plain_dir=plain_dir,
|
|
90
|
+
synced_dir=synced_dir,
|
|
91
|
+
save_mode=args.save_mode,
|
|
92
|
+
skip_existing=args.skip_existing,
|
|
93
|
+
overwrite=args.overwrite,
|
|
94
|
+
naming=args.naming,
|
|
95
|
+
artist=resolve_optional_str(args.artist, 'PYLRCLIB_ARTIST'),
|
|
96
|
+
track=resolve_optional_str(args.title, 'PYLRCLIB_TITLE'),
|
|
97
|
+
album=resolve_optional_str(args.album, 'PYLRCLIB_ALBUM'),
|
|
98
|
+
duration=resolve_optional_int(args.duration, 'PYLRCLIB_DURATION'),
|
|
99
|
+
lrclib_id=resolve_optional_int(args.lrclib_id, 'PYLRCLIB_LRCLIB_ID'),
|
|
100
|
+
allow_derived_plain=args.allow_derived_plain,
|
|
101
|
+
common=common,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def run(args: Namespace, lang: str) -> int:
|
|
106
|
+
config = _build_config(args, lang)
|
|
107
|
+
return run_down(config)
|