Compare commits

3 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
matt b9f6aaf462 feat(kexp): download kexp playlist from rest api 2026-08-04 12:28:00 -07:00
17 changed files with 462 additions and 31 deletions
+1
View File
@@ -0,0 +1 @@
MUSIC_BASE_PATH=/path/to/your/music
+1
View File
@@ -0,0 +1 @@
dotenv
+4
View File
@@ -8,6 +8,7 @@ requires-python = ">=3.12"
dependencies = [ dependencies = [
"audioop-lts>=0.2.2 ; python_full_version >= '3.13'", "audioop-lts>=0.2.2 ; python_full_version >= '3.13'",
"click>=8.1.8", "click>=8.1.8",
"duckdb>=1.1.0",
"mpv>=1.0.7", "mpv>=1.0.7",
"mutagen>=1.47.0", "mutagen>=1.47.0",
"pydantic>=2.0", "pydantic>=2.0",
@@ -22,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"
+10 -1
View File
@@ -1,7 +1,9 @@
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
from music.commands import Random, Stream, YtDownload from music.commands import Kexp, Playlist, Random, Stream, YtDownload
class Cli(BaseSettings): class Cli(BaseSettings):
@@ -11,9 +13,16 @@ 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")
kexp: CliSubCommand[Kexp] = Field(alias="kexp")
playlist: CliSubCommand[Playlist] = Field(alias="playlist")
def cli_cmd(self) -> None: def cli_cmd(self) -> None:
if (cmd := get_subcommand(self, is_required=False)) is not None: if (cmd := get_subcommand(self, is_required=False)) is not None:
+3 -1
View File
@@ -1,5 +1,7 @@
from .download import YtDownload from .download import YtDownload
from .kexp import Kexp
from .playlist import Playlist
from .random import Random from .random import Random
from .stream import Stream from .stream import Stream
__all__ = ["YtDownload", "Random", "Stream"] __all__ = ["YtDownload", "Kexp", "Playlist", "Random", "Stream"]
+31 -15
View File
@@ -1,12 +1,13 @@
import asyncio import asyncio
import glob
import os import os
import re import re
from pathlib import Path from pathlib import Path
from typing import ClassVar
from pydantic_settings import CliImplicitFlag, CliPositionalArg from pydantic_settings import CliImplicitFlag, CliPositionalArg
import structlog import structlog
import yt_dlp import yt_dlp
import yt_dlp.cookies
from mutagen.easyid3 import EasyID3 from mutagen.easyid3 import EasyID3
from pydantic import Field, computed_field from pydantic import Field, computed_field
from shazamio import Shazam 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")) return _clean(track_info.get("subtitle")), _clean(track_info.get("title"))
def current_quarter() -> Path: def _cookiesfrombrowser() -> tuple | None:
date = DateParse() """First available cookies source: firefox dev edition, firefox, then chrome."""
return Path().home() / "Music" / f"{date.quarter}_{date.year}" 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): class YtDownload(Command):
@@ -79,16 +87,6 @@ class YtDownload(Command):
""" """
url: CliPositionalArg[str] = Field(description="youtube url to download") url: CliPositionalArg[str] = Field(description="youtube url to download")
ydl_opts: ClassVar = {
"format": "mp3/bestaudio/best",
"postprocessors": [
{
"key": "FFmpegExtractAudio",
"preferredcodec": "mp3",
}
],
"remote_components": ["ejs:github"],
}
parents: CliImplicitFlag[bool] = Field( parents: CliImplicitFlag[bool] = Field(
default=True, description="create the parent dirs if not exists" default=True, description="create the parent dirs if not exists"
) )
@@ -97,7 +95,25 @@ 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()
@property
def ydl_opts(self) -> dict:
opts = {
"format": "mp3/bestaudio/best",
"postprocessors": [
{
"key": "FFmpegExtractAudio",
"preferredcodec": "mp3",
}
],
"remote_components": ["ejs:github"],
}
cookies = _cookiesfrombrowser()
if cookies is not None:
opts["cookiesfrombrowser"] = cookies
return opts
def run(self) -> None: def run(self) -> None:
os.chdir(Path.home() / "Downloads") os.chdir(Path.home() / "Downloads")
+225
View File
@@ -0,0 +1,225 @@
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime
from pathlib import Path
from typing import Any
import duckdb
import requests
import structlog
from pydantic import Field
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
from music.models import Command
log = structlog.get_logger()
API_URL = "https://api.kexp.org/v1/play/"
SCHEMA = """
CREATE TABLE IF NOT EXISTS plays (
playid BIGINT PRIMARY KEY,
playtype_id INTEGER,
playtype_name VARCHAR,
airdate TIMESTAMP,
epoch_airdate BIGINT,
artist_id BIGINT,
artist_name VARCHAR,
artist_islocal BOOLEAN,
release_id BIGINT,
release_name VARCHAR,
release_image VARCHAR,
releaseevent_id BIGINT,
releaseevent_year INTEGER,
track_id BIGINT,
track_name VARCHAR,
label_id BIGINT,
label_name VARCHAR,
showid BIGINT,
comments VARCHAR,
fetched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
"""
UPSERT = """
INSERT INTO plays (
playid, playtype_id, playtype_name, airdate, epoch_airdate,
artist_id, artist_name, artist_islocal,
release_id, release_name, release_image,
releaseevent_id, releaseevent_year,
track_id, track_name,
label_id, label_name,
showid, comments
) VALUES (
?, ?, ?, ?, ?,
?, ?, ?,
?, ?, ?,
?, ?,
?, ?,
?, ?,
?, ?
)
ON CONFLICT (playid) DO NOTHING;
"""
def _get(d: dict | None, key: str) -> Any:
return d.get(key) if isinstance(d, dict) else None
def _flatten(play: dict) -> tuple:
playtype = play.get("playtype") or {}
artist = play.get("artist") or {}
release = play.get("release") or {}
revent = play.get("releaseevent") or {}
track = play.get("track") or {}
label = play.get("label") or {}
comments = play.get("comments") or []
comment_text = " | ".join(c.get("text", "") for c in comments if c.get("text"))
airdate = play.get("airdate")
if airdate:
airdate = datetime.fromisoformat(airdate.replace("Z", "+00:00"))
return (
play.get("playid"),
_get(playtype, "playtypeid"),
_get(playtype, "name"),
airdate,
play.get("epoch_airdate"),
_get(artist, "artistid"),
_get(artist, "name"),
_get(artist, "islocal"),
_get(release, "releaseid"),
_get(release, "name"),
_get(release, "largeimageuri") or _get(release, "smallimageuri"),
_get(revent, "releaseeventid"),
_get(revent, "year"),
_get(track, "trackid"),
_get(track, "name"),
_get(label, "labelid"),
_get(label, "name"),
play.get("showid"),
comment_text or None,
)
def _make_session() -> requests.Session:
session = requests.Session()
retry = Retry(
total=5,
connect=5,
read=5,
status=5,
backoff_factor=1.5,
status_forcelist=(429, 500, 502, 503, 504),
allowed_methods=("GET",),
raise_on_status=False,
)
session.mount("https://", HTTPAdapter(max_retries=retry))
session.mount("http://", HTTPAdapter(max_retries=retry))
return session
def _fetch_page(session: requests.Session, offset: int, limit: int) -> list[dict]:
resp = session.get(
API_URL, params={"limit": limit, "offset": offset}, timeout=60
)
resp.raise_for_status()
return resp.json().get("results") or []
class Kexp(Command):
"""batch the KEXP play API into a duckdb table."""
db: Path = Field(
default=Path.home() / "Music" / "kexp.duckdb",
description="duckdb file path",
)
limit: int = Field(default=200, description="page size per request")
concurrency: int = Field(default=5, description="parallel HTTP workers")
batch_size: int = Field(
default=2000, description="rows per DB insert flush"
)
max_pages: int | None = Field(
default=None, description="stop after N pages (default: until exhausted)"
)
from_page: int = Field(
default=0, description="start pagination at this page number"
)
stop_on_existing: bool = Field(
default=False,
description="stop scheduling once a chunk yields zero new rows (use for incremental top-up)",
)
def _flush(self, con: duckdb.DuckDBPyConnection, buffer: list[tuple]) -> int:
if not buffer:
return 0
ids = [r[0] for r in buffer]
existing = {
row[0]
for row in con.execute(
f"SELECT playid FROM plays WHERE playid IN ({','.join('?' * len(ids))})",
ids,
).fetchall()
}
new_rows = [r for r in buffer if r[0] not in existing]
if new_rows:
con.executemany(UPSERT, new_rows)
buffer.clear()
return len(new_rows)
def run(self) -> None:
self.db.parent.mkdir(parents=True, exist_ok=True)
con = duckdb.connect(str(self.db))
con.execute(SCHEMA)
total_inserted = 0
buffer: list[tuple] = []
page = self.from_page
end_page = (
self.from_page + self.max_pages if self.max_pages is not None else None
)
stop = False
session = _make_session()
with ThreadPoolExecutor(max_workers=self.concurrency) as pool:
while not stop:
chunk = []
for _ in range(self.concurrency):
if end_page is not None and page >= end_page:
break
chunk.append((page, page * self.limit))
page += 1
if not chunk:
break
offsets = [off for _, off in chunk]
log.info("fetching chunk", pages=[p for p, _ in chunk])
results = list(
pool.map(lambda o: _fetch_page(session, o, self.limit), offsets)
)
chunk_new = 0
empty_page = False
for results_page in results:
if not results_page:
empty_page = True
continue
buffer.extend(_flatten(p) for p in results_page)
if len(buffer) >= self.batch_size:
chunk_new += self._flush(con, buffer)
chunk_new += self._flush(con, buffer)
total_inserted += chunk_new
log.info("chunk done", new=chunk_new, total=total_inserted)
if empty_page:
stop = True
elif self.stop_on_existing and chunk_new == 0:
log.info("no new rows in chunk, stopping")
stop = True
count = con.execute("SELECT COUNT(*) FROM plays").fetchone()[0]
log.info("done", inserted=total_inserted, table_rows=count, db=str(self.db))
con.close()
+37
View File
@@ -0,0 +1,37 @@
from pathlib import Path
import structlog
from pydantic import Field
from pydantic_settings import CliImplicitFlag
from music.models import Command
from music.paths import seasons
log = structlog.get_logger()
EXTENSIONS = {".mp3", ".flac", ".m4a", ".ogg", ".opus", ".wav"}
class Playlist(Command):
"""update .m3u8 playlist in every quarterly dir."""
force: CliImplicitFlag[bool] = Field(
default=True, description="overwrite existing playlist files."
)
def run(self) -> None:
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)
continue
playlist = d / f"{d.name}.m3u8"
if playlist.exists() and not self.force:
log.info("skipping existing playlist", path=str(playlist))
continue
playlist.write_text(
"\n".join(f.name for f in songs) + "\n", encoding="utf-8"
)
log.info("wrote playlist", path=str(playlist), tracks=len(songs))
+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
+17 -5
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()
@@ -19,9 +17,23 @@ class Source(str, Enum):
CLIS = "clis" CLIS = "clis"
def current_quarter() -> Path: class Show(str, Enum):
date = DateParse() """
return Path().home() / "Music" / f"{date.quarter}_{date.year}" TODO: map these to the most recent.
TODO: write a parser or use an llm to map english to timedelta
--query "last morning show"
--query "last friday show"
--query "today's midday show"
--query "recent roadhouse"
"""
FRIDAY_MORNING = ["friday", "friday morning"]
MORNING = ["morning show", "morning"]
# roadhouse
# midday
# afternoon
# drivetime
# el sonito
def url_for_time(t) -> str: def url_for_time(t) -> str:
+10 -1
View File
@@ -1,4 +1,7 @@
from datetime import datetime from __future__ import annotations
from datetime import date, datetime
from pathlib import Path
from typing import Optional, Tuple from typing import Optional, Tuple
from pydantic import BaseModel, Field, computed_field from pydantic import BaseModel, Field, computed_field
@@ -42,3 +45,9 @@ class DateParse(BaseModel):
@property @property
def year(self) -> int: def year(self) -> int:
return self.date.year return self.date.year
@staticmethod
def from_dir(path: Path) -> date | None:
# TODO: parse path.name into a date if its {winter|fall|..}_{yyyy}.
# TODO: None if can't parse
return None
+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
+90
View File
@@ -332,6 +332,35 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/66/50b9f5d8a0e9fbe5469c5b8a7f198511fff9959347fc2443531d651b21d7/dataclass_factory-2.16-py3-none-any.whl", hash = "sha256:9d01e73d40b8f74051df822f21d8dc8bbf2754b11670cd1c477357e8b21323ed", size = 29693, upload-time = "2022-07-20T14:21:06.678Z" }, { url = "https://files.pythonhosted.org/packages/f1/66/50b9f5d8a0e9fbe5469c5b8a7f198511fff9959347fc2443531d651b21d7/dataclass_factory-2.16-py3-none-any.whl", hash = "sha256:9d01e73d40b8f74051df822f21d8dc8bbf2754b11670cd1c477357e8b21323ed", size = 29693, upload-time = "2022-07-20T14:21:06.678Z" },
] ]
[[package]]
name = "duckdb"
version = "1.5.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/69/00/d579dcb2a536b6ea3a2563cdad6844f77d81a9b2d4b22a858097f2468acf/duckdb-1.5.3.tar.gz", hash = "sha256:df39428eb130faa35ae96fd35245bdeae6ecf43936250b116b5fead568eb9f16", size = 18026640, upload-time = "2026-05-20T11:55:31.901Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/c4/2e34929b16c8d544ef664fad8f7f3a2a9db05746aae1e7c8c4ee3a8b23e4/duckdb-1.5.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ff11a457258148337ef9a392148a8cdbd1069b6c27c21958816c7b67fe6c542d", size = 32626494, upload-time = "2026-05-20T11:54:33.738Z" },
{ url = "https://files.pythonhosted.org/packages/3a/53/3af681793d03771365ae3e2215331151c196a3ac8193f613344840694671/duckdb-1.5.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5fd25f533cb1b6b2c84cc767a9a9bab7769bb1aa44571a2a0bfc91ac3e4a38ac", size = 17301121, upload-time = "2026-05-20T11:54:36.928Z" },
{ url = "https://files.pythonhosted.org/packages/15/e2/c80af1eac2ab5d35fc2c372ef0a84668842e549fbbf7799277b3fccf3e39/duckdb-1.5.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:10960400ed60cdf0fe05bab2086fa8eb733889cb0ceca18d07ff9a00c0e0be7b", size = 15449283, upload-time = "2026-05-20T11:54:39.777Z" },
{ url = "https://files.pythonhosted.org/packages/2d/9a/c63af233c9f761bf5178a5210437e1bc6bcb30fa8a9073de6398cfb12c03/duckdb-1.5.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5f18e7561403054433706c187589e86629a7af09a7efc23a06a8b308e6acc68", size = 19332762, upload-time = "2026-05-20T11:54:42.51Z" },
{ url = "https://files.pythonhosted.org/packages/21/cc/2d77af4fff86012f334ef82e6d54a995a86c8745e58074f1218ed7d25171/duckdb-1.5.3-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9fb7516255a8764545e30f7efacea408cc847764a3027b3b0b3e7d1a7bebbc5c", size = 21453290, upload-time = "2026-05-20T11:54:45.272Z" },
{ url = "https://files.pythonhosted.org/packages/8d/5e/9bc4817a98feb4dab83e56f2245cd3a30d00ee646d4dec7926464e2b3f28/duckdb-1.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:8001eccbc28be244dfd04d708526f34ddd6460b47a8aeb5d0e39d6f7f9e3fe15", size = 13118308, upload-time = "2026-05-20T11:54:48.058Z" },
{ url = "https://files.pythonhosted.org/packages/81/35/e3f32e4e53e2450ddb1db8312a17d1ce455d60cc4941b6ad2cfc908794b0/duckdb-1.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:6d2835e39bb6af73891f73c0f8d4324f98afe00d0b00c6d34b2a582c2256cbb0", size = 13927187, upload-time = "2026-05-20T11:54:50.584Z" },
{ url = "https://files.pythonhosted.org/packages/cc/9c/a528eb09d8be51954c485864bd06753e616939a080cbc3dd4417e8c94a57/duckdb-1.5.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e75a6122c12579a99848517f6f00a4e342aebda3590c30fe9b5cc5f39d5e6afc", size = 32626254, upload-time = "2026-05-20T11:54:53.65Z" },
{ url = "https://files.pythonhosted.org/packages/ec/3c/1534c0a6db347c05eb7d0f6ecfb7aefbe74cbff398e4892a8fd1903a20e8/duckdb-1.5.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fd3963c1cb9d9567777f4a898a9dbe388a2fe9724681801b1e7d6d93eecf1b76", size = 17300917, upload-time = "2026-05-20T11:54:56.628Z" },
{ url = "https://files.pythonhosted.org/packages/23/fa/beafb91e6e152d2161c4a9cbc472334c87607eb61ad7104b5a7fa8d8d7b1/duckdb-1.5.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3d5db8c0b55e072cf437948ebb5d7e23d7b9d03d905fa5f9145583e65aa447f7", size = 15449411, upload-time = "2026-05-20T11:54:59.089Z" },
{ url = "https://files.pythonhosted.org/packages/50/0a/49b6fe04e2fcd63729eb607dadd44818dde77342a4f5ce086c6c92f1dd4d/duckdb-1.5.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ce80aed7a538422129a57eaca9141e3afb51f8bf562b1908b1576c9725b5b22", size = 19333120, upload-time = "2026-05-20T11:55:01.727Z" },
{ url = "https://files.pythonhosted.org/packages/63/4c/0907c3f76adb9dd90e67610b31e0304a35814e65c4c41a354a262c09b885/duckdb-1.5.3-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:787df63824f07bf18022dbc3b8ca4b2bfab0ebe616464f55c6e8cd0f59ea762e", size = 21453266, upload-time = "2026-05-20T11:55:04.5Z" },
{ url = "https://files.pythonhosted.org/packages/6d/9c/d2f23a7803ddbbd9413f7572ecf66a15120ed5ced7ce5c73e698c1406b76/duckdb-1.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:bb5bb5dcdd09d62ee60f0ddbbef918e71cce304ffe28428b1131949d39ffaabf", size = 13118640, upload-time = "2026-05-20T11:55:07.389Z" },
{ url = "https://files.pythonhosted.org/packages/27/d5/7ba2316415bcdab6edd765bbbe35c2ca8a3800f2fe695cd70e3cdb997f09/duckdb-1.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:2fa17ecdd5d3db122836cb71bb93601c2106a3be883c17dffddc02fbf3fa7888", size = 13926409, upload-time = "2026-05-20T11:55:10.166Z" },
{ url = "https://files.pythonhosted.org/packages/a5/c2/d4b6f8a5e4d3bc25773be6da76a99d9661ebbf3552c007c460d2dd59dbf8/duckdb-1.5.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:4bfa9a4dadf71e83e2c4eaca2f9421c82a54defecc1b0b4c0be95e2389dec4fe", size = 32636685, upload-time = "2026-05-20T11:55:13.158Z" },
{ url = "https://files.pythonhosted.org/packages/42/58/e835c8298979d29db7a62cb5acc29e9b57aeaca7cdde2fcd3ac980f5cb18/duckdb-1.5.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:aea7baf67ad7e1829ac76f67d7dcbd7fb1f57c3eb179d55ac30952df4709ae30", size = 17308134, upload-time = "2026-05-20T11:55:16.194Z" },
{ url = "https://files.pythonhosted.org/packages/c9/46/617b51363f5613418c8b224b3cce16b58e6dde80904566bec232579c1d4e/duckdb-1.5.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b0b4f088a65d77e1217ce5d7eff889e63fedc44281200d899ff47c84d8ff836", size = 15449891, upload-time = "2026-05-20T11:55:18.687Z" },
{ url = "https://files.pythonhosted.org/packages/b3/72/354146656e8d9ba3853d3a5ee80a481b8c5f70edfc3d5ae80a8c4479c967/duckdb-1.5.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe8d0c1f6a120aa03fa6e0d03897c71a1842e6cf7afd31d181348391f7108fe1", size = 19338499, upload-time = "2026-05-20T11:55:21.34Z" },
{ url = "https://files.pythonhosted.org/packages/56/8f/65fc623b51448f2bfba1a9ec6ab3debb4664c0876c0113a5e782600b53ac/duckdb-1.5.3-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0405eae18ec6e8210a471c97dbfe87a7e4d605274b7fe572a1f276e92158f13", size = 21455828, upload-time = "2026-05-20T11:55:23.847Z" },
{ url = "https://files.pythonhosted.org/packages/2b/db/d0274cbe9f5fe219f77c0bdf900ac77103569e83c102a4225ce04cbc607d/duckdb-1.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:33ae08b3e818d7613d8936744b67718c2062c2f530376895bfd89efb51b81538", size = 13640011, upload-time = "2026-05-20T11:55:26.276Z" },
{ url = "https://files.pythonhosted.org/packages/07/5d/8f1899b8bef291caf953992fcd6c24df9f29387a35645e58c2504a5ca473/duckdb-1.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:746433e49bbc667b4df283153415fbe37e9083e0eff6c3cd6e54de7536869cd4", size = 14411554, upload-time = "2026-05-20T11:55:29.037Z" },
]
[[package]] [[package]]
name = "frozenlist" name = "frozenlist"
version = "1.8.0" version = "1.8.0"
@@ -430,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"
@@ -545,6 +583,7 @@ source = { editable = "." }
dependencies = [ dependencies = [
{ name = "audioop-lts", marker = "python_full_version >= '3.13'" }, { name = "audioop-lts", marker = "python_full_version >= '3.13'" },
{ name = "click" }, { name = "click" },
{ name = "duckdb" },
{ name = "mpv" }, { name = "mpv" },
{ name = "mutagen" }, { name = "mutagen" },
{ name = "pydantic" }, { name = "pydantic" },
@@ -555,19 +594,27 @@ 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" },
{ name = "click", specifier = ">=8.1.8" }, { name = "click", specifier = ">=8.1.8" },
{ name = "duckdb", specifier = ">=1.1.0" },
{ name = "mpv", specifier = ">=1.0.7" }, { name = "mpv", specifier = ">=1.0.7" },
{ 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"
@@ -639,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"
@@ -846,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"