Skip to content

Commit 6e5d570

Browse files
committed
Add opt-in song identification (ShazamIO), offline-safe & cached
- oida/songid.py: SongIdProvider abstraction (ShazamIOProvider + description fallback), default OFF (OIDA_SONGID toggle), results shaped for an akousma's extensions.songid, cached by audio content hash, graceful offline/no-match so oída's descriptive path is never lost. enrich_akousma() lands results in a record. - shazamio as an optional extra (pip install 'oida[songid]'). - tests: mocked provider (no network) — toggle, match shape, offline, cache, enrich.
1 parent 3221d40 commit 6e5d570

3 files changed

Lines changed: 238 additions & 0 deletions

File tree

oida/songid.py

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
"""Song identification for oída — opt-in, provider-abstracted, offline-safe.
2+
3+
When oída's listening classifies a source as music, an optional "song id" toggle can try to
4+
name the track. Default is OFF. Uses ShazamIO (an *unofficial* API — no SLA; sends an audio
5+
fingerprint, not the raw recording) behind a provider interface, so open-data providers
6+
(AcoustID/MusicBrainz) can be added later without touching callers. On any failure or when
7+
disabled, it degrades gracefully to "no match" and oída's descriptive path is unaffected.
8+
9+
Results are shaped for an akousma's ``extensions.songid`` block.
10+
"""
11+
from __future__ import annotations
12+
13+
import os
14+
import time
15+
from dataclasses import dataclass, field
16+
from hashlib import sha256
17+
from pathlib import Path
18+
from typing import Any, Callable, Protocol
19+
20+
21+
def songid_enabled(explicit: bool | None = None) -> bool:
22+
"""Toggle state: explicit arg wins, else ``OIDA_SONGID`` env (default OFF)."""
23+
if explicit is not None:
24+
return explicit
25+
return os.getenv("OIDA_SONGID", "0").strip().lower() in {"1", "true", "yes", "on"}
26+
27+
28+
@dataclass
29+
class SongIdResult:
30+
provider: str
31+
matched: bool = False
32+
title: str | None = None
33+
artist: str | None = None
34+
album: str | None = None
35+
isrc: str | None = None
36+
track_id: str | None = None
37+
confidence: float | None = None
38+
note: str | None = None
39+
checked_at: str = field(default_factory=lambda: time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()))
40+
41+
def to_extension(self) -> dict[str, Any]:
42+
data = {
43+
"provider": self.provider,
44+
"matched": self.matched,
45+
"checked_at": self.checked_at,
46+
}
47+
for k in ("title", "artist", "album", "isrc", "track_id", "confidence", "note"):
48+
v = getattr(self, k)
49+
if v is not None:
50+
data[k] = v
51+
return data
52+
53+
54+
class SongIdProvider(Protocol):
55+
name: str
56+
57+
def identify(self, audio_path: Path) -> SongIdResult:
58+
...
59+
60+
61+
class DescriptionFallbackProvider:
62+
"""Never matches — signals callers to keep oída's descriptive path."""
63+
64+
name = "description"
65+
66+
def identify(self, audio_path: Path) -> SongIdResult:
67+
return SongIdResult(provider=self.name, matched=False, note="song id disabled; describing instead")
68+
69+
70+
class ShazamIOProvider:
71+
"""ShazamIO-backed identification. Lazy-imports shazamio; any error → matched=False."""
72+
73+
name = "shazamio"
74+
75+
def identify(self, audio_path: Path) -> SongIdResult:
76+
try:
77+
import asyncio
78+
79+
from shazamio import Shazam # optional dep: pip install 'oida[songid]'
80+
81+
async def _run() -> dict[str, Any]:
82+
shazam = Shazam()
83+
recognize = getattr(shazam, "recognize", None) or getattr(shazam, "recognize_song")
84+
return await recognize(str(audio_path))
85+
86+
data = asyncio.run(_run()) or {}
87+
except ModuleNotFoundError:
88+
return SongIdResult(provider=self.name, matched=False, note="shazamio not installed")
89+
except Exception as exc: # network/format/API — stay graceful and offline-safe
90+
return SongIdResult(provider=self.name, matched=False, note=f"offline or unrecognized: {exc}".strip())
91+
92+
track = (data.get("track") or {}) if isinstance(data, dict) else {}
93+
if not track:
94+
return SongIdResult(provider=self.name, matched=False, note="no match")
95+
isrc = track.get("isrc")
96+
if not isrc:
97+
for section in track.get("sections", []) or []:
98+
for meta in section.get("metadata", []) or []:
99+
if str(meta.get("title", "")).upper() == "ISRC":
100+
isrc = meta.get("text")
101+
return SongIdResult(
102+
provider=self.name,
103+
matched=True,
104+
title=track.get("title"),
105+
artist=track.get("subtitle"),
106+
isrc=isrc,
107+
track_id=str(track.get("key")) if track.get("key") is not None else None,
108+
)
109+
110+
111+
def _file_hash(audio_path: Path) -> str:
112+
return sha256(Path(audio_path).read_bytes()).hexdigest()
113+
114+
115+
def identify_song(
116+
audio_path: str | Path,
117+
*,
118+
enabled: bool | None = None,
119+
provider: SongIdProvider | None = None,
120+
cache: dict[str, SongIdResult] | None = None,
121+
) -> dict[str, Any]:
122+
"""Identify the track at ``audio_path`` and return an ``extensions.songid`` dict.
123+
124+
Off by default. Caches by audio content hash so the same fragment is never re-queried.
125+
Never raises for identification failures — returns ``matched=False`` instead.
126+
"""
127+
if not songid_enabled(enabled):
128+
return SongIdResult(provider="description", matched=False, note="song id off").to_extension()
129+
130+
provider = provider or ShazamIOProvider()
131+
path = Path(audio_path).expanduser()
132+
try:
133+
key = _file_hash(path)
134+
except OSError as exc:
135+
return SongIdResult(provider=provider.name, matched=False, note=f"unreadable audio: {exc}").to_extension()
136+
137+
if cache is not None and key in cache:
138+
return cache[key].to_extension()
139+
140+
result = provider.identify(path)
141+
if cache is not None:
142+
cache[key] = result
143+
return result.to_extension()
144+
145+
146+
def enrich_akousma(
147+
record: dict[str, Any],
148+
audio_path: str | Path,
149+
*,
150+
enabled: bool | None = None,
151+
provider: SongIdProvider | None = None,
152+
cache: dict[str, SongIdResult] | None = None,
153+
) -> dict[str, Any]:
154+
"""Attach a song-id result to ``record['extensions']['songid']`` and return the record."""
155+
record.setdefault("extensions", {})["songid"] = identify_song(
156+
audio_path, enabled=enabled, provider=provider, cache=cache
157+
)
158+
return record

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ dev = [
3030
"httpx>=0.27",
3131
"pytest>=8.2"
3232
]
33+
songid = [
34+
"shazamio>=0.4"
35+
]
3336

3437
[project.scripts]
3538
oida = "oida.cli:main"

tests/test_songid.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
"""Song identification: opt-in, offline-safe, cached (mocked provider — no network)."""
2+
import os
3+
import tempfile
4+
import unittest
5+
from pathlib import Path
6+
7+
from oida import songid
8+
9+
10+
class FakeProvider:
11+
def __init__(self, result: songid.SongIdResult):
12+
self.name = "fake"
13+
self.result = result
14+
self.calls = 0
15+
16+
def identify(self, audio_path: Path) -> songid.SongIdResult:
17+
self.calls += 1
18+
return self.result
19+
20+
21+
class TestSongId(unittest.TestCase):
22+
def setUp(self):
23+
self.f = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
24+
self.f.write(b"RIFF....fake-audio-bytes")
25+
self.f.close()
26+
self.path = self.f.name
27+
os.environ.pop("OIDA_SONGID", None)
28+
29+
def tearDown(self):
30+
os.unlink(self.path)
31+
os.environ.pop("OIDA_SONGID", None)
32+
33+
def test_off_by_default(self):
34+
ext = songid.identify_song(self.path)
35+
self.assertFalse(ext["matched"])
36+
self.assertEqual(ext["provider"], "description")
37+
38+
def test_enabled_match_shape(self):
39+
fake = FakeProvider(songid.SongIdResult(
40+
provider="fake", matched=True, title="Windowlicker", artist="Aphex Twin", isrc="GBAAA0000001"
41+
))
42+
ext = songid.identify_song(self.path, enabled=True, provider=fake)
43+
self.assertTrue(ext["matched"])
44+
self.assertEqual(ext["title"], "Windowlicker")
45+
self.assertEqual(ext["artist"], "Aphex Twin")
46+
self.assertEqual(ext["isrc"], "GBAAA0000001")
47+
self.assertIn("checked_at", ext)
48+
49+
def test_offline_degrades_to_no_match(self):
50+
fake = FakeProvider(songid.SongIdResult(provider="fake", matched=False, note="offline"))
51+
ext = songid.identify_song(self.path, enabled=True, provider=fake)
52+
self.assertFalse(ext["matched"])
53+
self.assertEqual(ext["note"], "offline")
54+
55+
def test_cache_by_audio_hash(self):
56+
fake = FakeProvider(songid.SongIdResult(provider="fake", matched=True, title="T"))
57+
cache: dict = {}
58+
songid.identify_song(self.path, enabled=True, provider=fake, cache=cache)
59+
songid.identify_song(self.path, enabled=True, provider=fake, cache=cache)
60+
self.assertEqual(fake.calls, 1) # second call served from cache
61+
62+
def test_enrich_akousma_places_result_in_extensions(self):
63+
fake = FakeProvider(songid.SongIdResult(provider="fake", matched=True, title="T", artist="A"))
64+
record = {"extensions": {}}
65+
songid.enrich_akousma(record, self.path, enabled=True, provider=fake)
66+
self.assertTrue(record["extensions"]["songid"]["matched"])
67+
self.assertEqual(record["extensions"]["songid"]["title"], "T")
68+
69+
def test_env_toggle(self):
70+
fake = FakeProvider(songid.SongIdResult(provider="fake", matched=True, title="T"))
71+
self.assertFalse(songid.identify_song(self.path, provider=fake)["matched"]) # env unset -> off
72+
os.environ["OIDA_SONGID"] = "1"
73+
self.assertTrue(songid.identify_song(self.path, provider=fake)["matched"]) # env on
74+
75+
76+
if __name__ == "__main__":
77+
unittest.main()

0 commit comments

Comments
 (0)