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.
Files changed (44) hide show
  1. pylrclib/__init__.py +4 -0
  2. pylrclib/__main__.py +3 -0
  3. pylrclib/api/__init__.py +3 -0
  4. pylrclib/api/client.py +93 -0
  5. pylrclib/api/http.py +64 -0
  6. pylrclib/api/pow.py +17 -0
  7. pylrclib/api/publish.py +92 -0
  8. pylrclib/api/retry.py +28 -0
  9. pylrclib/cli/__init__.py +0 -0
  10. pylrclib/cli/main.py +52 -0
  11. pylrclib/commands/__init__.py +0 -0
  12. pylrclib/commands/cleanse.py +56 -0
  13. pylrclib/commands/doctor.py +91 -0
  14. pylrclib/commands/down.py +107 -0
  15. pylrclib/commands/inspect.py +65 -0
  16. pylrclib/commands/search.py +67 -0
  17. pylrclib/commands/up.py +143 -0
  18. pylrclib/config.py +124 -0
  19. pylrclib/discovery.py +33 -0
  20. pylrclib/exceptions.py +10 -0
  21. pylrclib/fs/__init__.py +4 -0
  22. pylrclib/fs/cleaner.py +17 -0
  23. pylrclib/fs/mover.py +32 -0
  24. pylrclib/i18n.py +28 -0
  25. pylrclib/interaction.py +73 -0
  26. pylrclib/logging_utils.py +19 -0
  27. pylrclib/lrc/__init__.py +29 -0
  28. pylrclib/lrc/matcher.py +114 -0
  29. pylrclib/lrc/parser.py +175 -0
  30. pylrclib/lyrics/__init__.py +18 -0
  31. pylrclib/lyrics/loader.py +234 -0
  32. pylrclib/lyrics/writer.py +60 -0
  33. pylrclib/models/__init__.py +10 -0
  34. pylrclib/models/lyrics.py +97 -0
  35. pylrclib/models/track.py +137 -0
  36. pylrclib/workflows/down.py +98 -0
  37. pylrclib/workflows/search.py +54 -0
  38. pylrclib/workflows/up.py +278 -0
  39. pylrclib_cli-0.4.1.dist-info/METADATA +474 -0
  40. pylrclib_cli-0.4.1.dist-info/RECORD +44 -0
  41. pylrclib_cli-0.4.1.dist-info/WHEEL +5 -0
  42. pylrclib_cli-0.4.1.dist-info/entry_points.txt +2 -0
  43. pylrclib_cli-0.4.1.dist-info/licenses/LICENSE +21 -0
  44. pylrclib_cli-0.4.1.dist-info/top_level.txt +1 -0
pylrclib/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """pylrclib package."""
2
+
3
+ __all__ = ["__version__"]
4
+ __version__ = "1.2.0"
pylrclib/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli.main import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,3 @@
1
+ from .client import ApiClient
2
+
3
+ __all__ = ["ApiClient"]
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
@@ -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
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)