coverart-cli 0.4.0__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.
@@ -0,0 +1,47 @@
1
+ """coverart-cli — fetch missing album covers from Last.fm / iTunes / Deezer / MusicBrainz
2
+ and embed them into MP3 / M4A / FLAC / Ogg files (plus a cover.jpg sidecar).
3
+
4
+ Public API for library consumers:
5
+ >>> from coverart_cli import RunOptions, run, ITunesProvider
6
+ >>> stats = run(RunOptions(root=Path("~/Music"), providers=[ITunesProvider()]))
7
+
8
+ Anything not exported from this top-level package may change without notice.
9
+ """
10
+
11
+ __version__ = "0.4.0" # x-release-please-version
12
+
13
+ # Re-exports come after __version__ so submodules can import it safely.
14
+ from coverart_cli.core import RunOptions, RunStats, run # noqa: E402
15
+ from coverart_cli.providers import ( # noqa: E402
16
+ CoverProvider,
17
+ DeezerProvider,
18
+ ITunesProvider,
19
+ LastFmProvider,
20
+ MusicBrainzProvider,
21
+ ProviderResult,
22
+ )
23
+ from coverart_cli.report import AlbumEntry, scan_library, write_report # noqa: E402
24
+ from coverart_cli.tagging import AlbumMeta, embed_cover, has_embedded_cover # noqa: E402
25
+
26
+ __all__ = [
27
+ "__version__",
28
+ # core
29
+ "RunOptions",
30
+ "RunStats",
31
+ "run",
32
+ # providers
33
+ "CoverProvider",
34
+ "ProviderResult",
35
+ "LastFmProvider",
36
+ "ITunesProvider",
37
+ "DeezerProvider",
38
+ "MusicBrainzProvider",
39
+ # tagging
40
+ "AlbumMeta",
41
+ "embed_cover",
42
+ "has_embedded_cover",
43
+ # report
44
+ "AlbumEntry",
45
+ "scan_library",
46
+ "write_report",
47
+ ]
@@ -0,0 +1,4 @@
1
+ from coverart_cli.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
coverart_cli/cli.py ADDED
@@ -0,0 +1,257 @@
1
+ """Command-line interface for coverart-cli."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import logging
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from coverart_cli import __version__
11
+ from coverart_cli.config import load_config
12
+ from coverart_cli.core import RunOptions, RunStats, run
13
+ from coverart_cli.providers import (
14
+ DeezerProvider,
15
+ ITunesProvider,
16
+ LastFmProvider,
17
+ MusicBrainzProvider,
18
+ )
19
+
20
+ DEFAULT_UA = f"coverart-cli/{__version__} (+https://github.com/WildDragonKing/coverart-cli)"
21
+
22
+
23
+ def build_parser() -> argparse.ArgumentParser:
24
+ p = argparse.ArgumentParser(
25
+ prog="coverart",
26
+ description=(
27
+ "Fetch missing album cover art from Last.fm, iTunes, Deezer and "
28
+ "MusicBrainz; embed it into MP3 / M4A / FLAC / Ogg files and write "
29
+ "a cover.jpg sidecar in one pass."
30
+ ),
31
+ epilog=(
32
+ "examples:\n"
33
+ " coverart ~/Music # iTunes+Deezer+MB only\n"
34
+ " coverart ~/Music --lastfm-key YOUR_KEY # all 4 providers\n"
35
+ " coverart ~/Music --dry-run -v\n"
36
+ " coverart ~/Music --report-html report.html\n"
37
+ " coverart ~/Music --no-embed # sidecars only\n"
38
+ ),
39
+ formatter_class=argparse.RawDescriptionHelpFormatter,
40
+ )
41
+ p.add_argument("root", type=Path, help="root directory of your music library")
42
+ p.add_argument(
43
+ "--config",
44
+ type=Path,
45
+ default=None,
46
+ metavar="PATH",
47
+ help="explicit config file (default lookup: ~/.config/coverart-cli/config.toml, "
48
+ "then ./coverart.toml)",
49
+ )
50
+ p.add_argument(
51
+ "--lastfm-key",
52
+ default=None,
53
+ help="Last.fm API key (or set $LASTFM_API_KEY). Get one at https://www.last.fm/api/account/create",
54
+ )
55
+ p.add_argument(
56
+ "--no-lastfm",
57
+ action="store_true",
58
+ help="disable Last.fm provider (skip it even if a key is given)",
59
+ )
60
+ p.add_argument(
61
+ "--no-itunes",
62
+ action="store_true",
63
+ help="disable Apple Music / iTunes Search provider",
64
+ )
65
+ p.add_argument(
66
+ "--no-deezer",
67
+ action="store_true",
68
+ help="disable Deezer provider",
69
+ )
70
+ p.add_argument(
71
+ "--no-musicbrainz",
72
+ action="store_true",
73
+ help="disable MusicBrainz / Cover Art Archive fallback",
74
+ )
75
+ p.add_argument(
76
+ "--user-agent",
77
+ default=DEFAULT_UA,
78
+ help="HTTP User-Agent (MusicBrainz requires contact info)",
79
+ )
80
+ p.add_argument(
81
+ "--no-embed",
82
+ action="store_true",
83
+ help="do not embed cover into audio file tags",
84
+ )
85
+ p.add_argument(
86
+ "--no-sidecar",
87
+ action="store_true",
88
+ help="do not write cover.jpg sidecar in album directory",
89
+ )
90
+ p.add_argument(
91
+ "--no-fallback-dirnames",
92
+ action="store_true",
93
+ help="do not fall back to artist/album dir names if tags are missing",
94
+ )
95
+ p.add_argument(
96
+ "--min-bytes",
97
+ type=int,
98
+ default=0,
99
+ metavar="N",
100
+ help="upgrade existing covers smaller than this many bytes "
101
+ "(applies to both sidecar and embedded; 0 = never replace, the default)",
102
+ )
103
+ p.add_argument(
104
+ "--workers",
105
+ type=int,
106
+ default=4,
107
+ metavar="N",
108
+ help="number of albums to process in parallel (default: 4, set 1 for serial)",
109
+ )
110
+ p.add_argument(
111
+ "--replace-smaller",
112
+ action="store_true",
113
+ help="when an existing cover is smaller than the newly fetched one, "
114
+ "replace it (default: keep larger existing)",
115
+ )
116
+ p.add_argument(
117
+ "--dry-run",
118
+ action="store_true",
119
+ help="show what would happen, write nothing",
120
+ )
121
+ p.add_argument(
122
+ "--missing-csv",
123
+ type=Path,
124
+ default=None,
125
+ help="path to write a CSV of albums for which no cover was found",
126
+ )
127
+ p.add_argument(
128
+ "--report-html",
129
+ type=Path,
130
+ default=None,
131
+ help="write a self-contained HTML report of library coverage to this path",
132
+ )
133
+ p.add_argument(
134
+ "--no-thumbs",
135
+ action="store_true",
136
+ help="when generating the HTML report, skip embedding cover thumbnails",
137
+ )
138
+ p.add_argument(
139
+ "--report-only",
140
+ action="store_true",
141
+ help="only generate the HTML report; do not fetch or modify anything",
142
+ )
143
+ p.add_argument("-v", "--verbose", action="count", default=0, help="-v for INFO, -vv for DEBUG")
144
+ p.add_argument("--version", action="version", version=f"coverart-cli {__version__}")
145
+ return p
146
+
147
+
148
+ def configure_logging(verbosity: int) -> None:
149
+ level = logging.WARNING
150
+ if verbosity == 1:
151
+ level = logging.INFO
152
+ elif verbosity >= 2:
153
+ level = logging.DEBUG
154
+ logging.basicConfig(level=level, format="%(message)s")
155
+
156
+
157
+ def main(argv: list[str] | None = None) -> int:
158
+ parser = build_parser()
159
+ args = parser.parse_args(argv)
160
+ configure_logging(args.verbose)
161
+
162
+ # Merge config file values into args where the user did not pass a flag.
163
+ # CLI-supplied (non-default) values always win.
164
+ cfg = load_config(args.config)
165
+ for key, value in cfg.items():
166
+ if not hasattr(args, key):
167
+ continue
168
+ current = getattr(args, key)
169
+ default = parser.get_default(key)
170
+ if current == default or current is None:
171
+ setattr(args, key, value)
172
+
173
+ # Environment variable takes precedence over config but is overridden by --lastfm-key.
174
+ if args.lastfm_key is None:
175
+ args.lastfm_key = os.environ.get("LASTFM_API_KEY") or cfg.get("lastfm_key")
176
+
177
+ if args.report_only:
178
+ return _do_report_only(args)
179
+
180
+ providers = []
181
+ if not args.no_lastfm and args.lastfm_key:
182
+ providers.append(LastFmProvider(args.lastfm_key, user_agent=args.user_agent))
183
+ elif not args.no_lastfm:
184
+ print(
185
+ "info: Last.fm skipped (no key); pass --lastfm-key or set $LASTFM_API_KEY"
186
+ " to enable it.",
187
+ file=sys.stderr,
188
+ )
189
+ if not args.no_itunes:
190
+ providers.append(ITunesProvider(user_agent=args.user_agent))
191
+ if not args.no_deezer:
192
+ providers.append(DeezerProvider(user_agent=args.user_agent))
193
+ if not args.no_musicbrainz:
194
+ providers.append(MusicBrainzProvider(user_agent=args.user_agent))
195
+
196
+ if not providers:
197
+ print("error: no providers enabled — pass at least one", file=sys.stderr)
198
+ return 2
199
+
200
+ opts = RunOptions(
201
+ root=args.root,
202
+ providers=providers,
203
+ do_embed=not args.no_embed,
204
+ do_sidecar=not args.no_sidecar,
205
+ dry_run=args.dry_run,
206
+ fallback_to_dirnames=not args.no_fallback_dirnames,
207
+ missing_csv=args.missing_csv,
208
+ min_sidecar_bytes=args.min_bytes,
209
+ min_embedded_bytes=args.min_bytes,
210
+ keep_larger_existing=not args.replace_smaller,
211
+ workers=args.workers,
212
+ )
213
+
214
+ try:
215
+ stats = run(opts)
216
+ except FileNotFoundError as e:
217
+ print(f"error: {e}", file=sys.stderr)
218
+ return 1
219
+
220
+ _print_summary(stats, dry_run=args.dry_run)
221
+
222
+ if args.report_html:
223
+ from coverart_cli.report import write_report
224
+
225
+ path, n = write_report(args.root, args.report_html, embed_thumbs=not args.no_thumbs)
226
+ print(f"\nHTML report ({n} albums) written to: {path}")
227
+ return 0
228
+
229
+
230
+ def _do_report_only(args) -> int:
231
+ from coverart_cli.report import write_report
232
+
233
+ if not args.report_html:
234
+ print("error: --report-only requires --report-html PATH", file=sys.stderr)
235
+ return 2
236
+ try:
237
+ path, n = write_report(args.root, args.report_html, embed_thumbs=not args.no_thumbs)
238
+ except FileNotFoundError as e:
239
+ print(f"error: {e}", file=sys.stderr)
240
+ return 1
241
+ print(f"HTML report ({n} albums) written to: {path}")
242
+ return 0
243
+
244
+
245
+ def _print_summary(stats: RunStats, *, dry_run: bool) -> None:
246
+ head = "=== DRY-RUN SUMMARY ===" if dry_run else "=== SUMMARY ==="
247
+ print()
248
+ print(head)
249
+ print(f" Albums scanned: {stats.albums_total}")
250
+ print(f" Sidecar already there: {stats.sidecar_already}")
251
+ for source, n in sorted(stats.fetched_from.items()):
252
+ print(f" Fetched from {source:15s} {n}")
253
+ print(f" Not found: {stats.not_found}")
254
+ if not dry_run:
255
+ print(f" Files newly embedded: {stats.files_embedded}")
256
+ print(f" Files already embedded: {stats.files_already_embedded}")
257
+ print(f" Errors: {stats.errors}")
coverart_cli/config.py ADDED
@@ -0,0 +1,91 @@
1
+ """Optional TOML config file for default CLI values.
2
+
3
+ Resolution order (later wins):
4
+ 1. Built-in defaults (from argparse)
5
+ 2. ~/.config/coverart-cli/config.toml (XDG: $XDG_CONFIG_HOME or ~/.config)
6
+ 3. ./coverart.toml (working directory)
7
+ 4. --config PATH (explicit)
8
+ 5. Command-line flags
9
+ 6. Environment variables (e.g. LASTFM_API_KEY)
10
+
11
+ Example file (all keys optional):
12
+
13
+ # ~/.config/coverart-cli/config.toml
14
+ lastfm_key = "your-key"
15
+ no_lastfm = false
16
+ no_itunes = false
17
+ no_deezer = false
18
+ no_musicbrainz = false
19
+ no_embed = false
20
+ no_sidecar = false
21
+ min_bytes = 30000
22
+ replace_smaller = true
23
+ user_agent = "coverart-cli (mailto:you@example.com)"
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import logging
28
+ import os
29
+ import tomllib
30
+ from pathlib import Path
31
+
32
+ log = logging.getLogger(__name__)
33
+
34
+ # Keys we accept in the config file — must match argparse `dest` names.
35
+ ALLOWED_KEYS: frozenset[str] = frozenset({
36
+ "lastfm_key",
37
+ "no_lastfm",
38
+ "no_itunes",
39
+ "no_deezer",
40
+ "no_musicbrainz",
41
+ "user_agent",
42
+ "no_embed",
43
+ "no_sidecar",
44
+ "no_fallback_dirnames",
45
+ "min_bytes",
46
+ "replace_smaller",
47
+ "workers",
48
+ "dry_run",
49
+ "missing_csv",
50
+ "report_html",
51
+ "no_thumbs",
52
+ "report_only",
53
+ })
54
+
55
+
56
+ def default_config_paths() -> list[Path]:
57
+ """Return the list of config paths to check, lowest-precedence first."""
58
+ paths: list[Path] = []
59
+ xdg = os.environ.get("XDG_CONFIG_HOME")
60
+ config_root = Path(xdg) if xdg else Path.home() / ".config"
61
+ paths.append(config_root / "coverart-cli" / "config.toml")
62
+ paths.append(Path.cwd() / "coverart.toml")
63
+ return paths
64
+
65
+
66
+ def load_config(explicit_path: Path | None = None) -> dict:
67
+ """Merge config from default locations (and the explicit path, if given).
68
+
69
+ Higher-precedence sources overwrite earlier ones. Unknown keys are dropped
70
+ with a warning so a typo doesn't silently change behaviour.
71
+ """
72
+ paths = default_config_paths()
73
+ if explicit_path is not None:
74
+ paths.append(explicit_path)
75
+
76
+ merged: dict = {}
77
+ for p in paths:
78
+ if not p.is_file():
79
+ continue
80
+ try:
81
+ with p.open("rb") as f:
82
+ data = tomllib.load(f)
83
+ except (OSError, tomllib.TOMLDecodeError) as e:
84
+ log.warning("ignoring config %s: %s", p, e)
85
+ continue
86
+ for key, value in data.items():
87
+ if key not in ALLOWED_KEYS:
88
+ log.warning("ignoring unknown config key %r in %s", key, p)
89
+ continue
90
+ merged[key] = value
91
+ return merged