-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathproject_documentation.html
More file actions
698 lines (608 loc) · 31.2 KB
/
Copy pathproject_documentation.html
File metadata and controls
698 lines (608 loc) · 31.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>AlphaDEX (formerly SoundVault) Project Documentation</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; background: #f7f7f7; color: #333; }
h1 { color: #2c3e50; }
h2 { color: #34495e; }
h3 { color: #4a6fa5; }
pre { background: #ecf0f1; padding: 10px; border: 1px solid #bdc3c7; overflow-x: auto; }
code { font-family: "Courier New", monospace; }
hr { border: 0; border-top: 1px solid #ccc; margin: 30px 0; }
</style>
</head>
<body>
<h1>AlphaDEX (formerly SoundVault) Project Documentation</h1>
<h2>1. Introduction</h2>
<p>This document compiles all work done so far on the AlphaDEX (formerly SoundVault) project, including:</p>
<ul>
<li>Detailed descriptions of each program created.</li>
<li>Current progress and status of the system.</li>
<li>Future roadmap and next steps to complete the project.</li>
</ul>
<hr>
<h2>2. Programs Created So Far</h2>
<h3>2.1 Music Indexer (<code>music_indexer_api.py</code>)</h3>
<p>A comprehensive Python-based indexer to organize a music library. It:</p>
<ul>
<li>Scans folders recursively for audio files.</li>
<li>Reads metadata: artist, title, album, year, track number, embedded cover art.</li>
<li>Separates workflow into deterministic phases: vault pre-scan (artist counts), metadata aggregation, destination routing, and preview generation.</li>
<li>Surfaces every routing decision in <code>decision_log</code> and a tag index to drive consistent HTML previews and downstream plan rendering.</li>
<li><strong>Phase 0</strong>: Pre-scan the vault under MUSIC_ROOT to build a global artist-count map via <code>build_primary_counts()</code>.</li>
<li>Skips <code>Not Sorted/</code> and <code>Playlists/</code> directories during scans so you can exclude files from indexing.</li>
<li>Routes missing-metadata tracks into <code>Manual Review/</code> for later cleanup.</li>
<li>Implements grouping rules:
<ul>
<li>Common artist threshold (≥ 10 tracks) → <code>By Artist/<Artist></code>.</li>
<li>Rare artists → <code>By Year/<Year></code>.</li>
<li>Album folder if ≥ 5 tracks; otherwise, singles grouping.</li>
<li>“(Remixes)” folder if an album has ≥ 3 remixes.</li>
</ul>
</li>
<li>Generates a “dry-run” HTML preview of the proposed reorganization (color-coded) at <code>Docs/MusicIndex.html</code>.</li>
<li>Applies the final moves: creates new folders, renames/moves audio files, updates canonical primary-artist metadata, and archives leftovers into <code>Docs/</code> or <code>Trash/</code>, then deletes empty folders (two passes to also remove now-empty parents).</li>
<li>Writes <code>Docs/indexer_log.txt</code> detailing exactly how each file was classified and routed.</li>
<li><strong>Note:</strong> the indexer does not deduplicate; duplicate review lives in the Duplicate Finder workflow.</li>
</ul>
<p><strong>Skeleton of <code>music_indexer_api.py</code> (comments only):</strong></p>
<pre><code>
import os
import re
import shutil
import hashlib
from collections import defaultdict
from mutagen import File as MutagenFile
# Configuration
COMMON_ARTIST_THRESHOLD = 10
REMIX_FOLDER_THRESHOLD = 3
def sanitize(name: str) -> str:
# Remove invalid filename characters...
pass
def collapse_repeats(name: str) -> str:
# Collapse repeated substrings (e.g. "ARTISTARTIST" → "ARTIST")
pass
def extract_primary_and_collabs(raw_artist: str):
# Parse "Artist/Feat. Other" or "Artist & Co." into primary + collaborator list
pass
def compute_moves_and_tag_index(root_path, log_callback=None):
# 0. Pre-scan entire vault under MUSIC_ROOT -> build_primary_counts()
# 1. Decide music_root (either root_path or root_path/Music).
# 2. Walk recursively, collect supported audio files (skip Not Sorted/Playlists).
# 3. Read metadata for each track; build counts:
# • album_counts: number of tracks per (artist, album)
# • remix_counts: number of remix-tagged tracks per (artist, album)
# • album_disc_values: disc values per (artist, album)
# 4. For each file, decide destination:
# • If missing core tags → Manual Review/
# • If rare artist → By Year/<Year>
# • Else apply album/singles/remix grouping rules
# 5. Build a moves dict mapping old_path → new_path, plus a tag_index for HTML preview.
# 6. Return (moves, tag_index, decision_log).
pass
def build_dry_run_html(root_path, output_html_path, log_callback=None):
# Calls compute_moves_and_tag_index; uses tag_index to write an HTML
# showing old vs. new locations, color-coded.
pass
def apply_indexer_moves(root_path, log_callback=None):
# Actually performs the file moves:
# 1. Creates new directories as needed.
# 2. Moves and renames audio files.
# 3. Updates canonical primary-artist metadata where possible.
# 4. Moves docs (*.txt, *.html, *.db) into "Docs/" and other leftovers into "Trash/".
# 5. Deletes empty source folders in two passes so parent directories left
# empty after their children are removed are also deleted.
pass
def run_full_indexer(root_path, output_html_path, dry_run_only=False, log_callback=None):
# High-level driver:
# 1. Compute moves + tag_index
# 2. Write Docs/indexer_log.txt
# 3. build_dry_run_html(...)
# 4. If not dry_run_only, call apply_indexer_moves(...)
# 5. Optionally generate playlists under Playlists/
pass
</code></pre>
<hr>
<h3>2.2 Duplicate Finder (Redesigned)</h3>
<p>A review-first duplicate consolidation workflow launched from the <strong>Duplicate Finder</strong> tab:</p>
<ul>
<li>Scans the library, fingerprints tracks, and groups exact/near duplicates using configurable thresholds.</li>
<li>Generates preview artifacts (<code>Docs/duplicate_preview.json</code> and <code>Docs/duplicate_preview.html</code>) so users can inspect every group before execution.</li>
<li>Allows per-group dispositions (retain, quarantine, delete) while keeping a global default for most groups.</li>
<li>Supports playlist rewrites during execution, with all changes logged in <code>Docs/duplicate_execution_reports/</code>.</li>
<li>Blocks execution when review-required groups are present unless the override is explicitly enabled.</li>
<li>Exposes threshold tuning for fingerprint offsets, durations, silence trimming, and mixed-codec matching.</li>
</ul>
<h3>2.3 Similarity Inspector</h3>
<p>Diagnostic tool under <strong>Tools → Similarity Inspector…</strong> for analyzing two audio files with the Duplicate Finder fingerprint pipeline:</p>
<ul>
<li>Compares two selected tracks and reports codec metadata, raw fingerprint distance, and match verdicts.</li>
<li>Allows advanced overrides for fingerprint windows, silence trimming, and duplicate thresholds.</li>
<li>Applies mixed-codec threshold boosts to explain borderline comparisons.</li>
<li>Writes timestamped text reports to <code>Docs/</code> in the selected library.</li>
</ul>
<hr>
<h3>2.5 Library Sync Review Tool (<code>library_sync_review.py</code> / <code>library_sync.py</code>)</h3>
<p>Review-first workflow with a two-list UI plus optional plan/preview/execute actions:</p>
<ul>
<li>Runs independent scans for Library and Incoming folders, reusing cached fingerprints when size/mtime match and computing new prints in parallel when needed.</li>
<li>Builds normalized <code>TrackRecord</code> entries (path, ext, bitrate, size, mtime, duration, fingerprint, tags) and matches via indexed shortlists: fingerprint signature → extension cohort → fallback pool.</li>
<li>Classifies each incoming track as <em>New</em>, <em>Collision</em>, <em>Exact Match</em>, or <em>Low Confidence</em>, and attaches quality labels (<em>Potential Upgrade</em> / <em>Keep Existing</em>) using format priority and bitrate.</li>
<li>Exposes full <code>MatchResult</code> payloads with shortlist distances, thresholds used, near-miss margins, and confidence scores so the UI can render detailed inspectors without extra computation.</li>
<li>The UI provides Incoming/Existing lists, a text inspector, log export, and plan controls (Build Plan, Preview, Execute) with a transfer-mode toggle and optional playlist output.</li>
<li>Optional profiling (<code>PerformanceProfile</code>) tracks cache hits, fingerprints computed, shortlist sizes, and scan/match timings for troubleshooting.</li>
<li>Opt-in execution path: <code>compute_library_sync_plan()</code> remaps indexer-derived destinations under the chosen library root and emits deterministic plan items (<em>COPY</em> / <em>REPLACE</em> / <em>SKIP</em> with reasons). <code>build_library_sync_preview()</code> renders the HTML preview, and <code>execute_library_sync_plan()</code> writes JSON audits + executed HTML reports, creates a playlist of transferred files, and backs up any replaced destination before writing.</li>
<li><strong>Current gaps:</strong> per-item copy/replace flags and report-export helpers exist in code, but the UI does not expose them yet.</li>
</ul>
<hr>
<h3>2.2 Fingerprint Generator (<code>fingerprint_generator.py</code>)</h3>
<p>Walks a folder tree and stores AcoustID fingerprints for each audio file in a SQLite database.</p>
<pre><code>
import os, sqlite3
import acoustid
SUPPORTED_EXTS = {".flac", ".m4a", ".aac", ".mp3", ".wav", ".ogg"}
def compute_fingerprints(root_path, db_path, log_callback=None):
if log_callback is None:
log_callback = lambda msg: None
os.makedirs(os.path.dirname(db_path), exist_ok=True)
conn = sqlite3.connect(db_path)
conn.execute("""CREATE TABLE IF NOT EXISTS fingerprints(
path TEXT PRIMARY KEY,
duration INT,
fingerprint TEXT
);""")
...
</code></pre>
<hr>
<h3>2.3 Fingerprint Cache (<code>fingerprint_cache.py</code>)</h3>
<p>Caches AcoustID fingerprints in a SQLite database so repeated scans run much
faster. Schema upgrades are handled automatically.</p>
<hr>
<h3>2.4 Genre Updater via MusicBrainz (<code>update_genres.py</code>)</h3>
<p>Walks your music folder, reads each file’s <code>artist</code> and <code>title</code>, queries MusicBrainz for a matching recording, grabs the top 3 tags by popularity, and writes them back into each file’s <code>genre</code> field. Logs all actions into <code>genre_update_log.txt</code>. Rate-limited at 1 request/second.</p>
<pre><code>
#!/usr/bin/env python3
"""
update_genres.py
Folder picker → for each audio file:
1. Extract (artist, title) metadata (via Mutagen).
2. If missing artist/title, skip.
3. Query MusicBrainz: search_recordings(recording=title, artist=artist, limit=1, strict=True).
4. If no match, log “no MB match.”
5. Else, get recording's "tag-list" (list of {name, count}).
6. Pick up to top 3 by descending count (or first if no counts).
7. If file already has ≥ 2 genres, skip.
8. Write [genre1, genre2, genre3] back into file (EasyID3 or FLAC).
9. Wait 1 second (RATE_LIMIT_SECONDS) between calls.
10. Log success/failure.
Requires: pip install mutagen musicbrainzngs
"""
import os
import sys
import time
import tkinter as tk
from tkinter import filedialog
import musicbrainzngs
from mutagen import File as MutagenFile
from mutagen.easyid3 import EasyID3
from mutagen.flac import FLAC
# Configuration
musicbrainzngs.set_useragent(
"AlphaDEXGenreUpdater",
"1.0",
"youremail@example.com"
)
RATE_LIMIT_SECONDS = 1.0
SUPPORTED_EXTS = {".mp3", ".flac", ".m4a", ".aac", ".wav", ".ogg"}
def choose_top_genres(mb_tag_list, top_n=3):
# Filter out entries missing "name". Assign missing 'count'=0.
valid = [t for t in mb_tag_list if "name" in t and t["name"].strip()]
if not valid:
return []
for t in valid:
if not isinstance(t.get("count"), int):
t["count"] = 0
# Sort by (-count, name.lower())
sorted_tags = sorted(valid, key=lambda t: (-t["count"], t["name"].lower()))
return [t["name"].strip() for t in sorted_tags[:top_n]]
def extract_artist_title(filepath):
ext = os.path.splitext(filepath)[1].lower()
try:
if ext == ".mp3":
audio = EasyID3(filepath)
artist = audio.get("artist", [None])[0]
title = audio.get("title", [None])[0]
elif ext == ".flac":
audio = FLAC(filepath)
artist = audio.get("artist", [None])[0]
title = audio.get("title", [None])[0]
else:
audio = MutagenFile(filepath, easy=True)
if not audio or not audio.tags:
return None, None
artist = audio.tags.get("artist", [None])[0]
title = audio.tags.get("title", [None])[0]
return (artist.strip() if artist else None, title.strip() if title else None)
except Exception:
return None, None
def extract_genre_list(filepath):
ext = os.path.splitext(filepath)[1].lower()
try:
if ext == ".mp3":
audio = EasyID3(filepath)
return audio.get("genre", [])
elif ext == ".flac":
audio = FLAC(filepath)
return audio.get("genre", [])
else:
audio = MutagenFile(filepath, easy=True)
if not audio or not audio.tags:
return []
return audio.tags.get("genre", [])
except Exception:
return []
def update_genre_tag(filepath, new_genres):
ext = os.path.splitext(filepath)[1].lower()
try:
if ext == ".mp3":
audio = EasyID3(filepath)
audio["genre"] = new_genres
audio.save()
elif ext == ".flac":
audio = FLAC(filepath)
audio["genre"] = new_genres
audio.save()
else:
audio = MutagenFile(filepath, easy=True)
if not audio or not audio.tags:
return False
audio.tags["genre"] = new_genres
audio.save()
return True
except Exception:
return False
def log_line(logfile, message):
logfile.write(message + "\n")
logfile.flush()
def process_file(filepath, logfile):
relative = os.path.relpath(filepath)
artist, title = extract_artist_title(filepath)
if not artist or not title:
log_line(logfile, f"{relative} → skipped (missing artist/title).")
return
try:
result = musicbrainzngs.search_recordings(
recording=title,
artist=artist,
limit=1,
strict=True
)
except Exception as e:
log_line(logfile, f"{relative} → MB search error: {e}")
time.sleep(RATE_LIMIT_SECONDS)
return
recordings = result.get("recording-list", [])
if not recordings:
log_line(logfile, f"{relative} → no MB match.")
time.sleep(RATE_LIMIT_SECONDS)
return
rec = recordings[0]
mb_tags = rec.get("tag-list", [])
top_genres = choose_top_genres(mb_tags, top_n=3)
if not top_genres:
log_line(logfile, f"{relative} → matched MB but no tags.")
time.sleep(RATE_LIMIT_SECONDS)
return
existing = extract_genre_list(filepath)
if existing and len(existing) >= 2:
log_line(logfile, f"{relative} → skipped (already has genres {existing}).")
time.sleep(RATE_LIMIT_SECONDS)
return
success = update_genre_tag(filepath, top_genres)
if success:
log_line(logfile, f"{relative} → updated genres to [{', '.join(top_genres)}].")
else:
log_line(logfile, f"{relative} → failed to write genres [{', '.join(top_genres)}].")
time.sleep(RATE_LIMIT_SECONDS)
def main():
root = tk.Tk()
root.withdraw()
folder = filedialog.askdirectory(title="Select Music Folder to Update Genres")
if not folder:
sys.exit(0)
logpath = os.path.join(folder, "genre_update_log.txt")
try:
logfile = open(logpath, "w", encoding="utf-8")
except Exception as e:
print(f"Cannot open log file: {e}")
sys.exit(1)
log_line(logfile, f"Genre Update Log for: {folder}")
log_line(logfile, f"Started: {time.ctime()}")
log_line(logfile, "--------------------------------------")
count = 0
for dirpath, _, filenames in os.walk(folder):
for fname in filenames:
ext = os.path.splitext(fname)[1].lower()
if ext in SUPPORTED_EXTS:
count += 1
fullpath = os.path.join(dirpath, fname)
print(f"[{count}] Processing: {os.path.relpath(fullpath, folder)}")
process_file(fullpath, logfile)
log_line(logfile, "--------------------------------------")
log_line(logfile, f"Finished: {time.ctime()}")
log_line(logfile, f"Total files checked: {count}")
logfile.close()
print(f"Done. Check log at: {logpath}")
if __name__ == "__main__":
main()
</code></pre>
<hr>
<h3>2.x Tag Fixer (<code>tag_fixer.py</code>)</h3>
<ul>
<li><strong>collect_tag_proposals(target, log_callback)</strong>: fingerprints each file, returns a list of proposals <code>{file, old_artist, old_title, new_artist, new_title, score}</code> for all matches ≥ MIN_INTERACTIVE_SCORE.</li>
<li><strong>apply_tag_proposals(proposals, log_callback)</strong>: writes only the confirmed subset of tags back into files.</li>
<li>CLI wrapper still supports <code>--interactive</code>, falling back to per-file Y/N prompts.</li>
<li>Dependencies: <code>pyacoustid</code> (module <code>acoustid</code>) + Chromaprint (<code>fpcalc</code>), <code>mutagen</code>.</li>
</ul>
<h3>2.5 Library Index Generator (<code>library_index_controller.py</code>)</h3>
<p>Scans a folder (including <code>.ogg</code>) for audio files, extracts tags (artist, title, album, year, track, genres), and writes a single HTML table (<code>library_index.html</code>) for rapid review and reference. You can re-run it after adding new files to see only updated entries.</p>
<pre><code>
#!/usr/bin/env python3
"""
library_index_controller.py
Scan audio files (.mp3, .flac, .ogg, etc.) via Mutagen and build:
Path | Artist | Title | Album | Year | Track | Genres
Output: library_index.html
"""
import os
import sys
import tkinter as tk
from tkinter import filedialog
from mutagen import File as MutagenFile
from mutagen.easyid3 import EasyID3
from mutagen.flac import FLAC
SUPPORTED_EXTS = {".mp3", ".flac", ".m4a", ".aac", ".wav", ".ogg"}
def sanitize(text: str) -> str:
if not text:
return ""
return text.replace("&", "&").replace("<", "<").replace(">", ">")
def extract_tags(path):
ext = os.path.splitext(path)[1].lower()
artist = title = album = year = track = None
genres = []
try:
if ext == ".mp3":
audio = EasyID3(path)
artist = audio.get("artist", [None])[0]
title = audio.get("title", [None])[0]
album = audio.get("album", [None])[0]
year = audio.get("date", [None])[0] or audio.get("year", [None])[0]
track = audio.get("tracknumber", [None])[0] or audio.get("track", [None])[0]
genres = audio.get("genre", [])
elif ext == ".flac":
audio = FLAC(path)
artist = audio.get("artist", [None])[0]
title = audio.get("title", [None])[0]
album = audio.get("album", [None])[0]
year = audio.get("date", [None])[0] or audio.get("year", [None])[0]
track = audio.get("tracknumber", [None])[0] or audio.get("track", [None])[0]
genres = audio.get("genre", [])
else:
audio = MutagenFile(path, easy=True)
if audio and audio.tags:
artist = audio.tags.get("artist", [None])[0]
title = audio.tags.get("title", [None])[0]
album = audio.tags.get("album", [None])[0]
year = audio.tags.get("date", [None])[0] or audio.tags.get("year", [None])[0]
track = audio.tags.get("tracknumber", [None])[0] or audio.tags.get("track", [None])[0]
genres = audio.tags.get("genre", [])
except Exception:
pass
artist = artist.strip() if isinstance(artist, str) else ""
title = title.strip() if isinstance(title, str) else ""
album = album.strip() if isinstance(album, str) else ""
year = year.strip()[:4] if isinstance(year, str) else ""
if track:
try:
track = str(int(track.split("/")[0]))
except:
track = track.strip()
else:
track = ""
genres = [g.strip() for g in genres if isinstance(g, str) and g.strip()]
return {"artist": artist, "title": title, "album": album, "year": year, "track": track, "genres": ", ".join(genres)}
def main():
root = tk.Tk()
root.withdraw()
folder = filedialog.askdirectory(title="Select Music Folder to Index")
if not folder:
sys.exit(0)
entries = []
for dirpath, _, files in os.walk(folder):
for fname in files:
ext = os.path.splitext(fname)[1].lower()
if ext in SUPPORTED_EXTS:
full = os.path.join(dirpath, fname)
tags = extract_tags(full)
entries.append((os.path.relpath(full, folder), tags))
html = []
html.append("<!DOCTYPE html>")
html.append("<html lang='en'><head><meta charset='utf-8'>")
html.append(f"<title>Library Index – {sanitize(os.path.basename(folder) or folder)}</title>")
html.append("<style>body { font-family: Arial, sans-serif; background: #f7f7f7; color: #333; } table { border-collapse: collapse; width: 100%; } th, td { border: 1px solid #ccc; padding: 6px; } th { background: #ddd; } tr:nth-child(even) { background: #eee; } </style>")
html.append("</head><body>")
html.append(f"<h1>Library Index: {sanitize(os.path.basename(folder) or folder)}</h1>")
html.append("<table>")
html.append("<tr><th>Path</th><th>Artist</th><th>Title</th><th>Album</th><th>Year</th><th>Track</th><th>Genres</th></tr>")
for rel, t in sorted(entries, key=lambda x: x[0].lower()):
html.append("<tr>")
html.append(f"<td>{sanitize(rel)}</td>")
html.append(f"<td>{sanitize(t['artist'])}</td>")
html.append(f"<td>{sanitize(t['title'])}</td>")
html.append(f"<td>{sanitize(t['album'])}</td>")
html.append(f"<td>{sanitize(t['year'])}</td>")
html.append(f"<td>{sanitize(t['track'])}</td>")
html.append(f"<td>{sanitize(t['genres'])}</td>")
html.append("</tr>")
html.append("</table></body></html>")
out = os.path.join(folder, "library_index.html")
try:
with open(out, "w", encoding="utf-8") as f:
f.write("\n".join(html))
print(f"✓ Wrote library index to: {out}")
except Exception as e:
print(f"❌ Failed to write HTML: {e}")
if __name__ == "__main__":
main()
</code></pre>
<h3>2.7 Normalize Genres (<code>normalize_controller.py</code>)</h3>
<p>Maps raw genre tags to canonical genres using a JSON mapping file. Updated tags are written back to each track.</p>
<h3>2.8 Playlist Generator (<code>playlist_generator.py</code>)</h3>
<p>Creates simple M3U playlists mirroring your folder structure.</p>
<h3>2.9 Playlist Engine</h3>
<p>Advanced playlist features now include:</p>
<ul>
<li><strong>Tempo/Energy Buckets</strong>: group tracks by BPM and RMS energy.</li>
<li><strong>Auto-DJ</strong>: chain similar songs for seamless playback.</li>
<li><strong>Clustering Approaches</strong>: K-Means and HDBSCAN based sets.</li>
<li>Wireframe: <code>playlist_wireframe.png</code></li>
</ul>
<h3>2.10 Optional Essentia Audio Engine</h3>
<p>Essentia can replace <code>librosa</code> for tempo analysis and audio feature extraction. It is optional—<code>librosa</code> remains supported—but Essentia offers faster C++ implementations when installed.</p>
<ul>
<li><strong>Prerequisites</strong> (Linux builds compile C++ code and may take several minutes):
<ul>
<li><strong>Debian/Ubuntu</strong>: <code>sudo apt-get install build-essential libfftw3-dev liblapack-dev libblas-dev libyaml-dev libtag1-dev libchromaprint-dev libsamplerate0-dev libavcodec-dev libavformat-dev libavutil-dev libavresample-dev</code></li>
<li><strong>macOS</strong>: <code>brew install essentia</code> (installs the formula and dependencies)</li>
<li><strong>Windows</strong>: no official wheel; use WSL/Linux if you need Essentia.</li>
</ul>
</li>
<li><strong>Installation</strong> (after prerequisites):
<pre><code>pip install essentia==2.1b6</code></pre>
</li>
<li>Expect longer build times on Linux the first time you install Essentia. If you prefer the pure-Python stack, keep using <code>librosa</code> without this extra dependency.</li>
</ul>
<h3>2.y GUI Front-end (<code>main_gui.py</code>)</h3>
<ul>
<li>New menu item <strong>Fix Tags</strong>.</li>
<li><strong>ProgressDialog</strong>: modal Toplevel + determinate Progressbar to show fingerprint progress.</li>
<li>Background thread calls <code>collect_tag_proposals()</code> and reports each file to the UI via a <code>queue.Queue</code>.</li>
<li>Once done, the “Review Tag Fix Proposals” dialog appears (sortable, scrollable Treeview, color-coded, multi-select, Apply Selection button).</li>
</ul>
<p>Uses helper functions <code>collect_tag_proposals()</code>, <code>apply_tag_proposals()</code>, and <code>show_proposals_dialog()</code>.</p>
<hr>
<h2>3. Current Progress</h2>
<ul>
<li><strong>Indexer</strong>: Implemented as <code>music_indexer_api.py</code>. It reorganizes by artist, year, album, singles, and remixes, writes HTML previews/logs, and leaves deduplication to the Duplicate Finder workflow.</li>
<li><strong>Fingerprint Generator</strong>: <code>fingerprint_generator.py</code> stores AcoustID hashes for each track.</li>
<li><strong>Fingerprint Cache</strong>: <code>fingerprint_cache.py</code> keeps computed fingerprints in SQLite.</li>
<li><strong>Genre Updater</strong>: <code>update_genres.py</code> connects to MusicBrainz, pulls top 3 tags per track, and writes them into files—skipping files that already have ≥ 2 genres.</li>
<li><strong>Genre Normalizer</strong>: <code>normalize_controller.py</code> scans raw genre tags, suggests clean mappings, and writes normalized genres back to your files.</li>
<li><strong>Library Index</strong>: <code>library_index_controller.py</code> builds <code>library_index.html</code>, a table listing path, artist, title, album, year, track, and genre(s) for every audio file. Useful for quick review.</li>
<li><strong>Near-Duplicate Detector</strong>: available via the Duplicate Finder workflow for review-first deduplication.</li>
<li><strong>Clustered Playlists</strong>: interactive K-Means and HDBSCAN clustering with a graph-based panel.</li>
<li><strong>UI Enhancements</strong>: theme selector and automatic DPI scaling.</li>
<li><strong>Playlist Engine</strong>: tempo/energy buckets and Auto-DJ mode.</li>
</ul>
<hr>
<h2>4. Future Roadmap</h2>
<p>The following items are planned enhancements still in development.</p>
<ol>
<li><strong>Metadata Plugins</strong> (Discogs, Spotify)</li>
<li><strong>Incremental Updates</strong>
<ul>
<li>Implement a <code>last_indexed.txt</code> timestamp or a simple SQLite/JSON database to track which files have already been processed. Then:
<ul>
<li><code>library_index_controller.py</code> will only scan new/modified files.</li>
<li><code>update_genres.py</code> will only query MusicBrainz for the new/modified subset.</li>
</ul>
</li>
</ul>
</li>
<li><strong>Performance & Caching</strong>
<ul>
<li>Caching MusicBrainz queries in a local <code>mb_cache.json</code> so identical (artist, title) lookups are not repeated.</li>
<li>Parallelize non‐API‐dependent tasks (HTML generation, file scanning) using Python’s <code>threading</code> or <code>concurrent.futures</code>.</li>
</ul>
</li>
<li><strong>Backup & Rollback</strong>
<ul>
<li>Before running the indexer or genre updater, snapshot the current state:
<ul>
<li>Copy <code>library_index.html</code> and <code>genre_update_log.txt</code> to an “__archive” folder.</li>
<li>Add a “dry‐run” option for the genre updater; the indexer already supports preview-only runs.</li>
</ul>
</li>
<li>Implement a simple “undo” by storing the moves log and reversing the file operations if needed.</li>
</ul>
</li>
<li><strong>Documentation & Packaging</strong>
<ul>
<li>Host all code in a GitHub repository with this HTML as <code>docs/project_documentation.html</code>.</li>
<li>Provide a <code>requirements.txt</code>:
<pre><code>mutagen
musicbrainzngs
pydub
pyacoustid # provides the acoustid module
librosa # optional, for BPM estimation</code></pre>
FFmpeg and Chromaprint's fpcalc must be installed and on PATH.
<li>Create a single-entry point (e.g. <code>alphadex.py</code>) that imports modules:
<ul>
<li><code>indexer</code>, <code>genre_updater</code>, <code>library_indexer</code>, <code>playlist_generator</code>, and <code>html_utils</code>.</li>
<li>Publish as a pip-installable package (<code>pip install alphadex</code>).</li>
</li>
<li>Create a Windows MSI or PyInstaller build so users can double-click without installing Python.</li>
</ul>
</li>
<li><strong>Long-Term Enhancements</strong>
<ul>
<li>Integrate additional metadata sources:
<ul>
<li>Discogs API for backup genre/album info.</li>
<li>Last.fm API for crowd-sourced tags.</li>
<li>Spotify Web API for audio features (e.g., key, loudness).</li>
</ul>
</li>
<li>“Smart playlists” that update automatically based on folder changes, time (e.g. “Most recently added”), or play count.</li>
<li>Mobile sync: Compare a second folder (e.g. phone’s SD card) against the master index and highlight missing/updated tracks.</li>
</ul>
</li>
</ol>
<hr>
<h2>5. Summary & Migration Tips</h2>
<ul>
<li><strong>Splitting into Parts:</strong> If this file feels too large, you can save Part 1 (Introduction, Programs 2.1–2.4) and Part 2 (Programs 2.5–2.9, Current Progress, Future Roadmap) separately. Just cut at the <em>“Continue to Part 2”</em> line.</li>
<li><strong>Using Git:</strong> Keep each Python script in its own file, commit to Git. When requesting edits from ChatGPT, refer to specific function names or file paths, not the entire content.</li>
<li><strong>Incremental Patches:</strong> In VS Code, install the ChatGPT extension. Select only the function you want to change and ask “Please update this function to do X,” which avoids reposting the entire file.</li>
<li><strong>Deployment:</strong> After finalizing, bundle everything into a <code>alphadex</code> folder structure:
<pre><code>
alphadex/
gui/
main_gui.py
controllers/
library_index_controller.py
import_controller.py
tagfix_controller.py
highlight_controller.py
normalize_controller.py
music_indexer_api.py
playlist_generator.py
fingerprint_generator.py
tag_fixer.py
update_genres.py
gui/
main_gui.py
plugins/
assistant_plugin.py
requirements.txt
docs/
project_documentation.html
README.md
</code></pre>
</li>
</ul>
<p>With this HTML in hand, you can paste it into a new chat session or repository; future edits will be far lighter since you only need to update the small section that changes. Good luck completing your AlphaDEX (formerly SoundVault) system!</p>
</body>
</html>