From 1e531786762d32ec9ea9390052dcdd4517ce0751 Mon Sep 17 00:00:00 2001 From: PublicMatt Date: Sat, 8 Aug 2026 11:28:53 -0700 Subject: [PATCH] feat(config): make music base path configurable via env var hardcoded ~/Music path broke on any non-standard setup. `music_base_path` on root CLI and `Command` base reads from $MUSIC_BASE_PATH. `seasons()` and `current_quarter()` now take the path explicitly. adds .env.example, .envrc, and a pytest covering --help output. --- .env.example | 1 + .envrc | 1 + pyproject.toml | 3 ++ src/music/cli.py | 7 ++++ src/music/commands/download.py | 7 +--- src/music/commands/playlist.py | 2 +- src/music/commands/random.py | 2 +- src/music/commands/stream.py | 7 ---- src/music/models.py | 7 ++++ src/music/paths.py | 9 +++--- src/music/stream.py | 7 ++-- tests/__init__.py | 0 tests/test_cli_help.py | 16 +++++++++ uv.lock | 59 ++++++++++++++++++++++++++++++++++ 14 files changed, 106 insertions(+), 22 deletions(-) create mode 100644 .env.example create mode 100644 .envrc create mode 100644 tests/__init__.py create mode 100644 tests/test_cli_help.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5843374 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +MUSIC_BASE_PATH=/path/to/your/music diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..fe7c01a --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +dotenv diff --git a/pyproject.toml b/pyproject.toml index 8544029..da71450 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,9 @@ dependencies = [ music = "music.__main__:cli" stream = "music.__main__:stream" +[project.optional-dependencies] +dev = ["pytest>=8.0"] + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" diff --git a/src/music/cli.py b/src/music/cli.py index 98396f9..49c1074 100644 --- a/src/music/cli.py +++ b/src/music/cli.py @@ -1,3 +1,5 @@ +from pathlib import Path + from pydantic import Field from pydantic_settings import BaseSettings, CliApp, CliSubCommand, get_subcommand @@ -11,6 +13,11 @@ class Cli(BaseSettings): "cli_use_class_docs_for_groups": True, } + music_base_path: Path = Field( + default_factory=lambda: Path.home() / "Music", + description="base music directory ($MUSIC_BASE_PATH)", + ) + download: CliSubCommand[YtDownload] = Field(alias="yt") stream: CliSubCommand[Stream] = Field(alias="stream") random: CliSubCommand[Random] = Field(alias="random") diff --git a/src/music/commands/download.py b/src/music/commands/download.py index 5535091..983a5ec 100644 --- a/src/music/commands/download.py +++ b/src/music/commands/download.py @@ -69,11 +69,6 @@ def _shazam_lookup(filepath: str) -> tuple[str | None, str | None]: return _clean(track_info.get("subtitle")), _clean(track_info.get("title")) -def current_quarter() -> Path: - date = DateParse() - return Path().home() / "Music" / f"{date.quarter}_{date.year}" - - def _cookiesfrombrowser() -> tuple | None: """First available cookies source: firefox dev edition, firefox, then chrome.""" for root in yt_dlp.cookies._firefox_browser_dirs(): @@ -100,7 +95,7 @@ class YtDownload(Command): @property def quarter_dir(self) -> Path: date = DateParse() - return Path().home() / "Music" / f"{date.quarter}_{date.year}" + return self.music_base_path / f"{date.quarter}_{date.year}" @computed_field() @property diff --git a/src/music/commands/playlist.py b/src/music/commands/playlist.py index 9a01f95..b9c935b 100644 --- a/src/music/commands/playlist.py +++ b/src/music/commands/playlist.py @@ -20,7 +20,7 @@ class Playlist(Command): ) def run(self) -> None: - for d in sorted(seasons()): + for d in sorted(seasons(self.music_base_path)): songs = sorted(f for f in d.iterdir() if f.suffix in EXTENSIONS) if not songs: log.info("skipping empty dir", dir=d.name) diff --git a/src/music/commands/random.py b/src/music/commands/random.py index ea1e702..aa495fb 100644 --- a/src/music/commands/random.py +++ b/src/music/commands/random.py @@ -12,7 +12,7 @@ class Random(Command): def run(self): """play random local songs.""" songs = [] - for d in seasons(): + for d in seasons(self.music_base_path): for f in d.iterdir(): if not f.suffix == ".mp3": continue diff --git a/src/music/commands/stream.py b/src/music/commands/stream.py index 9dfccea..5b8f4da 100644 --- a/src/music/commands/stream.py +++ b/src/music/commands/stream.py @@ -1,13 +1,11 @@ from datetime import datetime, timezone from enum import Enum -from pathlib import Path import subprocess from pydantic import Field import requests import structlog -from music.dates import DateParse from music.models import Command log = structlog.get_logger() @@ -38,11 +36,6 @@ class Show(str, Enum): # el sonito -def current_quarter() -> Path: - date = DateParse() - return Path().home() / "Music" / f"{date.quarter}_{date.year}" - - def url_for_time(t) -> str: default = "https://kexp-mp3-128.streamguys1.com/kexp128.mp3" tz = t.strftime("%Y-%m-%dT%H:%M:%SZ") diff --git a/src/music/models.py b/src/music/models.py index c83314a..f035de5 100644 --- a/src/music/models.py +++ b/src/music/models.py @@ -1,9 +1,16 @@ from abc import ABC, abstractmethod +from pathlib import Path +from pydantic import Field from pydantic_settings import BaseSettings class Command(BaseSettings, ABC): + music_base_path: Path = Field( + default_factory=lambda: Path.home() / "Music", + description="base music directory", + ) + @abstractmethod def run(self) -> None: pass diff --git a/src/music/paths.py b/src/music/paths.py index 65dc993..e63f9e1 100644 --- a/src/music/paths.py +++ b/src/music/paths.py @@ -4,10 +4,9 @@ from typing import Generator from music.dates import quarter_year -def seasons() -> Generator[Path, None, None]: +def seasons(music_base_path: Path) -> Generator[Path, None, None]: prefix = ("fall_", "winter_", "spring_", "summer_") - music_dir = Path().home() / "Music" - for d in music_dir.iterdir(): + for d in music_base_path.iterdir(): if not d.is_dir(): continue if not d.name.startswith(prefix): @@ -16,6 +15,6 @@ def seasons() -> Generator[Path, None, None]: yield from [] -def current_quarter() -> Path: +def current_quarter(music_base_path: Path) -> Path: quarter, year = quarter_year() - return Path().home() / "Music" / f"{quarter}_{year}" + return music_base_path / f"{quarter}_{year}" diff --git a/src/music/stream.py b/src/music/stream.py index 168d820..bbbf9d2 100644 --- a/src/music/stream.py +++ b/src/music/stream.py @@ -1,5 +1,6 @@ import subprocess from enum import Enum +from pathlib import Path from .paths import seasons import random import requests @@ -11,6 +12,8 @@ import os log = structlog.get_logger() +_DEFAULT_MUSIC_BASE_PATH = Path.home() / "Music" + class Stream(str, Enum): KEXP = "kexp" @@ -54,10 +57,10 @@ def play(stream, t=None): subprocess.run(["mpv", url]) -def local(): +def local(music_base_path: Path = _DEFAULT_MUSIC_BASE_PATH): """play random local songs.""" songs = [] - for d in seasons(): + for d in seasons(music_base_path): for f in d.iterdir(): if not f.suffix == ".mp3": continue diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_cli_help.py b/tests/test_cli_help.py new file mode 100644 index 0000000..303980c --- /dev/null +++ b/tests/test_cli_help.py @@ -0,0 +1,16 @@ +import pytest +from io import StringIO +from contextlib import redirect_stdout + +from pydantic_settings import CliApp + +from music.cli import Cli + + +def test_help_shows_music_dir(): + buf = StringIO() + with pytest.raises(SystemExit): + with redirect_stdout(buf): + CliApp.run(Cli, cli_args=["--help"]) + output = buf.getvalue() + assert "--music-base-path" in output, f"--music-base-path not found in help output:\n{output}" diff --git a/uv.lock b/uv.lock index 1861310..8168daa 100644 --- a/uv.lock +++ b/uv.lock @@ -459,6 +459,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "mpv" version = "1.0.8" @@ -585,6 +594,11 @@ dependencies = [ { name = "yt-dlp" }, ] +[package.optional-dependencies] +dev = [ + { name = "pytest" }, +] + [package.metadata] requires-dist = [ { name = "audioop-lts", marker = "python_full_version >= '3.13'", specifier = ">=0.2.2" }, @@ -594,11 +608,13 @@ requires-dist = [ { name = "mutagen", specifier = ">=1.47.0" }, { name = "pydantic", specifier = ">=2.0" }, { name = "pydantic-settings", specifier = ">=2.12.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, { name = "requests", specifier = ">=2.32.3" }, { name = "shazamio", specifier = ">=0.8.1" }, { name = "structlog", specifier = ">=25.5.0" }, { name = "yt-dlp", specifier = ">=2025.1.15" }, ] +provides-extras = ["dev"] [[package]] name = "mutagen" @@ -670,6 +686,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/58/78/548fb8e07b1a341746bfbecb32f2c268470f45fa028aacdbd10d9bc73aab/numpy-2.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7", size = 10566643, upload-time = "2026-03-29T13:21:34.339Z" }, ] +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "propcache" version = "0.5.2" @@ -877,6 +911,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a6/53/d78dc063216e62fc55f6b2eebb447f6a4b0a59f55c8406376f76bf959b08/pydub-0.25.1-py2.py3-none-any.whl", hash = "sha256:65617e33033874b59d87db603aa1ed450633288aefead953b30bded59cb599a6", size = 32327, upload-time = "2021-03-10T02:09:53.503Z" }, ] +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.2"