diff --git a/pyproject.toml b/pyproject.toml index 1b92986..8544029 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,7 @@ requires-python = ">=3.12" dependencies = [ "audioop-lts>=0.2.2 ; python_full_version >= '3.13'", "click>=8.1.8", + "duckdb>=1.1.0", "mpv>=1.0.7", "mutagen>=1.47.0", "pydantic>=2.0", diff --git a/src/music/cli.py b/src/music/cli.py index 257230b..98396f9 100644 --- a/src/music/cli.py +++ b/src/music/cli.py @@ -1,7 +1,7 @@ from pydantic import Field 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): @@ -14,6 +14,8 @@ class Cli(BaseSettings): download: CliSubCommand[YtDownload] = Field(alias="yt") stream: CliSubCommand[Stream] = Field(alias="stream") random: CliSubCommand[Random] = Field(alias="random") + kexp: CliSubCommand[Kexp] = Field(alias="kexp") + playlist: CliSubCommand[Playlist] = Field(alias="playlist") def cli_cmd(self) -> None: if (cmd := get_subcommand(self, is_required=False)) is not None: diff --git a/src/music/commands/__init__.py b/src/music/commands/__init__.py index 2285273..7f20f44 100644 --- a/src/music/commands/__init__.py +++ b/src/music/commands/__init__.py @@ -1,5 +1,7 @@ from .download import YtDownload +from .kexp import Kexp +from .playlist import Playlist from .random import Random from .stream import Stream -__all__ = ["YtDownload", "Random", "Stream"] +__all__ = ["YtDownload", "Kexp", "Playlist", "Random", "Stream"] diff --git a/src/music/commands/kexp.py b/src/music/commands/kexp.py new file mode 100644 index 0000000..106ae0c --- /dev/null +++ b/src/music/commands/kexp.py @@ -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() diff --git a/src/music/commands/playlist.py b/src/music/commands/playlist.py new file mode 100644 index 0000000..9a01f95 --- /dev/null +++ b/src/music/commands/playlist.py @@ -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()): + 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)) diff --git a/src/music/commands/stream.py b/src/music/commands/stream.py index d628125..9dfccea 100644 --- a/src/music/commands/stream.py +++ b/src/music/commands/stream.py @@ -19,6 +19,25 @@ class Source(str, Enum): CLIS = "clis" +class Show(str, Enum): + """ + 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 current_quarter() -> Path: date = DateParse() return Path().home() / "Music" / f"{date.quarter}_{date.year}" diff --git a/src/music/dates.py b/src/music/dates.py index de2beca..ed0716b 100644 --- a/src/music/dates.py +++ b/src/music/dates.py @@ -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 pydantic import BaseModel, Field, computed_field @@ -42,3 +45,9 @@ class DateParse(BaseModel): @property def year(self) -> int: 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 diff --git a/uv.lock b/uv.lock index d177656..1861310 100644 --- a/uv.lock +++ b/uv.lock @@ -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" }, ] +[[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]] name = "frozenlist" version = "1.8.0" @@ -545,6 +574,7 @@ source = { editable = "." } dependencies = [ { name = "audioop-lts", marker = "python_full_version >= '3.13'" }, { name = "click" }, + { name = "duckdb" }, { name = "mpv" }, { name = "mutagen" }, { name = "pydantic" }, @@ -559,6 +589,7 @@ dependencies = [ requires-dist = [ { name = "audioop-lts", marker = "python_full_version >= '3.13'", specifier = ">=0.2.2" }, { name = "click", specifier = ">=8.1.8" }, + { name = "duckdb", specifier = ">=1.1.0" }, { name = "mpv", specifier = ">=1.0.7" }, { name = "mutagen", specifier = ">=1.47.0" }, { name = "pydantic", specifier = ">=2.0" },