Compare commits
3 Commits
bde1ddf465
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e53178676 | |||
| e0d3af2a4f | |||
| b9f6aaf462 |
@@ -0,0 +1 @@
|
||||
MUSIC_BASE_PATH=/path/to/your/music
|
||||
@@ -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",
|
||||
@@ -22,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"
|
||||
|
||||
+10
-1
@@ -1,7 +1,9 @@
|
||||
from pathlib import Path
|
||||
|
||||
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):
|
||||
@@ -11,9 +13,16 @@ 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")
|
||||
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:
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -19,9 +17,23 @@ class Source(str, Enum):
|
||||
CLIS = "clis"
|
||||
|
||||
|
||||
def current_quarter() -> Path:
|
||||
date = DateParse()
|
||||
return Path().home() / "Music" / f"{date.quarter}_{date.year}"
|
||||
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 url_for_time(t) -> str:
|
||||
|
||||
+10
-1
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
@@ -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"
|
||||
@@ -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" },
|
||||
]
|
||||
|
||||
[[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"
|
||||
@@ -545,6 +583,7 @@ source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "audioop-lts", marker = "python_full_version >= '3.13'" },
|
||||
{ name = "click" },
|
||||
{ name = "duckdb" },
|
||||
{ name = "mpv" },
|
||||
{ name = "mutagen" },
|
||||
{ name = "pydantic" },
|
||||
@@ -555,19 +594,27 @@ 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" },
|
||||
{ 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" },
|
||||
{ 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"
|
||||
@@ -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" },
|
||||
]
|
||||
|
||||
[[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"
|
||||
@@ -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" },
|
||||
]
|
||||
|
||||
[[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"
|
||||
|
||||
Reference in New Issue
Block a user