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.
- coverart_cli/__init__.py +47 -0
- coverart_cli/__main__.py +4 -0
- coverart_cli/cli.py +257 -0
- coverart_cli/config.py +91 -0
- coverart_cli/core.py +320 -0
- coverart_cli/providers/__init__.py +15 -0
- coverart_cli/providers/base.py +73 -0
- coverart_cli/providers/deezer.py +48 -0
- coverart_cli/providers/itunes.py +59 -0
- coverart_cli/providers/lastfm.py +72 -0
- coverart_cli/providers/musicbrainz.py +78 -0
- coverart_cli/py.typed +0 -0
- coverart_cli/report.py +180 -0
- coverart_cli/tagging.py +230 -0
- coverart_cli/templates/report.html +450 -0
- coverart_cli-0.4.0.dist-info/METADATA +160 -0
- coverart_cli-0.4.0.dist-info/RECORD +20 -0
- coverart_cli-0.4.0.dist-info/WHEEL +4 -0
- coverart_cli-0.4.0.dist-info/entry_points.txt +2 -0
- coverart_cli-0.4.0.dist-info/licenses/LICENSE +21 -0
coverart_cli/__init__.py
ADDED
|
@@ -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
|
+
]
|
coverart_cli/__main__.py
ADDED
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
|