trackfetch 2.0.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.
trackfetch.py
ADDED
|
@@ -0,0 +1,710 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import tempfile
|
|
12
|
+
import time
|
|
13
|
+
import unicodedata
|
|
14
|
+
from difflib import SequenceMatcher
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import requests
|
|
18
|
+
import spotipy
|
|
19
|
+
from mutagen.id3 import (
|
|
20
|
+
APIC,
|
|
21
|
+
ID3,
|
|
22
|
+
TALB,
|
|
23
|
+
TDRC,
|
|
24
|
+
TIT2,
|
|
25
|
+
TPE1,
|
|
26
|
+
TPE2,
|
|
27
|
+
TRCK,
|
|
28
|
+
)
|
|
29
|
+
from spotipy.oauth2 import SpotifyClientCredentials
|
|
30
|
+
|
|
31
|
+
YTDLP_REMOTE_COMPONENTS = "ejs:github"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def normalize(text: str) -> str:
|
|
35
|
+
text = unicodedata.normalize("NFKD", text).lower()
|
|
36
|
+
text = re.sub(r"[^\w\s]", " ", text)
|
|
37
|
+
text = re.sub(r"\s+", " ", text)
|
|
38
|
+
return text.strip()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def similarity(a: str, b: str) -> float:
|
|
42
|
+
return SequenceMatcher(
|
|
43
|
+
None,
|
|
44
|
+
normalize(a),
|
|
45
|
+
normalize(b)
|
|
46
|
+
).ratio()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def sanitize_filename(name: str) -> str:
|
|
50
|
+
name = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", name)
|
|
51
|
+
name = re.sub(r"\s+", " ", name).strip()
|
|
52
|
+
|
|
53
|
+
return name[:200]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def parse_song(line: str):
|
|
57
|
+
if " - " not in line:
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
artist, title = line.split(" - ", 1)
|
|
61
|
+
|
|
62
|
+
artist = artist.strip()
|
|
63
|
+
title = title.strip()
|
|
64
|
+
|
|
65
|
+
if not artist or not title:
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
return artist, title
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def get_spotify():
|
|
72
|
+
client_id = os.getenv("SPOTIFY_CLIENT_ID")
|
|
73
|
+
client_secret = os.getenv("SPOTIFY_CLIENT_SECRET")
|
|
74
|
+
|
|
75
|
+
if not client_id or not client_secret:
|
|
76
|
+
print("\nERROR: Spotify credentials not found.")
|
|
77
|
+
print("Set:")
|
|
78
|
+
print("export SPOTIFY_CLIENT_ID='...'")
|
|
79
|
+
print("export SPOTIFY_CLIENT_SECRET='...'")
|
|
80
|
+
sys.exit(1)
|
|
81
|
+
|
|
82
|
+
return spotipy.Spotify(
|
|
83
|
+
auth_manager=SpotifyClientCredentials(
|
|
84
|
+
client_id=client_id,
|
|
85
|
+
client_secret=client_secret,
|
|
86
|
+
),
|
|
87
|
+
requests_timeout=30,
|
|
88
|
+
retries=3,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def find_spotify_track(sp, artist: str, title: str):
|
|
93
|
+
query = f'artist:"{artist}" track:"{title}"'
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
results = sp.search(
|
|
97
|
+
q=query,
|
|
98
|
+
type="track",
|
|
99
|
+
limit=10,
|
|
100
|
+
)
|
|
101
|
+
except Exception as error:
|
|
102
|
+
print(f"WARNING: Spotify search error: {error}")
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
tracks = results.get("tracks", {}).get("items", [])
|
|
106
|
+
|
|
107
|
+
if not tracks:
|
|
108
|
+
try:
|
|
109
|
+
results = sp.search(
|
|
110
|
+
q=f"{artist} {title}",
|
|
111
|
+
type="track",
|
|
112
|
+
limit=10,
|
|
113
|
+
)
|
|
114
|
+
tracks = results.get("tracks", {}).get("items", [])
|
|
115
|
+
except Exception:
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
if not tracks:
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
best_track = None
|
|
122
|
+
best_score = -1.0
|
|
123
|
+
|
|
124
|
+
for track in tracks:
|
|
125
|
+
spotify_title = track["name"]
|
|
126
|
+
spotify_artists = " ".join(
|
|
127
|
+
item["name"]
|
|
128
|
+
for item in track["artists"]
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
title_score = similarity(title, spotify_title)
|
|
132
|
+
artist_score = similarity(artist, spotify_artists)
|
|
133
|
+
|
|
134
|
+
score = title_score * 0.70 + artist_score * 0.30
|
|
135
|
+
|
|
136
|
+
if score > best_score:
|
|
137
|
+
best_score = score
|
|
138
|
+
best_track = track
|
|
139
|
+
|
|
140
|
+
print(f"Spotify match score: {best_score:.2f}")
|
|
141
|
+
|
|
142
|
+
return best_track
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def get_track_metadata(track, fallback_artist, fallback_title):
|
|
146
|
+
if track is None:
|
|
147
|
+
return {
|
|
148
|
+
"artist": fallback_artist,
|
|
149
|
+
"title": fallback_title,
|
|
150
|
+
"album": "",
|
|
151
|
+
"album_artist": fallback_artist,
|
|
152
|
+
"track_number": None,
|
|
153
|
+
"release_date": None,
|
|
154
|
+
"cover_url": None,
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
artist = ", ".join(
|
|
158
|
+
item["name"]
|
|
159
|
+
for item in track["artists"]
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
album = track.get("album", {})
|
|
163
|
+
|
|
164
|
+
album_artist = ", ".join(
|
|
165
|
+
item["name"]
|
|
166
|
+
for item in album.get("artists", [])
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
images = album.get("images", [])
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
"artist": artist,
|
|
173
|
+
"title": track["name"],
|
|
174
|
+
"album": album.get("name", ""),
|
|
175
|
+
"album_artist": album_artist or artist,
|
|
176
|
+
"track_number": track.get("track_number"),
|
|
177
|
+
"release_date": album.get("release_date"),
|
|
178
|
+
"cover_url": images[0]["url"] if images else None,
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def download_cover(url: str, destination: Path):
|
|
183
|
+
response = requests.get(
|
|
184
|
+
url,
|
|
185
|
+
timeout=30,
|
|
186
|
+
headers={
|
|
187
|
+
"User-Agent": "trackfetch/2.0"
|
|
188
|
+
},
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
response.raise_for_status()
|
|
192
|
+
|
|
193
|
+
destination.write_bytes(response.content)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def youtube_search(query: str, limit: int = 5):
|
|
197
|
+
command = [
|
|
198
|
+
"yt-dlp",
|
|
199
|
+
"--remote-components",
|
|
200
|
+
YTDLP_REMOTE_COMPONENTS,
|
|
201
|
+
"--flat-playlist",
|
|
202
|
+
"--dump-json",
|
|
203
|
+
f"ytsearch{limit}:{query}",
|
|
204
|
+
]
|
|
205
|
+
|
|
206
|
+
result = subprocess.run(
|
|
207
|
+
command,
|
|
208
|
+
capture_output=True,
|
|
209
|
+
text=True,
|
|
210
|
+
encoding="utf-8",
|
|
211
|
+
errors="replace",
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
videos = []
|
|
215
|
+
|
|
216
|
+
for line in result.stdout.splitlines():
|
|
217
|
+
try:
|
|
218
|
+
data = json.loads(line)
|
|
219
|
+
videos.append(data)
|
|
220
|
+
except json.JSONDecodeError:
|
|
221
|
+
pass
|
|
222
|
+
|
|
223
|
+
return videos
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def score_youtube_result(
|
|
227
|
+
video,
|
|
228
|
+
artist: str,
|
|
229
|
+
title: str,
|
|
230
|
+
):
|
|
231
|
+
video_title = video.get("title", "")
|
|
232
|
+
|
|
233
|
+
title_score = similarity(title, video_title)
|
|
234
|
+
|
|
235
|
+
artist_score = similarity(artist, video_title)
|
|
236
|
+
|
|
237
|
+
score = (
|
|
238
|
+
title_score * 0.70
|
|
239
|
+
+ artist_score * 0.30
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
normalized_video = normalize(video_title)
|
|
243
|
+
|
|
244
|
+
if "official audio" in normalized_video:
|
|
245
|
+
score += 0.08
|
|
246
|
+
|
|
247
|
+
if "audio" in normalized_video:
|
|
248
|
+
score += 0.03
|
|
249
|
+
|
|
250
|
+
bad_words = [
|
|
251
|
+
"cover",
|
|
252
|
+
"караоке",
|
|
253
|
+
"karaoke",
|
|
254
|
+
"nightcore",
|
|
255
|
+
"sped up",
|
|
256
|
+
"slowed",
|
|
257
|
+
"remix",
|
|
258
|
+
"reaction",
|
|
259
|
+
]
|
|
260
|
+
|
|
261
|
+
for word in bad_words:
|
|
262
|
+
if word in normalized_video:
|
|
263
|
+
score -= 0.20
|
|
264
|
+
|
|
265
|
+
return score
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def find_youtube_video(artist: str, title: str):
|
|
269
|
+
query = f"{artist} {title}"
|
|
270
|
+
|
|
271
|
+
print(f"Searching YouTube: {query}")
|
|
272
|
+
|
|
273
|
+
videos = youtube_search(query, limit=5)
|
|
274
|
+
|
|
275
|
+
if not videos:
|
|
276
|
+
return None
|
|
277
|
+
|
|
278
|
+
best_video = None
|
|
279
|
+
best_score = -999.0
|
|
280
|
+
|
|
281
|
+
for video in videos:
|
|
282
|
+
score = score_youtube_result(
|
|
283
|
+
video,
|
|
284
|
+
artist,
|
|
285
|
+
title,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
print(
|
|
289
|
+
f" YouTube score {score:.2f}: "
|
|
290
|
+
f"{video.get('title', 'Unknown')}"
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
if score > best_score:
|
|
294
|
+
best_score = score
|
|
295
|
+
best_video = video
|
|
296
|
+
|
|
297
|
+
if best_video:
|
|
298
|
+
print(
|
|
299
|
+
f"Selected: "
|
|
300
|
+
f"{best_video.get('title', 'Unknown')}"
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
return best_video
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def download_audio(
|
|
307
|
+
video_url: str,
|
|
308
|
+
temp_dir: Path,
|
|
309
|
+
):
|
|
310
|
+
template = str(temp_dir / "audio.%(ext)s")
|
|
311
|
+
|
|
312
|
+
command = [
|
|
313
|
+
"yt-dlp",
|
|
314
|
+
"-x",
|
|
315
|
+
"--audio-format",
|
|
316
|
+
"mp3",
|
|
317
|
+
"--audio-quality",
|
|
318
|
+
"0",
|
|
319
|
+
"--remote-components",
|
|
320
|
+
YTDLP_REMOTE_COMPONENTS,
|
|
321
|
+
"--no-playlist",
|
|
322
|
+
"-o",
|
|
323
|
+
template,
|
|
324
|
+
video_url,
|
|
325
|
+
]
|
|
326
|
+
|
|
327
|
+
result = subprocess.run(command)
|
|
328
|
+
|
|
329
|
+
if result.returncode != 0:
|
|
330
|
+
return None
|
|
331
|
+
|
|
332
|
+
files = list(temp_dir.glob("audio.mp3"))
|
|
333
|
+
|
|
334
|
+
if not files:
|
|
335
|
+
return None
|
|
336
|
+
|
|
337
|
+
return files[0]
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def add_metadata(
|
|
341
|
+
mp3_path: Path,
|
|
342
|
+
cover_path: Path,
|
|
343
|
+
metadata: dict,
|
|
344
|
+
):
|
|
345
|
+
try:
|
|
346
|
+
audio = ID3(mp3_path)
|
|
347
|
+
except Exception:
|
|
348
|
+
audio = ID3()
|
|
349
|
+
|
|
350
|
+
for tag in [
|
|
351
|
+
"APIC",
|
|
352
|
+
"TIT2",
|
|
353
|
+
"TPE1",
|
|
354
|
+
"TALB",
|
|
355
|
+
"TPE2",
|
|
356
|
+
"TRCK",
|
|
357
|
+
"TDRC",
|
|
358
|
+
]:
|
|
359
|
+
audio.delall(tag)
|
|
360
|
+
|
|
361
|
+
audio.add(
|
|
362
|
+
TIT2(
|
|
363
|
+
encoding=3,
|
|
364
|
+
text=metadata["title"],
|
|
365
|
+
)
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
audio.add(
|
|
369
|
+
TPE1(
|
|
370
|
+
encoding=3,
|
|
371
|
+
text=metadata["artist"],
|
|
372
|
+
)
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
if metadata["album"]:
|
|
376
|
+
audio.add(
|
|
377
|
+
TALB(
|
|
378
|
+
encoding=3,
|
|
379
|
+
text=metadata["album"],
|
|
380
|
+
)
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
if metadata["album_artist"]:
|
|
384
|
+
audio.add(
|
|
385
|
+
TPE2(
|
|
386
|
+
encoding=3,
|
|
387
|
+
text=metadata["album_artist"],
|
|
388
|
+
)
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
if metadata["track_number"]:
|
|
392
|
+
audio.add(
|
|
393
|
+
TRCK(
|
|
394
|
+
encoding=3,
|
|
395
|
+
text=str(metadata["track_number"]),
|
|
396
|
+
)
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
if metadata["release_date"]:
|
|
400
|
+
audio.add(
|
|
401
|
+
TDRC(
|
|
402
|
+
encoding=3,
|
|
403
|
+
text=str(metadata["release_date"]),
|
|
404
|
+
)
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
if cover_path.exists():
|
|
408
|
+
image_data = cover_path.read_bytes()
|
|
409
|
+
|
|
410
|
+
audio.add(
|
|
411
|
+
APIC(
|
|
412
|
+
encoding=3,
|
|
413
|
+
mime="image/jpeg",
|
|
414
|
+
type=3,
|
|
415
|
+
desc="Cover",
|
|
416
|
+
data=image_data,
|
|
417
|
+
)
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
audio.save(
|
|
421
|
+
mp3_path,
|
|
422
|
+
v2_version=3,
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def make_filename(metadata, title_only):
|
|
427
|
+
if title_only:
|
|
428
|
+
name = metadata["title"]
|
|
429
|
+
else:
|
|
430
|
+
name = (
|
|
431
|
+
f"{metadata['artist']} - "
|
|
432
|
+
f"{metadata['title']}"
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
return sanitize_filename(name) + ".mp3"
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def read_done(done_file: Path):
|
|
439
|
+
if not done_file.exists():
|
|
440
|
+
return set()
|
|
441
|
+
|
|
442
|
+
return {
|
|
443
|
+
line.strip()
|
|
444
|
+
for line in done_file.read_text(
|
|
445
|
+
encoding="utf-8",
|
|
446
|
+
errors="replace",
|
|
447
|
+
).splitlines()
|
|
448
|
+
if line.strip()
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def append_line(path: Path, text: str):
|
|
453
|
+
with path.open(
|
|
454
|
+
"a",
|
|
455
|
+
encoding="utf-8",
|
|
456
|
+
) as file:
|
|
457
|
+
file.write(text + "\n")
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def process_song(
|
|
461
|
+
sp,
|
|
462
|
+
input_artist,
|
|
463
|
+
input_title,
|
|
464
|
+
output,
|
|
465
|
+
title_only,
|
|
466
|
+
):
|
|
467
|
+
track = find_spotify_track(
|
|
468
|
+
sp,
|
|
469
|
+
input_artist,
|
|
470
|
+
input_title,
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
metadata = get_track_metadata(
|
|
474
|
+
track,
|
|
475
|
+
input_artist,
|
|
476
|
+
input_title,
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
print(
|
|
480
|
+
f"Metadata: "
|
|
481
|
+
f"{metadata['artist']} - "
|
|
482
|
+
f"{metadata['title']}"
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
filename = make_filename(
|
|
486
|
+
metadata,
|
|
487
|
+
title_only,
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
final_path = output / filename
|
|
491
|
+
|
|
492
|
+
if final_path.exists():
|
|
493
|
+
print(f"SKIP: already exists: {filename}")
|
|
494
|
+
return True, "exists"
|
|
495
|
+
|
|
496
|
+
video = find_youtube_video(
|
|
497
|
+
metadata["artist"],
|
|
498
|
+
metadata["title"],
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
if not video:
|
|
502
|
+
print("FAILED: No YouTube result")
|
|
503
|
+
return False, "no YouTube result"
|
|
504
|
+
|
|
505
|
+
video_url = (
|
|
506
|
+
video.get("webpage_url")
|
|
507
|
+
or video.get("url")
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
if not video_url:
|
|
511
|
+
video_id = video.get("id")
|
|
512
|
+
|
|
513
|
+
if video_id:
|
|
514
|
+
video_url = (
|
|
515
|
+
"https://www.youtube.com/watch?v="
|
|
516
|
+
+ video_id
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
if not video_url:
|
|
520
|
+
print("FAILED: Invalid YouTube result")
|
|
521
|
+
return False, "invalid YouTube result"
|
|
522
|
+
|
|
523
|
+
with tempfile.TemporaryDirectory() as temp:
|
|
524
|
+
temp_dir = Path(temp)
|
|
525
|
+
|
|
526
|
+
mp3_path = download_audio(
|
|
527
|
+
video_url,
|
|
528
|
+
temp_dir,
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
if not mp3_path:
|
|
532
|
+
return False, "yt-dlp download failed"
|
|
533
|
+
|
|
534
|
+
cover_path = temp_dir / "cover.jpg"
|
|
535
|
+
|
|
536
|
+
if metadata["cover_url"]:
|
|
537
|
+
try:
|
|
538
|
+
download_cover(
|
|
539
|
+
metadata["cover_url"],
|
|
540
|
+
cover_path,
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
print("Spotify cover downloaded")
|
|
544
|
+
|
|
545
|
+
except Exception as error:
|
|
546
|
+
print(
|
|
547
|
+
f"WARNING: cover download failed: "
|
|
548
|
+
f"{error}"
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
try:
|
|
552
|
+
add_metadata(
|
|
553
|
+
mp3_path,
|
|
554
|
+
cover_path,
|
|
555
|
+
metadata,
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
except Exception as error:
|
|
559
|
+
return False, f"metadata error: {error}"
|
|
560
|
+
|
|
561
|
+
try:
|
|
562
|
+
shutil.move(
|
|
563
|
+
str(mp3_path),
|
|
564
|
+
str(final_path),
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
except Exception as error:
|
|
568
|
+
return False, f"move error: {error}"
|
|
569
|
+
|
|
570
|
+
print(f"SUCCESS: {filename}")
|
|
571
|
+
|
|
572
|
+
return True, "downloaded"
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def main():
|
|
576
|
+
parser = argparse.ArgumentParser(
|
|
577
|
+
description=(
|
|
578
|
+
"Download music using Spotify metadata "
|
|
579
|
+
"and YouTube audio"
|
|
580
|
+
)
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
parser.add_argument(
|
|
584
|
+
"input",
|
|
585
|
+
type=Path,
|
|
586
|
+
help="Text file with Artist - Title",
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
parser.add_argument(
|
|
590
|
+
"-o",
|
|
591
|
+
"--output",
|
|
592
|
+
type=Path,
|
|
593
|
+
default=Path.home() / "Music" / "trackfetch",
|
|
594
|
+
help="Output directory",
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
parser.add_argument(
|
|
598
|
+
"--title-only",
|
|
599
|
+
action="store_true",
|
|
600
|
+
help="Use only title in filename",
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
parser.add_argument(
|
|
604
|
+
"--delay",
|
|
605
|
+
type=float,
|
|
606
|
+
default=1.0,
|
|
607
|
+
help="Delay between songs in seconds",
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
args = parser.parse_args()
|
|
611
|
+
|
|
612
|
+
if not args.input.exists():
|
|
613
|
+
print(f"ERROR: Input file not found: {args.input}")
|
|
614
|
+
sys.exit(1)
|
|
615
|
+
|
|
616
|
+
args.output.mkdir(
|
|
617
|
+
parents=True,
|
|
618
|
+
exist_ok=True,
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
done_file = args.output / "done.txt"
|
|
622
|
+
failed_file = args.output / "failed.txt"
|
|
623
|
+
|
|
624
|
+
songs = []
|
|
625
|
+
|
|
626
|
+
for line in args.input.read_text(
|
|
627
|
+
encoding="utf-8",
|
|
628
|
+
errors="replace",
|
|
629
|
+
).splitlines():
|
|
630
|
+
|
|
631
|
+
line = line.strip()
|
|
632
|
+
|
|
633
|
+
if not line or line.startswith("#"):
|
|
634
|
+
continue
|
|
635
|
+
|
|
636
|
+
song = parse_song(line)
|
|
637
|
+
|
|
638
|
+
if song:
|
|
639
|
+
songs.append(song)
|
|
640
|
+
|
|
641
|
+
done = read_done(done_file)
|
|
642
|
+
|
|
643
|
+
print(f"Found {len(songs)} songs")
|
|
644
|
+
print(f"Already completed: {len(done)}")
|
|
645
|
+
print(f"Output: {args.output}")
|
|
646
|
+
|
|
647
|
+
sp = get_spotify()
|
|
648
|
+
|
|
649
|
+
success = 0
|
|
650
|
+
skipped = 0
|
|
651
|
+
failed = 0
|
|
652
|
+
|
|
653
|
+
for number, (artist, title) in enumerate(
|
|
654
|
+
songs,
|
|
655
|
+
start=1,
|
|
656
|
+
):
|
|
657
|
+
song_key = f"{artist} - {title}"
|
|
658
|
+
|
|
659
|
+
print()
|
|
660
|
+
print("=" * 60)
|
|
661
|
+
print(f"[{number}/{len(songs)}]")
|
|
662
|
+
print(song_key)
|
|
663
|
+
|
|
664
|
+
if song_key in done:
|
|
665
|
+
print("SKIP: already completed")
|
|
666
|
+
skipped += 1
|
|
667
|
+
continue
|
|
668
|
+
|
|
669
|
+
ok, reason = process_song(
|
|
670
|
+
sp,
|
|
671
|
+
artist,
|
|
672
|
+
title,
|
|
673
|
+
args.output,
|
|
674
|
+
args.title_only,
|
|
675
|
+
)
|
|
676
|
+
|
|
677
|
+
if ok:
|
|
678
|
+
append_line(
|
|
679
|
+
done_file,
|
|
680
|
+
song_key,
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
success += 1
|
|
684
|
+
|
|
685
|
+
if reason == "exists":
|
|
686
|
+
skipped += 1
|
|
687
|
+
|
|
688
|
+
else:
|
|
689
|
+
append_line(
|
|
690
|
+
failed_file,
|
|
691
|
+
f"{song_key} | {reason}",
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
failed += 1
|
|
695
|
+
|
|
696
|
+
if number < len(songs):
|
|
697
|
+
time.sleep(args.delay)
|
|
698
|
+
|
|
699
|
+
print()
|
|
700
|
+
print("=" * 60)
|
|
701
|
+
print("FINISHED")
|
|
702
|
+
print(f"SUCCESS: {success}")
|
|
703
|
+
print(f"SKIPPED: {skipped}")
|
|
704
|
+
print(f"FAILED: {failed}")
|
|
705
|
+
print(f"DONE FILE: {done_file}")
|
|
706
|
+
print(f"FAILED FILE: {failed_file}")
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
if __name__ == "__main__":
|
|
710
|
+
main()
|