Skip to content

Commit b13a4fa

Browse files
authored
Tidal Metadata Plugin (#6520)
## Description This PR introduces tidal as metadatasource. It add both an minimal api layer and the typical metadata source plugin capabilities. ### Details The implementation provides a small API layer consisting of `TidalAPI` for high-level album and track fetching, and `TidalSession` which extends `requests.Session` with token authentication, automatic rate limiting (~4 req/s via `RateLimitAdapter`), and pagination resolution following the JSON:API spec. Authentication is handled through an OAuth2 PKCE flow accessible via `beet tidal --auth`, with automatic token refresh when the access token expires. Metadata parsing handles Tidal's JSON:API response format, extracting album and track information including ISO 8601 duration conversion, artist relationships, and copyright/label data. ## Input wanted The API layer currently lacks comprehensive test coverage. Setting up proper tests would require either mocking all outgoing requests or creating a dedicated test token (which necessitates an account and might require read/write to github secrets). Are we comfortable with the current approach of unit testing the plugin itself while mocking all requests? ## TODOs - [x] Documentation - [x] `candidate` and `item_candidates` lookup - [x] It should be possible to optimize batched lookups - [x] Add tests for candidates and item_candidates - [x] Implement batching for more than 20 filters ## Refs thanks to @jcjordyn130 for his initial implementations in #5637 and #4641
2 parents c9cee0d + aa17a50 commit b13a4fa

14 files changed

Lines changed: 1795 additions & 1 deletion

File tree

.github/CODEOWNERS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
# Specific ownerships:
55
/beets/metadata_plugins.py @semohr
6+
/beetsplug/tidal/* @semohr
67

78
/beetsplug/titlecase.py @henry-oberholtzer
89

beets/util/id_extractors.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
# - https://github.com/snejus/beetcamp. Bandcamp album URLs usually look
4444
# like: https://nameofartist.bandcamp.com/album/nameofalbum
4545
"bandcamp": re.compile(r"(.+)"),
46-
"tidal": re.compile(r"([^/]+)$"),
46+
"tidal": re.compile(r"(?:^|tidal\.com/(?:browse/)?(?:album|track)/)(\d+)"),
4747
}
4848

4949

beetsplug/_utils/requests.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import atexit
44
import threading
5+
import time
56
from contextlib import contextmanager
67
from functools import cached_property
78
from http import HTTPStatus
@@ -103,6 +104,38 @@ def request(self, *args, **kwargs):
103104
return r
104105

105106

107+
class RateLimitAdapter(HTTPAdapter):
108+
"""HTTPAdapter that enforces minimum interval between requests.
109+
110+
Prevents server overload and 429 errors by sleeping when requests
111+
come too fast. Thread-safe via lock.
112+
113+
Attributes:
114+
rate_limit: Minimum seconds between requests. Default 0.25 (4/sec).
115+
116+
Override `_wait_time()` for custom strategies (token bucket, burst, etc.).
117+
"""
118+
119+
def __init__(self, rate_limit: float = 0.25, **kwargs):
120+
super().__init__(**kwargs)
121+
self.rate_limit = rate_limit
122+
self._last_request_time = 0.0
123+
self._lock = threading.Lock()
124+
125+
def _wait_time(self, elapsed: float) -> float:
126+
"""Return seconds to wait. Override for custom rate limiting."""
127+
return max(0, self.rate_limit - elapsed)
128+
129+
def send(self, request: requests.PreparedRequest, *args, **kwargs):
130+
with self._lock:
131+
elapsed = time.monotonic() - self._last_request_time
132+
wait = self._wait_time(elapsed)
133+
if wait > 0:
134+
time.sleep(wait)
135+
self._last_request_time = time.monotonic()
136+
return super().send(request, *args, **kwargs)
137+
138+
106139
class RequestHandler:
107140
"""Manages HTTP requests with custom error handling and session management.
108141

0 commit comments

Comments
 (0)