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()