Compare commits

2 Commits

Author SHA1 Message Date
PublicMatt 1e53178676 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.
2026-08-08 11:28:53 -07:00
matt e0d3af2a4f feat(yt): pull cookies from firefox dev edition, firefox, then chrome
age/login-gated videos need browser cookies to download.
`ydl_opts` becomes a computed_field that picks the first available source.
2026-08-07 12:38:13 -07:00
14 changed files with 136 additions and 31 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"
stream = "music.__main__:stream"
[project.optional-dependencies]
dev = ["pytest>=8.0"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
+7
View File
@@ -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")
+30 -14
View File
@@ -1,12 +1,13 @@
import asyncio
import glob
import os
import re
from pathlib import Path
from typing import ClassVar
from pydantic_settings import CliImplicitFlag, CliPositionalArg
import structlog
import yt_dlp
import yt_dlp.cookies
from mutagen.easyid3 import EasyID3
from pydantic import Field, computed_field
from shazamio import Shazam
@@ -68,9 +69,16 @@ 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():
# dev edition lives in a randomly-prefixed dir, so pass an absolute path
matches = glob.glob(os.path.join(root, "*.dev-edition-default"))
if matches:
return ("firefox", matches[0], None, None)
if any(yt_dlp.cookies._firefox_cookie_dbs(yt_dlp.cookies._firefox_browser_dirs())):
return ("firefox", None, None, None)
return ("chrome", None, None, None)
class YtDownload(Command):
@@ -79,7 +87,20 @@ class YtDownload(Command):
"""
url: CliPositionalArg[str] = Field(description="youtube url to download")
ydl_opts: ClassVar = {
parents: CliImplicitFlag[bool] = Field(
default=True, description="create the parent dirs if not exists"
)
@computed_field()
@property
def quarter_dir(self) -> Path:
date = DateParse()
return self.music_base_path / f"{date.quarter}_{date.year}"
@computed_field()
@property
def ydl_opts(self) -> dict:
opts = {
"format": "mp3/bestaudio/best",
"postprocessors": [
{
@@ -89,15 +110,10 @@ class YtDownload(Command):
],
"remote_components": ["ejs:github"],
}
parents: CliImplicitFlag[bool] = Field(
default=True, description="create the parent dirs if not exists"
)
@computed_field()
@property
def quarter_dir(self) -> Path:
date = DateParse()
return Path().home() / "Music" / f"{date.quarter}_{date.year}"
cookies = _cookiesfrombrowser()
if cookies is not None:
opts["cookiesfrombrowser"] = cookies
return opts
def run(self) -> None:
os.chdir(Path.home() / "Downloads")
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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
-7
View File
@@ -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")
+7
View File
@@ -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
+4 -5
View File
@@ -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}"
+5 -2
View File
@@ -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
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" },
]
[[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"