aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorPinapelz <yukais@pinapelz.com>2026-04-25 18:34:59 -0700
committerPinapelz <yukais@pinapelz.com>2026-04-25 18:34:59 -0700
commitd7ff02e9921c62ab21b3a5fde4532e6a8d8a291c (patch)
tree2d9df1cfea88d19892d2597d617cf2167b6af7d5
parent2d501af06a04d68031979594411eae89c3a3a691 (diff)
automatically normalize audio to -14 LUFS, multithread, use semaphore to
avoid lyrics rate limit
-rw-r--r--main.py155
-rw-r--r--pyproject.toml1
-rw-r--r--uv.lock42
3 files changed, 148 insertions, 50 deletions
diff --git a/main.py b/main.py
index 1982a90..a031dd7 100644
--- a/main.py
+++ b/main.py
@@ -1,5 +1,8 @@
import argparse
import subprocess
+import threading
+import time
+from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from typing import Iterator
from tqdm import tqdm
@@ -7,6 +10,8 @@ import syncedlyrics
from mutagen.flac import FLAC
import ffmpeg
+_lyrics_semaphore = threading.Semaphore(1)
+
def iter_files(base: Path) -> Iterator[Path]:
iterator = base.rglob("*")
@@ -78,7 +83,8 @@ def get_track_info(path: Path) -> tuple:
audio = FLAC(str(path))
title = audio.get("TITLE", [""])
artist = audio.get("ARTIST", [""])
- return (title[0], artist[0])
+ album = audio.get("ALBUM", [""])
+ return (title[0], artist[0], album[0])
def resize_album_art(path: Path) -> None:
@@ -97,76 +103,125 @@ def resize_album_art(path: Path) -> None:
audio.save()
+def normalize_loudness(path: Path, target_lufs: float = -14.0, target_tp: float = -1.0) -> Path:
+ temp_path = path.with_suffix(".tmp.flac")
+ subprocess.run(
+ [
+ "ffmpeg-normalize", str(path),
+ "-o", str(temp_path),
+ "-c:a", "flac",
+ "-t", str(target_lufs),
+ "--true-peak", str(target_tp),
+ "-f",
+ ],
+ capture_output=True,
+ text=True,
+ check=True,
+ )
+ path.unlink()
+ temp_path.rename(path)
+ return path
+
+
def sanitize_filename(name: str) -> str:
illegal = r'\/:*?"<>|'
return "".join(c for c in name if c not in illegal).strip()
-def main():
- parser = argparse.ArgumentParser()
- parser.add_argument("base_dir", type=Path)
- parser.add_argument("--nolrc", "-n", action="store_true", dest="nolrc")
+def process_file(fp: Path, nolrc: bool) -> str:
+ lines = []
+ log = lines.append
- args = parser.parse_args()
+ log(f"\nProcessing: {fp.name}")
+ title, artist, album = get_track_info(fp)
- base = args.base_dir
+ if not title:
+ log(f" Warning: TITLE tag is empty, using filename as title")
+ title = fp.stem
+ artist = "UNKNOWN ARTIST"
- files = [p for p in iter_files(base) if p.suffix == ".flac"]
+ new_stem = sanitize_filename(f"{title} - {artist}")
+ target = fp.with_name(new_stem + ".flac")
+ if target.exists() and target.resolve() != fp.resolve():
+ if album:
+ log(f" Conflict detected, adding album name as differentiator")
+ new_stem = sanitize_filename(f"{title} - {artist} ({album})")
+ else:
+ log(f" Warning: filename conflict but no album tag, keeping original name")
+ new_stem = fp.stem
+ new_file_name = new_stem + ".flac"
+ if new_file_name != fp.name:
+ rename_file(fp, new_file_name)
+ fp = fp.with_name(new_file_name)
- for fp in tqdm(files, desc="Processing FLAC files", unit="file"):
- print(f"\nProcessing: {fp.name}")
- title, artist = get_track_info(fp)
+ issues = get_audio_issues(fp)
+ log(f" Stats: {issues['sample_rate']}Hz, {issues['bits_per_sample']}-bit, blocksize={issues['max_blocksize']}")
- if not title:
- print(f" Warning: TITLE tag is empty, using filename as title")
- title = fp.stem
- artist = "UNKNOWN ARTIST"
+ if issues["needs_sample_rate_fix"] or issues["needs_bitdepth_fix"]:
+ reasons = []
+ if issues["needs_sample_rate_fix"]:
+ reasons.append(f"sample rate {issues['sample_rate']}Hz -> 192000Hz")
+ if issues["needs_bitdepth_fix"]:
+ reasons.append(f"bit depth {issues['bits_per_sample']}-bit -> 24-bit")
+ log(f" Fixing via ffmpeg: {', '.join(reasons)}")
+ fp = fix_with_ffmpeg(fp, issues["needs_sample_rate_fix"], issues["needs_bitdepth_fix"])
- new_file_name = sanitize_filename(f"{title} - {artist}") + ".flac"
- if new_file_name != fp.name:
- rename_file(fp, new_file_name)
- fp = fp.with_name(new_file_name)
+ log(" Normalizing loudness to -14 LUFS")
+ fp = normalize_loudness(fp)
- issues = get_audio_issues(fp)
- print(f" Stats: {issues['sample_rate']}Hz, {issues['bits_per_sample']}-bit, blocksize={issues['max_blocksize']}")
+ post_blocksize = getattr(FLAC(str(fp)).info, "max_blocksize", 4096)
+ if post_blocksize != 4096:
+ log(f" Fixing blocksize -> 4096 via flac CLI")
+ fp = fix_blocksize(fp)
- if issues["needs_sample_rate_fix"] or issues["needs_bitdepth_fix"]:
- reasons = []
- if issues["needs_sample_rate_fix"]:
- reasons.append(f"sample rate {issues['sample_rate']}Hz -> 192000Hz")
- if issues["needs_bitdepth_fix"]:
- reasons.append(f"bit depth {issues['bits_per_sample']}-bit -> 24-bit")
- print(f" Fixing via ffmpeg: {', '.join(reasons)}")
- fp = fix_with_ffmpeg(fp, issues["needs_sample_rate_fix"], issues["needs_bitdepth_fix"])
+ log(" Resizing album art to 500x500")
+ resize_album_art(fp)
- post_info = FLAC(str(fp)).info
- if getattr(post_info, "max_blocksize", 4096) != 4096:
- issues["needs_blocksize_fix"] = True
+ if nolrc:
+ return "\n".join(lines)
- if issues["needs_blocksize_fix"]:
- print(f" Fixing blocksize -> 4096 via flac CLI")
- fp = fix_blocksize(fp)
+ if not title or not artist:
+ log(f" Skipping LRC for {fp.name} (missing title or artist tag)")
+ return "\n".join(lines)
- print(" Resizing album art to 500x500")
- resize_album_art(fp)
+ lrc_path = fp.with_suffix(".lrc")
+ if lrc_path.exists():
+ log(f" Skipping LRC for {fp.name} (already exists)")
+ return "\n".join(lines)
- if args.nolrc:
- continue
+ log(f" Fetching LRC for: {title} - {artist}")
+ with _lyrics_semaphore:
+ lrc = syncedlyrics.search(f"{title} {artist}", providers=["Lrclib", "Megalobiz", "NetEase"])
+ time.sleep(0.5)
- if not title or not artist:
- print(f" Skipping LRC for {fp.name} (missing title or artist tag)")
- continue
+ with open(lrc_path, "w", encoding="utf-8") as f:
+ f.write(lrc if lrc else "")
- lrc_path = fp.with_suffix(".lrc")
- if lrc_path.exists():
- print(f" Skipping LRC for {fp.name} (already exists)")
- continue
+ return "\n".join(lines)
- print(f" Fetching LRC for: {title} - {artist}")
- lrc = syncedlyrics.search(f"{title} {artist}", providers=["Lrclib", "Megalobiz", "NetEase"])
- with open(lrc_path, "w", encoding="utf-8") as f:
- f.write(lrc if lrc else "")
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument("base_dir", type=Path)
+ parser.add_argument("--nolrc", action="store_true", dest="nolrc")
+ parser.add_argument("-n", "--workers", type=int, default=1, dest="workers",
+ help="Number of parallel workers (default: 1)")
+ args = parser.parse_args()
+
+ files = [p for p in iter_files(args.base_dir) if p.suffix == ".flac"]
+
+ with ThreadPoolExecutor(max_workers=args.workers) as executor:
+ futures = {executor.submit(process_file, fp, args.nolrc): fp for fp in files}
+ with tqdm(total=len(files), desc="Processing FLAC files", unit="file") as pbar:
+ for future in as_completed(futures):
+ try:
+ result = future.result()
+ tqdm.write(result)
+ except Exception as e:
+ fp = futures[future]
+ tqdm.write(f"\n ERROR processing {fp.name}: {e}")
+ finally:
+ pbar.update(1)
if __name__ == "__main__":
diff --git a/pyproject.toml b/pyproject.toml
index d6449eb..9364074 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -5,6 +5,7 @@ description = "Media tool for retagging FLACs for Snowsky Echo/Mini"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
+ "ffmpeg-normalize>=1.37.6",
"ffmpeg-python>=0.2.0",
"mutagen>=1.47.0",
"pillow>=12.2.0",
diff --git a/uv.lock b/uv.lock
index eec6aed..2503869 100644
--- a/uv.lock
+++ b/uv.lock
@@ -91,6 +91,46 @@ wheels = [
]
[[package]]
+name = "colorlog"
+version = "6.7.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/78/6b/4e5481ddcdb9c255b2715f54c863629f1543e97bc8c309d1c5c131ad14f2/colorlog-6.7.0.tar.gz", hash = "sha256:bd94bd21c1e13fac7bd3153f4bc3a7dc0eb0974b8bc2fdf1a989e474f6e582e5", size = 29920, upload-time = "2022-08-29T14:51:27.945Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/58/43/a363c213224448f9e194d626221123ce00e3fb3d87c0c22aed52b620bdd1/colorlog-6.7.0-py2.py3-none-any.whl", hash = "sha256:0d33ca236784a1ba3ff9c532d4964126d8a2c44f1f0cb1d2b0728196f512f662", size = 11286, upload-time = "2022-08-29T14:51:26.426Z" },
+]
+
+[[package]]
+name = "ffmpeg-normalize"
+version = "1.37.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "colorlog" },
+ { name = "ffmpeg-progress-yield" },
+ { name = "mutagen" },
+ { name = "tqdm" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c8/1a/0d27abae69de51b27dae48892f2d325dda3f80cc7def8ac216058b27c20c/ffmpeg_normalize-1.37.6.tar.gz", hash = "sha256:cec7d6a9d1b2108f0e4f8fcbd304f192676a708e8d11faf948073bf8eec562ee", size = 34800, upload-time = "2026-04-13T11:59:59.226Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8e/a6/63f2d450cc11275a5e9e67e4734e9cd8f85ab3bf5b4af50f4c304424735a/ffmpeg_normalize-1.37.6-py3-none-any.whl", hash = "sha256:3e346c6f5bc2fc5411d22b52d5e33b994cd690ece657cd68d58911dfcbd5bd6b", size = 41075, upload-time = "2026-04-13T11:59:57.881Z" },
+]
+
+[[package]]
+name = "ffmpeg-progress-yield"
+version = "1.1.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "tqdm" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/36/eb/f4836c4c30cef5f26cb0d77fa16938303665f87c1e0f64c2812d00706ecf/ffmpeg_progress_yield-1.1.3.tar.gz", hash = "sha256:79bf782a6d6bf9be64bea1d3b7a0f777f11057705935128a225970db74dedf85", size = 10012, upload-time = "2026-03-30T15:38:45.453Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fd/c7/c42ce9353ae6b70bd920856a97736ac81e068901fb537126365e6735f1c8/ffmpeg_progress_yield-1.1.3-py3-none-any.whl", hash = "sha256:4eb4948a72414608c7b05c8061aff85ff47e31e33f629abc22d08d0b03196c62", size = 12709, upload-time = "2026-03-30T15:38:44.575Z" },
+]
+
+[[package]]
name = "ffmpeg-python"
version = "0.2.0"
source = { registry = "https://pypi.org/simple" }
@@ -107,6 +147,7 @@ name = "fiio-snowsky-flac-media"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
+ { name = "ffmpeg-normalize" },
{ name = "ffmpeg-python" },
{ name = "mutagen" },
{ name = "pillow" },
@@ -116,6 +157,7 @@ dependencies = [
[package.metadata]
requires-dist = [
+ { name = "ffmpeg-normalize", specifier = ">=1.37.6" },
{ name = "ffmpeg-python", specifier = ">=0.2.0" },
{ name = "mutagen", specifier = ">=1.47.0" },
{ name = "pillow", specifier = ">=12.2.0" },
send patches to the email below
yukais@pinapelz.com
include the subject [PATCH repo_name]
pinapelz.com
homepage