- 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>
159 lines
6.0 KiB
Python
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()
|