171 lines
5.3 KiB
Python
171 lines
5.3 KiB
Python
"""Provider-neutral data types.
|
||||
|
|
|
|||
|
|
Every radio catalogue — SomaFM today, anything else later — is reduced to
|
|||
|
|
:class:`Station` and :class:`Song` before the UI sees it, so the widgets and
|
|||
|
|
the app never have to know which catalogue a station came from. The parsers
|
|||
|
|
at the bottom are the small, boring helpers (numbers, dates, "Artist - Title"
|
|||
|
|
strings) that every provider ends up needing.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import re
|
|||
|
|
from dataclasses import dataclass, field
|
|||
|
|
from datetime import datetime, timezone
|
|||
|
|
from typing import Any
|
|||
|
|
|
|||
|
|
_QUALITY_RANK = {"highest": 0, "high": 1, "low": 2}
|
|||
|
|
_FORMAT_RANK = {"mp3": 0, "aac": 1, "aacp": 2}
|
|||
|
|
_FORMAT_LABEL = {"aacp": "aac+", "aac": "aac", "mp3": "mp3"}
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass(slots=True)
|
|||
|
|
class Song:
|
|||
|
|
title: str = ""
|
|||
|
|
artist: str = ""
|
|||
|
|
album: str = ""
|
|||
|
|
art_url: str = ""
|
|||
|
|
bandcamp_url: str = ""
|
|||
|
|
played_at: datetime | None = None
|
|||
|
|
|
|||
|
|
@property
|
|||
|
|
def display_title(self) -> str:
|
|||
|
|
return self.title.strip() or "Unknown track"
|
|||
|
|
|
|||
|
|
@property
|
|||
|
|
def display_artist(self) -> str:
|
|||
|
|
return self.artist.strip() or "Unknown artist"
|
|||
|
|
|
|||
|
|
def key(self) -> str:
|
|||
|
|
return f"{self.artist}\x1f{self.title}\x1f{self.album}"
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass(slots=True)
|
|||
|
|
class Station:
|
|||
|
|
id: str
|
|||
|
|
title: str
|
|||
|
|
description: str = ""
|
|||
|
|
genre: str = ""
|
|||
|
|
dj: str = ""
|
|||
|
|
image: str = ""
|
|||
|
|
listeners: int = 0
|
|||
|
|
last_playing: str = ""
|
|||
|
|
playlists: list[dict[str, str]] = field(default_factory=list)
|
|||
|
|
now_playing: Song | None = None
|
|||
|
|
history: list[Song] = field(default_factory=list)
|
|||
|
|
favorite: bool = False
|
|||
|
|
#: Id of the provider that produced this station (see
|
|||
|
|
#: :mod:`beefy_player.providers`). Station ids are only unique within a
|
|||
|
|
#: catalogue, so anything persisted across providers should use
|
|||
|
|
#: :attr:`uid` instead.
|
|||
|
|
provider: str = ""
|
|||
|
|
|
|||
|
|
@property
|
|||
|
|
def uid(self) -> str:
|
|||
|
|
"""Provider-qualified id, stable once more than one catalogue exists."""
|
|||
|
|
return f"{self.provider}:{self.id}" if self.provider else self.id
|
|||
|
|
|
|||
|
|
@property
|
|||
|
|
def genre_label(self) -> str:
|
|||
|
|
return self.genre.replace("|", " · ") or "eclectic"
|
|||
|
|
|
|||
|
|
def sorted_playlists(self) -> list[dict[str, str]]:
|
|||
|
|
return sorted(
|
|||
|
|
self.playlists,
|
|||
|
|
key=lambda pl: (
|
|||
|
|
_QUALITY_RANK.get(pl.get("quality", ""), 3),
|
|||
|
|
_FORMAT_RANK.get(pl.get("format", ""), 3),
|
|||
|
|
),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
@property
|
|||
|
|
def format_label(self) -> str:
|
|||
|
|
playlists = self.sorted_playlists()
|
|||
|
|
if not playlists:
|
|||
|
|
return "stream"
|
|||
|
|
first = playlists[0]
|
|||
|
|
fmt = _FORMAT_LABEL.get(
|
|||
|
|
first.get("format", ""), first.get("format") or "stream"
|
|||
|
|
)
|
|||
|
|
return f"{fmt} · {guess_bitrate(first.get('url', ''))}k"
|
|||
|
|
|
|||
|
|
|
|||
|
|
# --------------------------------------------------------------------------- #
|
|||
|
|
# small, provider-agnostic parsers
|
|||
|
|
# --------------------------------------------------------------------------- #
|
|||
|
|
|
|||
|
|
|
|||
|
|
def guess_bitrate(url: str) -> int:
|
|||
|
|
match = re.search(
|
|||
|
|
r"(256|192|130|128|96|64|48|32)(?=\.pls|\.m3u|-mp3|-aac)", url
|
|||
|
|
)
|
|||
|
|
if not match:
|
|||
|
|
return 128
|
|||
|
|
value = int(match.group(1))
|
|||
|
|
return 128 if value == 130 else value
|
|||
|
|
|
|||
|
|
|
|||
|
|
def to_int(value: Any, default: int = 0) -> int:
|
|||
|
|
"""Forgiving ``int`` — catalogues like to send ``"1,873"`` as text."""
|
|||
|
|
if isinstance(value, bool):
|
|||
|
|
return default
|
|||
|
|
if isinstance(value, int):
|
|||
|
|
return value
|
|||
|
|
if isinstance(value, float):
|
|||
|
|
return int(value)
|
|||
|
|
if isinstance(value, str):
|
|||
|
|
digits = re.sub(r"[^0-9]", "", value)
|
|||
|
|
if digits:
|
|||
|
|
try:
|
|||
|
|
return int(digits)
|
|||
|
|
except ValueError:
|
|||
|
|
return default
|
|||
|
|
return default
|
|||
|
|
|
|||
|
|
|
|||
|
|
def parse_date(value: Any) -> datetime | None:
|
|||
|
|
"""Unix timestamp, ISO 8601 or one of the usual ``%Y-%m-%d`` shapes."""
|
|||
|
|
if value is None or value == "":
|
|||
|
|
return None
|
|||
|
|
if isinstance(value, (int, float)):
|
|||
|
|
try:
|
|||
|
|
return datetime.fromtimestamp(float(value), tz=timezone.utc)
|
|||
|
|
except (OverflowError, OSError, ValueError):
|
|||
|
|
return None
|
|||
|
|
if isinstance(value, str):
|
|||
|
|
text = value.strip()
|
|||
|
|
if text.isdigit():
|
|||
|
|
try:
|
|||
|
|
return datetime.fromtimestamp(int(text), tz=timezone.utc)
|
|||
|
|
except (OverflowError, OSError, ValueError):
|
|||
|
|
return None
|
|||
|
|
try:
|
|||
|
|
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
|
|||
|
|
except ValueError:
|
|||
|
|
parsed = None
|
|||
|
|
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y/%m/%d %H:%M:%S"):
|
|||
|
|
try:
|
|||
|
|
parsed = datetime.strptime(text, fmt)
|
|||
|
|
break
|
|||
|
|
except ValueError:
|
|||
|
|
continue
|
|||
|
|
if parsed is not None:
|
|||
|
|
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
|
|||
|
|
_SEPARATORS = (" - ", " – ", " — ", " -- ")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def parse_last_playing(text: str | None) -> Song | None:
|
|||
|
|
"""Split the ``"Artist - Title"`` blurb most catalogues send."""
|
|||
|
|
if not isinstance(text, str):
|
|||
|
|
return None
|
|||
|
|
cleaned = text.strip()
|
|||
|
|
if not cleaned:
|
|||
|
|
return None
|
|||
|
|
for separator in _SEPARATORS:
|
|||
|
|
if separator in cleaned:
|
|||
|
|
artist, title = cleaned.split(separator, 1)
|
|||
|
|
return Song(title=title.strip(), artist=artist.strip())
|
|||
|
|
return Song(title=cleaned)
|