From 404deff6e4dc63a6dd3cbc491bb78c701ab7b789 Mon Sep 17 00:00:00 2001 From: publicmatt Date: Mon, 20 Jan 2025 12:00:18 -0800 Subject: [PATCH] init --- .python-version | 1 + README.md | 0 pyproject.toml | 19 +++++ requirements.txt | 7 ++ src/music/__init__.py | 2 + src/music/__main__.py | 54 +++++++++++++++ src/music/shazam/shazam.py | 137 +++++++++++++++++++++++++++++++++++++ uv.lock | 58 ++++++++++++++++ 8 files changed, 278 insertions(+) create mode 100644 .python-version create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 src/music/__init__.py create mode 100644 src/music/__main__.py create mode 100755 src/music/shazam/shazam.py create mode 100644 uv.lock diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d58d154 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,19 @@ +[project] +name = "music" +version = "0.1.0" +description = "music utilies" +readme = "README.md" +authors = [{ name = "publicmatt", email = "git@publicmatt.com" }] +requires-python = ">=3.13" +dependencies = [ + "click>=8.1.8", + "joblib>=1.4.2", + "python-dotenv>=1.0.1", +] + +[project.scripts] +music = "music.__main__:cli" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..297998b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +asyncio +shazamio +click +python_dotenv +tqdm +joblib +mutagen diff --git a/src/music/__init__.py b/src/music/__init__.py new file mode 100644 index 0000000..4e078ac --- /dev/null +++ b/src/music/__init__.py @@ -0,0 +1,2 @@ +def main() -> None: + print("Hello from music!") diff --git a/src/music/__main__.py b/src/music/__main__.py new file mode 100644 index 0000000..ceef787 --- /dev/null +++ b/src/music/__main__.py @@ -0,0 +1,54 @@ +from enum import Enum +import click +import subprocess +# from .shazam import rename + +from click.exceptions import ClickException + + +@click.group() +def cli(): + pass + + +class Stream(str, Enum): + KEXP = "kexp" + KUGS = "kugs" + CLIS = "clis" + + +@cli.command("stream") +@click.option( + "-s", "--stream", "stream", type=click.Choice(Stream), default=Stream.KEXP +) +def play_stream(stream): + """Play the KEXP stream using mpv.""" + match stream: + case Stream.KEXP: + url = "https://kexp-mp3-128.streamguys1.com/kexp128.mp3" + case Stream.KUGS: + url = "https://peridot.streamguys1.com:7175/kugs-mp3"" + case Stream.CLIS: + url = "https://stream2.statsradio.com:8012/stream?=&&___cb=759135934160766" + case _: + raise ClickException(f"unrecognized stream: {stream}") + subprocess.run(["mpv", url]) + +@cli.command("random") +def play_random(): + """todo.""" + raise NotImplementedError("todo") + # fd -t file -e mp3 -p -a "fall|winter|spring|summer" $HOME/Music/ | mpv --playlist=- --shuffle --no-video + +@cli.command("save") +@click.option( "-u", "--url", "url", required=True) +def save_song(url): + """todo.""" + raise NotImplementedError("todo") + + # FILENAME=$(yt-dlp {{url}} -x --audio-format mp3 | tee /dev/tty | grep 'ExtractAudio' | cut -d ' ' -f 3-) + # $HOME/.local/lib/shazam/bin/shazam.py rename --song "$FILENAME" + + +if __name__ == "__main__": + cli() diff --git a/src/music/shazam/shazam.py b/src/music/shazam/shazam.py new file mode 100755 index 0000000..180b410 --- /dev/null +++ b/src/music/shazam/shazam.py @@ -0,0 +1,137 @@ +#! /home/user/.venv/bin/python + +import asyncio +from shazamio import Shazam, Serialize +from pathlib import Path +import click +from dotenv import load_dotenv +import os +from tqdm import tqdm +from typing import Optional +import sys +from collections import namedtuple +from joblib import Memory +from pydantic import BaseModel, Field + +base_path = Path(__file__).parent.parent +memory = Memory(base_path / "./.cache", verbose=0) + + +class Match(BaseModel): + old: Path + new: Optional[Path] = Field(default=None) + title: Optional[str] = Field(default=None) + artist: Optional[str] = Field(default=None) + + +@click.group() +def cli(): + pass + + +@cli.command() +@click.option( + "-s", + "--song", + required=True, + type=click.Path(exists=True, dir_okay=False, path_type=Path), +) +def rename(song: Path): + change = get_match(song) + if change and change.new is not None: + prompt_rename(change) + + +def songs(root: Path): + seasons = ["winter", "spring", "summer", "fall"] + pattern = "{}_*/*.mp3" + matches = [] + for season in seasons: + for file_path in root.rglob(pattern.format(season)): + matches.append(file_path) + + def small(f: Path, max_bytes: int = 10_000_000): + return f.stat().st_size < max_bytes + + return filter(small, matches) + + +def needs_rename(song: Match): + if song.new is None: + print(f"\nno match: {song.old.name}\n") + return False + if song.old.name == song.new.name: + print(f"\nname correct: {song.old.name}\n") + return False + return True + + +@cli.command("main") +def main(): + root = Path(os.getenv("MUSIC_PATH", "~/Music")) + if not root.exists(): + raise ValueError(f"{str(root)} does not exist") + + renames = [] + for song in tqdm(songs(root), position=0, leave=True): + rename = get_match(song) + if rename and rename.new is not None: + renames.append(rename) + + for song in filter(needs_rename, renames): + prompt_rename(song) + + sys.exit(127) + + +def prompt_rename(song: Match): + old = song.old + answer = input( + f'\nrename: "{old.name}"\nto: "{song.new.name}"?\n[y[es]/n[o]/m[anual]] > ' + ) + if answer in ["n", "N"]: + print(f"\nskipping: {old.name}\n") + return + if answer in ["m"]: + manual = input("\nnew name: ") + new = old.parent / manual + assert ( + new.suffix == old.suffix + ), f"filetype changed: {old.suffix} -> {new.suffix}" + else: + new = song.new + try: + old.rename(new) + from mutagen.easyid3 import EasyID3 + + tags = EasyID3(new) + tags["title"] = song.title + tags["artist"] = song.artist + tags.save() + except FileNotFoundError as e: + print(f"error renaming: {e}") + print(f'old: "{old}"\nnew: "{new}"') + return + + +@memory.cache +def get_match(original: Path) -> Match: + loop = asyncio.get_event_loop() + shazam = Shazam() + try: + out = loop.run_until_complete(shazam.recognize(str(original))) + serialized = Serialize.track(out["track"]) + rename = f"{serialized.title} - {serialized.subtitle}{original.suffix}" + new = Path(original.parent) / rename + return Match( + old=original, new=new, title=serialized.title, artist=serialized.subtitle + ) + except: + return Match(old=original, new=None) + + +if __name__ == "__main__": + env = Path(__file__).parent.parent / ".env" + if not load_dotenv(env): + raise ValueError(".env not found: {env}") + cli() diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..2ba66df --- /dev/null +++ b/uv.lock @@ -0,0 +1,58 @@ +version = 1 +requires-python = ">=3.13" + +[[package]] +name = "click" +version = "8.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "platform_system == 'Windows'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "joblib" +version = "1.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/64/33/60135848598c076ce4b231e1b1895170f45fbcaeaa2c9d5e38b04db70c35/joblib-1.4.2.tar.gz", hash = "sha256:2382c5816b2636fbd20a09e0f4e9dad4736765fdfb7dca582943b9c1366b3f0e", size = 2116621 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/29/df4b9b42f2be0b623cbd5e2140cafcaa2bef0759a00b7b70104dcfe2fb51/joblib-1.4.2-py3-none-any.whl", hash = "sha256:06d478d5674cbc267e7496a410ee875abd68e4340feff4490bcb7afb88060ae6", size = 301817 }, +] + +[[package]] +name = "music" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "click" }, + { name = "joblib" }, + { name = "python-dotenv" }, +] + +[package.metadata] +requires-dist = [ + { name = "click", specifier = ">=8.1.8" }, + { name = "joblib", specifier = ">=1.4.2" }, + { name = "python-dotenv", specifier = ">=1.0.1" }, +] + +[[package]] +name = "python-dotenv" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863 }, +]