Last active
August 16, 2025 15:33
-
-
Save noaione/bf239ce7c66e9a50495193e011ddc6fa to your computer and use it in GitHub Desktop.
quick script to generate playlist for Navidrome for my umamusu discography | sorted by the "type" of the album then release date
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# /// script | |
# dependencies = [ | |
# "mutagen", | |
# ] | |
# /// | |
# run with `uv run uma-pl-gen.py` on the same root folder as all the albums folders | |
from pathlib import Path | |
from mutagen.flac import FLAC | |
from mutagen.mp4 import MP4 | |
CURRENT_DIR = Path(__file__).resolve().parent | |
SEP = " • " | |
M3U8_TEMPLATE = """#EXTM3U | |
#PLAYLIST:Umamusume 🐴 | |
""" | |
album_candidates: list[tuple[str, str, Path]] = [] | |
for folder in CURRENT_DIR.iterdir(): | |
if not folder.is_dir(): | |
continue | |
# Folder format: [YYYY.MM.DD] Album Name | |
if not folder.name.startswith("[") or "]" not in folder.name: | |
continue | |
year_stuff, album_name = folder.name.split("]", 1) | |
year_stuff = year_stuff.strip("[]") | |
year_stuff = year_stuff.replace(".", "-") | |
album_name = album_name.strip() | |
album_candidates.append((year_stuff, album_name, folder)) | |
# We want to sort albums by "type" and then by date | |
# Sorting priority (asc): | |
# - Umamusume Pretty Derby STARTING GATE | |
# - ANIMATION DERBY (without Season - a.k.a Season 1) | |
# - ANIMATION DERBY Season 2 | |
# - ANIMATION DERBY Season 3 | |
# - Umamusume Pretty Derby WINNING LIVE | |
# - Umamusume Pretty Derby ROAD TO THE TOP | |
# - Umamusume Pretty Derby BEGINNING OF THE NEW ERA | |
# When nothing matches, put at the end then sort by date | |
# first sort by folder name | |
# it's already "sorted" in this format [YYYY.MM.DD] Album Name | |
# but Linux would resort them again | |
album_candidates.sort(key=lambda x: x[2].name) | |
def get_album_type_priority(album_name: str) -> int: | |
"""Scoring function for album types""" | |
if album_name.startswith("Umamusume Pretty Derby STARTING GATE"): | |
return 0 | |
elif album_name.startswith("ANIMATION DERBY") and "Season" not in album_name: | |
return 1 | |
elif album_name.startswith("ANIMATION DERBY Season 2"): | |
return 2 | |
elif album_name.startswith("ANIMATION DERBY Season 3"): | |
return 3 | |
elif album_name.startswith("Umamusume Pretty Derby WINNING LIVE"): | |
return 4 | |
elif album_name.startswith("Umamusume Pretty Derby ROAD TO THE TOP"): | |
return 5 | |
elif album_name.startswith("Umamusume Pretty Derby BEGINNING OF THE NEW ERA"): | |
return 6 | |
else: | |
return 999 # Put unmatched albums at the end | |
def sort_albums(album_tuple): | |
"""Sort function for albums: first by type priority, then by date.""" | |
date_str, album_name, _ = album_tuple | |
type_priority = get_album_type_priority(album_name) | |
return (type_priority, date_str) | |
def load_file(song_file: Path) -> MP4 | FLAC | None: | |
if song_file.suffix.lower() == ".m4a": | |
return MP4(song_file) | |
elif song_file.suffix.lower() == ".flac": | |
return FLAC(song_file) | |
return None | |
def determine_file_length(song_file: MP4 | FLAC | None) -> float | None: | |
if isinstance(song_file, MP4): | |
return song_file.info.length if song_file.info else None | |
elif isinstance(song_file, FLAC): | |
return song_file.info.length if song_file.info else None | |
return None | |
def make_extinf_title(song_file: MP4 | FLAC | None) -> str | None: | |
if isinstance(song_file, MP4): | |
titles: list[str] = song_file["\xa9nam"] # type: ignore | |
artists: list[str] = song_file["\xa9ART"] # type: ignore | |
artist_merged = SEP.join(artists) | |
titles_merged = SEP.join(titles) | |
return f"{artist_merged} - {titles_merged}" | |
elif isinstance(song_file, FLAC): | |
titles: list[str] = song_file["title"] | |
artists: list[str] = song_file["artist"] | |
artist_merged = SEP.join(artists) | |
titles_merged = SEP.join(titles) | |
return f"{artist_merged} - {titles_merged}" | |
return None | |
# sort score by type | |
sorted_albums = sorted(album_candidates, key=sort_albums) | |
print(f"Processing {len(album_candidates)} albums...") | |
m3u8_file = CURRENT_DIR / "Umamusume 🐴.m3u" | |
# delete the file if it exists | |
if m3u8_file.exists(): | |
m3u8_file.unlink() | |
with m3u8_file.open("a", encoding="utf-8") as m3u8_f: | |
m3u8_file.write_text(M3U8_TEMPLATE, encoding="utf-8") | |
for date_str, album_name, folder in sorted_albums: | |
print(f"Processing: [{date_str}] {album_name}") | |
song_files = list(folder.rglob("*.m4a")) + list(folder.rglob("*.flac")) | |
# sort by filename (without suffix) | |
song_files.sort(key=lambda x: x.stem) | |
for song_file in song_files: | |
file_loader = load_file(song_file) | |
assert file_loader is not None, f"Could not load file: {song_file}" | |
# check how long the media file is for EXTINF | |
file_length = determine_file_length(file_loader) | |
assert file_length is not None, f"File length for {song_file} could not be determined." | |
extinf_title = make_extinf_title(file_loader) | |
assert extinf_title is not None, f"Could not create EXTINF title for {song_file}" | |
# round to int | |
file_length = int(round(file_length, 0)) | |
song_file_cast = song_file.relative_to(CURRENT_DIR).as_posix() | |
real_path = f"/mnt/tachyon/ongaku/Japanese/Umamusume/{song_file_cast}" | |
m3u8_f.write(f"#EXTINF:{file_length},{extinf_title}\n") | |
m3u8_f.write(f"{real_path}\n") |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment