diff options
| -rw-r--r-- | README.md | 13 | ||||
| -rw-r--r-- | main.py | 91 | ||||
| -rw-r--r-- | pyproject.toml | 1 | ||||
| -rw-r--r-- | uv.lock | 25 |
4 files changed, 123 insertions, 7 deletions
@@ -1,12 +1,23 @@ # FIIO Snowsky Echo FLAC Media Tool -This is a small script to help owners who already maintain a FLAC library make their music show up nicer on the Snowsky Echo/Echo Mini +This is a small script to help owners who already maintain a FLAC library make their music work/show up nicer on the Snowsky Echo/Echo Mini The script does the following: - Recursively searches through the provided directory +- Re-samples audio higher than 192Khz 24bit via ffmpeg +- Re-encodes files with block size higher than 4096 via `flac` CLI - Rename FLAC file to `TRACK_NAME.flac` - Resize album art to 500x500px - Download LRC file +# External Dependencies +You need the FLAC command line tool to be accessible globally, meaning it must be able to run anywhere on your machine. Using the official tool was the most consistent way of fixing the block-size issue cross-platform. + +https://xiph.org/flac/download.html + +- Windows: `winget install -e --id Xiph.FLAC` +- Linux: `sudo pacman -S flac` (follow your package manager) +- macOS: `brew install flac` (idk tho i don't own a mac) + ```bash uv sync uv run main.py <base_dir> [--nolrc] @@ -1,9 +1,12 @@ import argparse +import subprocess from pathlib import Path from typing import Iterator from tqdm import tqdm import syncedlyrics from mutagen.flac import FLAC +import ffmpeg + def iter_files(base: Path) -> Iterator[Path]: iterator = base.rglob("*") @@ -11,17 +14,73 @@ def iter_files(base: Path) -> Iterator[Path]: if p.is_file(): yield p.resolve() + def rename_file(filepath: Path, new_name: str) -> None: target = filepath.with_name(new_name) filepath.rename(target) +def get_audio_issues(path: Path) -> dict: + audio = FLAC(str(path)) + info = audio.info + sample_rate = getattr(info, "sample_rate", 0) + bits_per_sample = getattr(info, "bits_per_sample", 24) + max_blocksize = getattr(info, "max_blocksize", 4096) + + return { + "needs_sample_rate_fix": sample_rate > 192000, + "needs_bitdepth_fix": bits_per_sample > 24, + "needs_blocksize_fix": max_blocksize != 4096, + "sample_rate": sample_rate, + "bits_per_sample": bits_per_sample, + "max_blocksize": max_blocksize, + } + + +def fix_with_ffmpeg(path: Path, fix_sample_rate: bool, fix_bitdepth: bool) -> Path: + output_kwargs = { + "acodec": "flac", + "map": "0:a", + } + if fix_bitdepth: + output_kwargs["sample_fmt"] = "s24" + if fix_sample_rate: + output_kwargs["ar"] = "192000" + + temp_path = path.with_suffix(".tmp.flac") + ( + ffmpeg + .input(str(path)) + .output(str(temp_path), **output_kwargs) + .overwrite_output() + .run(quiet=True) + ) + path.unlink() + temp_path.rename(path) + return path + + +def fix_blocksize(path: Path, blocksize: int = 4096) -> Path: + temp_path = path.with_suffix(".tmp.flac") + command = ["flac", "--force", f"--blocksize={blocksize}", str(path), "-o", str(temp_path)] + try: + subprocess.run(command, capture_output=True, text=True, check=True) + path.unlink() + temp_path.rename(path) + except subprocess.CalledProcessError as e: + temp_path.unlink(missing_ok=True) + print(f" Error fixing blocksize for {path.name}:") + print(e.stderr) + return path + + def get_track_info(path: Path) -> tuple: audio = FLAC(str(path)) title = audio.get("TITLE", [""]) artist = audio.get("ARTIST", [""]) return (title[0], artist[0]) + def resize_album_art(path: Path) -> None: audio = FLAC(str(path)) if not audio.pictures: @@ -37,24 +96,45 @@ def resize_album_art(path: Path) -> None: pic.data = out.getvalue() audio.save() + def main(): parser = argparse.ArgumentParser() parser.add_argument("base_dir", type=Path) - parser.add_argument("--nolrc", "-n", action="store_true", dest="flag") + parser.add_argument("--nolrc", "-n", action="store_true", dest="nolrc") args = parser.parse_args() base = args.base_dir files = [p for p in iter_files(base) if p.suffix == ".flac"] for fp in tqdm(files, desc="Processing FLAC files", unit="file"): - print("Fetching track info and renaming file") + print(f"\nProcessing: {fp.name}") title, artist = get_track_info(fp) new_file_name = title + ".flac" rename_file(fp, new_file_name) fp = fp.with_name(new_file_name) - print("Resizing album art to 500x500") + issues = get_audio_issues(fp) + print(f" Stats: {issues['sample_rate']}Hz, {issues['bits_per_sample']}-bit, blocksize={issues['max_blocksize']}") + + 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"]) + + post_info = FLAC(str(fp)).info + if getattr(post_info, "max_blocksize", 4096) != 4096: + issues["needs_blocksize_fix"] = True + + if issues["needs_blocksize_fix"]: + print(f" Fixing blocksize -> 4096 via flac CLI") + fp = fix_blocksize(fp) + + print(" Resizing album art to 500x500") resize_album_art(fp) if args.nolrc: @@ -62,14 +142,15 @@ def main(): lrc_path = fp.with_suffix(".lrc") if lrc_path.exists(): - print("Skipping", lrc_path, "as LRC already exists") + print(f" Skipping LRC for {fp.name} (already exists)") continue - print(f"Fetching LRC file for {title} {artist}") + 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 "") + if __name__ == "__main__": main() diff --git a/pyproject.toml b/pyproject.toml index 4b6dca1..d6449eb 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-python>=0.2.0", "mutagen>=1.47.0", "pillow>=12.2.0", "syncedlyrics>=1.0.1", @@ -91,10 +91,23 @@ wheels = [ ] [[package]] -name = "fiio-snowsky-flac-tagger" +name = "ffmpeg-python" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "future" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/5e/d5f9105d59c1325759d838af4e973695081fbbc97182baf73afc78dec266/ffmpeg-python-0.2.0.tar.gz", hash = "sha256:65225db34627c578ef0e11c8b1eb528bb35e024752f6f10b78c011f6f64c4127", size = 21543, upload-time = "2019-07-06T00:19:08.989Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/0c/56be52741f75bad4dc6555991fabd2e07b432d333da82c11ad701123888a/ffmpeg_python-0.2.0-py3-none-any.whl", hash = "sha256:ac441a0404e053f8b6a1113a77c0f452f1cfc62f6344a769475ffdc0f56c23c5", size = 25024, upload-time = "2019-07-06T00:19:07.215Z" }, +] + +[[package]] +name = "fiio-snowsky-flac-media" version = "0.1.0" source = { virtual = "." } dependencies = [ + { name = "ffmpeg-python" }, { name = "mutagen" }, { name = "pillow" }, { name = "syncedlyrics" }, @@ -103,6 +116,7 @@ dependencies = [ [package.metadata] requires-dist = [ + { name = "ffmpeg-python", specifier = ">=0.2.0" }, { name = "mutagen", specifier = ">=1.47.0" }, { name = "pillow", specifier = ">=12.2.0" }, { name = "syncedlyrics", specifier = ">=1.0.1" }, @@ -110,6 +124,15 @@ requires-dist = [ ] [[package]] +name = "future" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/b2/4140c69c6a66432916b26158687e821ba631a4c9273c474343badf84d3ba/future-1.0.0.tar.gz", hash = "sha256:bd2968309307861edae1458a4f8a4f3598c03be43b97521076aebf5d94c07b05", size = 1228490, upload-time = "2024-02-21T11:52:38.461Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/71/ae30dadffc90b9006d77af76b393cb9dfbfc9629f339fc1574a1c52e6806/future-1.0.0-py3-none-any.whl", hash = "sha256:929292d34f5872e70396626ef385ec22355a1fae8ad29e1a734c3e43f9fbc216", size = 491326, upload-time = "2024-02-21T11:52:35.956Z" }, +] + +[[package]] name = "idna" version = "3.13" source = { registry = "https://pypi.org/simple" } |
