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.
This commit is contained in:
PublicMatt
2026-08-08 11:28:53 -07:00
parent e0d3af2a4f
commit 1e53178676
14 changed files with 106 additions and 22 deletions
+1
View File
@@ -0,0 +1 @@
MUSIC_BASE_PATH=/path/to/your/music
+1
View File
@@ -0,0 +1 @@
dotenv
+3
View File
@@ -23,6 +23,9 @@ dependencies = [
music = "music.__main__:cli" music = "music.__main__:cli"
stream = "music.__main__:stream" stream = "music.__main__:stream"
[project.optional-dependencies]
dev = ["pytest>=8.0"]
[build-system] [build-system]
requires = ["hatchling"] requires = ["hatchling"]
build-backend = "hatchling.build" build-backend = "hatchling.build"
+7
View File
@@ -1,3 +1,5 @@
from pathlib import Path
from pydantic import Field from pydantic import Field
from pydantic_settings import BaseSettings, CliApp, CliSubCommand, get_subcommand from pydantic_settings import BaseSettings, CliApp, CliSubCommand, get_subcommand
@@ -11,6 +13,11 @@ class Cli(BaseSettings):
"cli_use_class_docs_for_groups": True, "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") download: CliSubCommand[YtDownload] = Field(alias="yt")
stream: CliSubCommand[Stream] = Field(alias="stream") stream: CliSubCommand[Stream] = Field(alias="stream")
random: CliSubCommand[Random] = Field(alias="random") random: CliSubCommand[Random] = Field(alias="random")
+1 -6
View File
@@ -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")) 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: def _cookiesfrombrowser() -> tuple | None:
"""First available cookies source: firefox dev edition, firefox, then chrome.""" """First available cookies source: firefox dev edition, firefox, then chrome."""
for root in yt_dlp.cookies._firefox_browser_dirs(): for root in yt_dlp.cookies._firefox_browser_dirs():
@@ -100,7 +95,7 @@ class YtDownload(Command):
@property @property
def quarter_dir(self) -> Path: def quarter_dir(self) -> Path:
date = DateParse() date = DateParse()
return Path().home() / "Music" / f"{date.quarter}_{date.year}" return self.music_base_path / f"{date.quarter}_{date.year}"
@computed_field() @computed_field()
@property @property
+1 -1
View File
@@ -20,7 +20,7 @@ class Playlist(Command):
) )
def run(self) -> None: 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) songs = sorted(f for f in d.iterdir() if f.suffix in EXTENSIONS)
if not songs: if not songs:
log.info("skipping empty dir", dir=d.name) log.info("skipping empty dir", dir=d.name)
+1 -1
View File
@@ -12,7 +12,7 @@ class Random(Command):
def run(self): def run(self):
"""play random local songs.""" """play random local songs."""
songs = [] songs = []
for d in seasons(): for d in seasons(self.music_base_path):
for f in d.iterdir(): for f in d.iterdir():
if not f.suffix == ".mp3": if not f.suffix == ".mp3":
continue continue
-7
View File
@@ -1,13 +1,11 @@
from datetime import datetime, timezone from datetime import datetime, timezone
from enum import Enum from enum import Enum
from pathlib import Path
import subprocess import subprocess
from pydantic import Field from pydantic import Field
import requests import requests
import structlog import structlog
from music.dates import DateParse
from music.models import Command from music.models import Command
log = structlog.get_logger() log = structlog.get_logger()
@@ -38,11 +36,6 @@ class Show(str, Enum):
# el sonito # el sonito
def current_quarter() -> Path:
date = DateParse()
return Path().home() / "Music" / f"{date.quarter}_{date.year}"
def url_for_time(t) -> str: def url_for_time(t) -> str:
default = "https://kexp-mp3-128.streamguys1.com/kexp128.mp3" default = "https://kexp-mp3-128.streamguys1.com/kexp128.mp3"
tz = t.strftime("%Y-%m-%dT%H:%M:%SZ") tz = t.strftime("%Y-%m-%dT%H:%M:%SZ")
+7
View File
@@ -1,9 +1,16 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from pathlib import Path
from pydantic import Field
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
class Command(BaseSettings, ABC): class Command(BaseSettings, ABC):
music_base_path: Path = Field(
default_factory=lambda: Path.home() / "Music",
description="base music directory",
)
@abstractmethod @abstractmethod
def run(self) -> None: def run(self) -> None:
pass pass
+4 -5
View File
@@ -4,10 +4,9 @@ from typing import Generator
from music.dates import quarter_year 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_") prefix = ("fall_", "winter_", "spring_", "summer_")
music_dir = Path().home() / "Music" for d in music_base_path.iterdir():
for d in music_dir.iterdir():
if not d.is_dir(): if not d.is_dir():
continue continue
if not d.name.startswith(prefix): if not d.name.startswith(prefix):
@@ -16,6 +15,6 @@ def seasons() -> Generator[Path, None, None]:
yield from [] yield from []
def current_quarter() -> Path: def current_quarter(music_base_path: Path) -> Path:
quarter, year = quarter_year() quarter, year = quarter_year()
return Path().home() / "Music" / f"{quarter}_{year}" return music_base_path / f"{quarter}_{year}"
+5 -2
View File
@@ -1,5 +1,6 @@
import subprocess import subprocess
from enum import Enum from enum import Enum
from pathlib import Path
from .paths import seasons from .paths import seasons
import random import random
import requests import requests
@@ -11,6 +12,8 @@ import os
log = structlog.get_logger() log = structlog.get_logger()
_DEFAULT_MUSIC_BASE_PATH = Path.home() / "Music"
class Stream(str, Enum): class Stream(str, Enum):
KEXP = "kexp" KEXP = "kexp"
@@ -54,10 +57,10 @@ def play(stream, t=None):
subprocess.run(["mpv", url]) subprocess.run(["mpv", url])
def local(): def local(music_base_path: Path = _DEFAULT_MUSIC_BASE_PATH):
"""play random local songs.""" """play random local songs."""
songs = [] songs = []
for d in seasons(): for d in seasons(music_base_path):
for f in d.iterdir(): for f in d.iterdir():
if not f.suffix == ".mp3": if not f.suffix == ".mp3":
continue continue
View File
+16
View File
@@ -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}"
Generated
+59
View File
@@ -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" }, { 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]] [[package]]
name = "mpv" name = "mpv"
version = "1.0.8" version = "1.0.8"
@@ -585,6 +594,11 @@ dependencies = [
{ name = "yt-dlp" }, { name = "yt-dlp" },
] ]
[package.optional-dependencies]
dev = [
{ name = "pytest" },
]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "audioop-lts", marker = "python_full_version >= '3.13'", specifier = ">=0.2.2" }, { 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 = "mutagen", specifier = ">=1.47.0" },
{ name = "pydantic", specifier = ">=2.0" }, { name = "pydantic", specifier = ">=2.0" },
{ name = "pydantic-settings", specifier = ">=2.12.0" }, { name = "pydantic-settings", specifier = ">=2.12.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
{ name = "requests", specifier = ">=2.32.3" }, { name = "requests", specifier = ">=2.32.3" },
{ name = "shazamio", specifier = ">=0.8.1" }, { name = "shazamio", specifier = ">=0.8.1" },
{ name = "structlog", specifier = ">=25.5.0" }, { name = "structlog", specifier = ">=25.5.0" },
{ name = "yt-dlp", specifier = ">=2025.1.15" }, { name = "yt-dlp", specifier = ">=2025.1.15" },
] ]
provides-extras = ["dev"]
[[package]] [[package]]
name = "mutagen" 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" }, { 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]] [[package]]
name = "propcache" name = "propcache"
version = "0.5.2" 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" }, { 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]] [[package]]
name = "python-dotenv" name = "python-dotenv"
version = "1.2.2" version = "1.2.2"