nkbmusic/tools/scan_songs.py
metatroncubeswdev 178d2d00ab Nanga Kathai: Flutter app, self-hosted backend and song tools
- Flutter app (Android/iOS): home, explore, albums, search, player with
  equalizer, mini player, sign up/sign in with email codes, profile,
  encrypted offline downloads, About page
- server/: one-command deploy of self-hosted Supabase behind HestiaCP/nginx
  (or Caddy), migrations, backups, email templates
- supabase/migrations: schema, row-level security, profiles
- tools/: song scanner and importer

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 10:39:26 -04:00

159 lines
6.0 KiB
Python

"""Scan the songs folder and write a manifest to review before import.
Reads tags, duration and bitrate, finds exact duplicates (same audio bytes),
and proposes a title, singer and album for every file. Open the CSV in Excel,
fix the columns, set `include` to no for anything to skip, then run
import_songs.py.
Usage: python tools/scan_songs.py [songs_dir] [out_csv]
Needs: pip install mutagen
"""
import csv
import hashlib
import re
import sys
from pathlib import Path
from mutagen import File as AudioFile
AUDIO_EXT = {".mp3", ".m4a", ".wma", ".wav", ".aac"}
# Formats the app streams directly; others must be converted to mp3 first.
PLAYABLE_EXT = {".mp3", ".m4a", ".aac"}
COLUMNS = [
"include", "id", "file", "folder", "title", "singers", "album", "occasion",
"year", "duration_s", "bitrate_kbps", "duplicate_of", "notes",
"tag_title", "tag_artist", "tag_album",
]
JUNK_TITLE = re.compile(
r"^(.*\btrack\s*\d+\w*.*|audio\s*t|\d+|.{1,3}|.*hits|<<<.*>>>|title|unknown.*|new( \(\d+\))?|(aud|wa)[-_ ]?\d.*|z \(\d+\))$", re.I)
# Artist tags that are placeholders, numbers or song titles rather than singers.
JUNK_ARTIST = re.compile(
r"^(\d+|.*\bartists?|unknown.*|track\s*\d+|<<<.*>>>|latest|\d+[.\s_].*|zz.*|.*\bhits)$", re.I)
# Folders of film songs (not Badaga, owned by film labels): excluded by default.
FILM_FOLDERS = {"old hits"}
def first_tag(audio, *keys):
tags = getattr(audio, "tags", None)
if not tags:
return ""
for key in keys:
value = tags.get(key)
if value is None:
continue
text = value.text[0] if hasattr(value, "text") else (value[0] if isinstance(value, list) else value)
text = str(text).strip()
if text:
return text
return ""
def audio_digest(path):
"""Hash of the file minus the first 128 KB, so retagged copies still match."""
h = hashlib.sha1()
with open(path, "rb") as f:
f.seek(131072)
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
def clean_title(stem):
t = re.sub(r"\.(mp3|m4a|wma)$", "", stem.strip(), flags=re.I)
t = re.sub(r"\(\d+\)$", "", t).strip() # "Song (3)" copies
t = re.sub(r"^(zz|\d+[\s._-]*)", "", t, flags=re.I) # leading track numbers
t = re.sub(r"-?SS$", "", t) # uploader's tag on some files
t = re.sub(r"\bAUDIO\s*T\b", "", t, flags=re.I)
t = re.sub(r"[_]+", " ", t)
t = re.sub(r"\s{2,}", " ", t).strip(" .-")
return t.title() if t.isupper() or t.islower() else t
def main():
root = Path(sys.argv[1] if len(sys.argv) > 1 else "songs")
out = Path(sys.argv[2] if len(sys.argv) > 2 else "songs_manifest.csv")
files = sorted(p for p in root.rglob("*") if p.suffix.lower() in AUDIO_EXT)
seen = {}
rows = []
for n, path in enumerate(files, 1):
notes = []
try:
audio = AudioFile(path)
except Exception as e: # unreadable file
audio = None
notes.append(f"unreadable: {e.__class__.__name__}")
info = getattr(audio, "info", None)
duration = round(info.length) if info and getattr(info, "length", 0) else 0
bitrate = round(getattr(info, "bitrate", 0) / 1000) if info else 0
tag_title = first_tag(audio, "TIT2", "\xa9nam", "Title", "title")
tag_artist = first_tag(audio, "TPE1", "\xa9ART", "Author", "artist")
tag_album = first_tag(audio, "TALB", "\xa9alb", "WM/AlbumTitle", "album")
tag_year = first_tag(audio, "TDRC", "\xa9day", "WM/Year", "date")[:4]
folder = path.parent.relative_to(root).as_posix()
film = folder in FILM_FOLDERS
title = clean_title(tag_title) if tag_title and not JUNK_TITLE.match(tag_title) else clean_title(path.stem)
if re.search(r"\d{8,}", title): # phone numbers in WhatsApp-forwarded file names
title = ""
if not title or JUNK_TITLE.match(title):
title = ""
notes.append("needs title")
singers = "" if JUNK_ARTIST.match(tag_artist) else tag_artist
if not singers:
notes.append("needs singer")
if film:
notes.append("film song: not Badaga, needs label licence")
if path.suffix.lower() not in PLAYABLE_EXT:
notes.append("convert to mp3")
if duration and duration < 60:
notes.append("very short")
if bitrate and bitrate < 96:
notes.append("low quality")
digest = audio_digest(path)
duplicate_of = seen.get(digest, "")
if duplicate_of:
notes.append("duplicate")
else:
seen[digest] = f"s{n:04d}"
rows.append({
"include": "no" if film or duplicate_of or "unreadable" in " ".join(notes) else "yes",
"id": f"s{n:04d}",
"file": path.relative_to(root).as_posix(),
"folder": folder,
"title": title,
"singers": singers,
"album": tag_album if tag_album and not JUNK_TITLE.match(tag_album) else path.parent.name,
"occasion": "folk",
"year": tag_year if tag_year.isdigit() else "",
"duration_s": duration,
"bitrate_kbps": bitrate,
"duplicate_of": duplicate_of,
"notes": "; ".join(notes),
"tag_title": tag_title,
"tag_artist": tag_artist,
"tag_album": tag_album,
})
# utf-8-sig so Excel shows Tamil text correctly.
with open(out, "w", newline="", encoding="utf-8-sig") as f:
w = csv.DictWriter(f, fieldnames=COLUMNS)
w.writeheader()
w.writerows(rows)
dupes = sum(1 for r in rows if r["duplicate_of"])
print(f"{len(rows)} files -> {out}")
print(f" duplicates: {dupes}")
for flag in ("needs title", "needs singer", "convert to mp3", "low quality", "very short", "unreadable"):
print(f" {flag}: {sum(1 for r in rows if flag in r['notes'])}")
print(f" total hours: {sum(r['duration_s'] for r in rows) / 3600:.1f}")
if __name__ == "__main__":
main()