slipmat-mlc 2026.7__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- slipmat_mlc-2026.7/PKG-INFO +344 -0
- slipmat_mlc-2026.7/README.md +327 -0
- slipmat_mlc-2026.7/pyproject.toml +49 -0
- slipmat_mlc-2026.7/src/mlc/.DS_Store +0 -0
- slipmat_mlc-2026.7/src/mlc/__init__.py +48 -0
- slipmat_mlc-2026.7/src/mlc/adapters/__init__.py +107 -0
- slipmat_mlc-2026.7/src/mlc/adapters/apple.py +565 -0
- slipmat_mlc-2026.7/src/mlc/adapters/factory.py +61 -0
- slipmat_mlc-2026.7/src/mlc/adapters/spotify.py +543 -0
- slipmat_mlc-2026.7/src/mlc/adapters/tidal.py +719 -0
- slipmat_mlc-2026.7/src/mlc/adapters/youtube.py +419 -0
- slipmat_mlc-2026.7/src/mlc/config.py +91 -0
- slipmat_mlc-2026.7/src/mlc/converter.py +574 -0
- slipmat_mlc-2026.7/src/mlc/datamodels.py +246 -0
- slipmat_mlc-2026.7/src/mlc/exceptions.py +54 -0
- slipmat_mlc-2026.7/src/mlc/logging.py +42 -0
- slipmat_mlc-2026.7/src/mlc/parsers/__init__.py +0 -0
- slipmat_mlc-2026.7/src/mlc/parsers/youtube.py +241 -0
- slipmat_mlc-2026.7/src/mlc/tests/__init__.py +0 -0
- slipmat_mlc-2026.7/src/mlc/tests/test_apple_adapter.py +761 -0
- slipmat_mlc-2026.7/src/mlc/tests/test_cache_and_non_music.py +185 -0
- slipmat_mlc-2026.7/src/mlc/tests/test_config.py +175 -0
- slipmat_mlc-2026.7/src/mlc/tests/test_converter.py +1055 -0
- slipmat_mlc-2026.7/src/mlc/tests/test_factory.py +204 -0
- slipmat_mlc-2026.7/src/mlc/tests/test_is_valid_song.py +109 -0
- slipmat_mlc-2026.7/src/mlc/tests/test_logging.py +19 -0
- slipmat_mlc-2026.7/src/mlc/tests/test_models.py +327 -0
- slipmat_mlc-2026.7/src/mlc/tests/test_spotify_adapter.py +534 -0
- slipmat_mlc-2026.7/src/mlc/tests/test_tidal_adapter.py +729 -0
- slipmat_mlc-2026.7/src/mlc/tests/test_youtube_adapter.py +500 -0
- slipmat_mlc-2026.7/src/mlc/tests/test_youtube_parser.py +51 -0
- slipmat_mlc-2026.7/src/mlc/tests/utils.py +77 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: slipmat-mlc
|
|
3
|
+
Version: 2026.7
|
|
4
|
+
Summary: Slipmat Music Link Converter
|
|
5
|
+
Author: Ville Säävuori
|
|
6
|
+
Author-email: Ville Säävuori <ville@slipmat.io>
|
|
7
|
+
Requires-Dist: cryptography>=49.0.0
|
|
8
|
+
Requires-Dist: environs>=15.0.1
|
|
9
|
+
Requires-Dist: google-api-python-client>=2.198.0
|
|
10
|
+
Requires-Dist: httpcore>=1.0.9
|
|
11
|
+
Requires-Dist: httpx>=0.28.1
|
|
12
|
+
Requires-Dist: pydantic>=2.13.4
|
|
13
|
+
Requires-Dist: pyjwt>=2.13.0
|
|
14
|
+
Requires-Dist: structlog>=26.1.0
|
|
15
|
+
Requires-Python: >=3.14
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# Slipmat Music Link Converter
|
|
19
|
+
|
|
20
|
+
A Python library for converting music streaming service links between platforms. Supports bidirectional conversion between Spotify, Apple Music, Tidal, and YouTube Music for songs, albums, and artists.
|
|
21
|
+
|
|
22
|
+
## Features
|
|
23
|
+
|
|
24
|
+
- **Supported Services:** Apple Music, Spotify, Tidal, and YouTube Music
|
|
25
|
+
- **Supported Item Types:** Songs (Tracks), Albums, Artists
|
|
26
|
+
- **Bidirectional Conversion:** Convert from any supported service to any other configured service
|
|
27
|
+
- **Asynchronous:** Built with `asyncio` for efficient I/O operations
|
|
28
|
+
- **Smart Caching:** Bounded in-memory LRU cache per converter, keyed by normalized URL
|
|
29
|
+
- **Smart YouTube Filtering:** Automatically filters out non-music YouTube videos
|
|
30
|
+
- **URL Validation:** `is_song` method quickly validates if a URL points to music content
|
|
31
|
+
- **Error Handling:** Includes specific exceptions for common issues
|
|
32
|
+
|
|
33
|
+
## Installation
|
|
34
|
+
|
|
35
|
+
Requires **Python 3.14 or newer**.
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
uv add slipmat-mlc
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Configuration
|
|
42
|
+
|
|
43
|
+
The library requires API credentials for each service you want to use. These are loaded from environment variables.
|
|
44
|
+
|
|
45
|
+
1. **Create an `.env` file** in your project root.
|
|
46
|
+
2. **Add credentials** for the services you want to use:
|
|
47
|
+
|
|
48
|
+
```dotenv
|
|
49
|
+
# Apple Music
|
|
50
|
+
APPLE_MUSIC_TEAM_ID="your_apple_developer_team_id"
|
|
51
|
+
APPLE_MUSIC_KEY_ID="your_apple_music_key_id"
|
|
52
|
+
# The secret key should be the base64-encoded content of your .p8 file:
|
|
53
|
+
# uv run python -c "import base64; print(base64.b64encode(open('AuthKey_YOUR_KEY_ID.p8', 'rb').read()).decode())"
|
|
54
|
+
APPLE_MUSIC_SECRET_KEY="your_base64_encoded_private_key_content"
|
|
55
|
+
|
|
56
|
+
# Spotify
|
|
57
|
+
SPOTIFY_CLIENT_ID="your_spotify_client_id"
|
|
58
|
+
SPOTIFY_CLIENT_SECRET="your_spotify_client_secret"
|
|
59
|
+
|
|
60
|
+
# Tidal
|
|
61
|
+
TIDAL_CLIENT_ID="foo"
|
|
62
|
+
TIDAL_CLIENT_SECRET="foo"
|
|
63
|
+
|
|
64
|
+
# YouTube Music
|
|
65
|
+
YOUTUBE_API_KEY="your_youtube_data_api_v3_key"
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Basic Usage
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
import asyncio
|
|
72
|
+
from mlc import MusicLinkConverter, Config, Service
|
|
73
|
+
|
|
74
|
+
async def main():
|
|
75
|
+
# Load configuration
|
|
76
|
+
config = Config.from_env()
|
|
77
|
+
|
|
78
|
+
# Create converter
|
|
79
|
+
converter = MusicLinkConverter.create(
|
|
80
|
+
config=config,
|
|
81
|
+
services=[Service.APPLE_MUSIC, Service.SPOTIFY, Service.TIDAL, Service.YOUTUBE_MUSIC]
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# Convert a Spotify track URL
|
|
85
|
+
url = "https://open.spotify.com/track/4cOdK2wGLETKBW3PvgPWqT"
|
|
86
|
+
result = await converter.convert(url)
|
|
87
|
+
|
|
88
|
+
# Access the results
|
|
89
|
+
print(f"Original URL: {result.original_url}")
|
|
90
|
+
print(f"Normalized URL: {result.normalized_source_url}")
|
|
91
|
+
print(f"Title: {result.metadata.title}")
|
|
92
|
+
print(f"Artists: {', '.join(result.metadata.artists)}")
|
|
93
|
+
|
|
94
|
+
# Alternative links on other services
|
|
95
|
+
for alt in result.alternatives:
|
|
96
|
+
print(f"{alt.service.value}: {alt.url} (Confidence: {alt.confidence:.2f})")
|
|
97
|
+
|
|
98
|
+
if __name__ == "__main__":
|
|
99
|
+
asyncio.run(main())
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Advanced Usage
|
|
103
|
+
|
|
104
|
+
### Resource Lifecycle
|
|
105
|
+
|
|
106
|
+
If you do not pass your own `httpx.AsyncClient`, the converter creates and owns one.
|
|
107
|
+
Close it with `await converter.close()` or use the async context manager:
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
import asyncio
|
|
111
|
+
from mlc import MusicLinkConverter, Config, Service
|
|
112
|
+
|
|
113
|
+
async def main():
|
|
114
|
+
config = Config.from_env()
|
|
115
|
+
async with MusicLinkConverter.create(
|
|
116
|
+
config=config,
|
|
117
|
+
services=[Service.SPOTIFY, Service.APPLE_MUSIC, Service.YOUTUBE_MUSIC],
|
|
118
|
+
) as converter:
|
|
119
|
+
result = await converter.convert("https://open.spotify.com/track/4cOdK2wGLETKBW3PvgPWqT")
|
|
120
|
+
print(result.metadata.title)
|
|
121
|
+
|
|
122
|
+
asyncio.run(main())
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
If you pass a client to `create(..., client=...)`, you own its lifecycle and should close it yourself.
|
|
126
|
+
|
|
127
|
+
### Converting Multiple URLs Concurrently
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
import asyncio
|
|
131
|
+
from mlc import MusicLinkConverter, Config, Service
|
|
132
|
+
|
|
133
|
+
async def convert_multiple(urls: list[str]):
|
|
134
|
+
config = Config.from_env()
|
|
135
|
+
converter = MusicLinkConverter.create(
|
|
136
|
+
config=config,
|
|
137
|
+
services=[Service.SPOTIFY, Service.APPLE_MUSIC, Service.YOUTUBE_MUSIC]
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
# Convert URLs concurrently
|
|
141
|
+
results = await asyncio.gather(
|
|
142
|
+
*(converter.convert(url) for url in urls),
|
|
143
|
+
return_exceptions=True
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
for url, result in zip(urls, results):
|
|
147
|
+
if isinstance(result, Exception):
|
|
148
|
+
print(f"Error converting {url}: {result}")
|
|
149
|
+
else:
|
|
150
|
+
print(f"{result.metadata.title} by {', '.join(result.metadata.artists)}")
|
|
151
|
+
print(f" Alternatives: {len(result.alternatives)}")
|
|
152
|
+
|
|
153
|
+
# Example usage
|
|
154
|
+
urls = [
|
|
155
|
+
"https://open.spotify.com/track/0pakiWeYJcqrqka4SAaqa6",
|
|
156
|
+
"https://music.apple.com/us/album/tulivuoria/368880626?i=368880680",
|
|
157
|
+
"https://music.youtube.com/watch?v=zUSeGUsY1zk",
|
|
158
|
+
]
|
|
159
|
+
asyncio.run(convert_multiple(urls))
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
### Validating Music URLs
|
|
163
|
+
|
|
164
|
+
Check if a URL points to valid music content. For YouTube, this verifies the video is actually a music video.
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
import asyncio
|
|
168
|
+
from mlc import MusicLinkConverter, Config, Service
|
|
169
|
+
|
|
170
|
+
async def validate_urls(urls: list[str]):
|
|
171
|
+
config = Config.from_env()
|
|
172
|
+
converter = MusicLinkConverter.create(
|
|
173
|
+
config=config,
|
|
174
|
+
services=[Service.SPOTIFY, Service.APPLE_MUSIC, Service.YOUTUBE_MUSIC]
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
for url in urls:
|
|
178
|
+
is_valid = await converter.is_song(url)
|
|
179
|
+
status = "✓ Valid music" if is_valid else "✗ Not music"
|
|
180
|
+
print(f"{url}: {status}")
|
|
181
|
+
|
|
182
|
+
urls = [
|
|
183
|
+
"https://open.spotify.com/track/4cOdK2wGLETKBW3PvgPWqT", # Valid track
|
|
184
|
+
"https://www.youtube.com/watch?v=dQw4w9WgXcQ", # Music video
|
|
185
|
+
"https://www.youtube.com/watch?v=someRandomVideo", # Non-music video
|
|
186
|
+
]
|
|
187
|
+
asyncio.run(validate_urls(urls))
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### Caching Behavior
|
|
191
|
+
|
|
192
|
+
- Cache is **per converter instance** and stored in memory only.
|
|
193
|
+
- Entries are keyed by the **normalized source URL** and use an LRU policy (default max 128).
|
|
194
|
+
- Successful metadata fetches are cached and reused across `is_song()` and `convert()`.
|
|
195
|
+
- Non-music YouTube results are cached as negative entries to avoid repeat API calls.
|
|
196
|
+
|
|
197
|
+
### Validation and Non-Music Content
|
|
198
|
+
|
|
199
|
+
- `MusicItemMetadata` enforces strict validation; missing values are `None` (never placeholder URLs).
|
|
200
|
+
- For **non-music YouTube videos**, `is_song()` returns `False`, and `convert()` returns:
|
|
201
|
+
- `metadata.title == "Non-music content"`
|
|
202
|
+
- `metadata.artists == []`
|
|
203
|
+
- `alternatives == []`
|
|
204
|
+
- Unsupported URLs raise `UnsupportedUrlError` in `convert()` and return `False` in `is_song()`.
|
|
205
|
+
|
|
206
|
+
### Error Handling
|
|
207
|
+
|
|
208
|
+
```python
|
|
209
|
+
from mlc import MusicLinkConverter, Config, Service
|
|
210
|
+
from mlc.exceptions import (
|
|
211
|
+
MusicConverterError,
|
|
212
|
+
UnsupportedUrlError
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
async def safe_convert(converter, url):
|
|
216
|
+
try:
|
|
217
|
+
result = await converter.convert(url)
|
|
218
|
+
return result
|
|
219
|
+
except UnsupportedUrlError:
|
|
220
|
+
print(f"URL not supported: {url}")
|
|
221
|
+
except MusicConverterError as e:
|
|
222
|
+
print(f"Conversion error: {e}")
|
|
223
|
+
except Exception as e:
|
|
224
|
+
print(f"Unexpected error: {e}")
|
|
225
|
+
return None
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
## API Reference
|
|
229
|
+
|
|
230
|
+
### Core Classes
|
|
231
|
+
|
|
232
|
+
- **`MusicLinkConverter`**
|
|
233
|
+
- `create(config, services, client=None, logger=None)` - Create a converter instance
|
|
234
|
+
- `async close()` - Close the owned HTTP client (no-op if a client was provided)
|
|
235
|
+
- `async convert(url)` - Convert a music URL to other services
|
|
236
|
+
- `async find_all_matches(url)` - Fetch complete metadata-backed matches and per-service outcomes
|
|
237
|
+
- `async is_song(url)` - Check if URL points to valid music content
|
|
238
|
+
- `async validate_and_normalize_url(url)` - Validate and normalize a URL
|
|
239
|
+
|
|
240
|
+
- **`Config`**
|
|
241
|
+
- `from_env(path=None)` - Load configuration from environment variables
|
|
242
|
+
|
|
243
|
+
### Models
|
|
244
|
+
|
|
245
|
+
- **`Service`** - Enum of supported services: `SPOTIFY`, `APPLE_MUSIC`, `TIDAL`, `YOUTUBE_MUSIC`
|
|
246
|
+
- **`ItemType`** - Enum of item types: `SONG`, `ALBUM`, `ARTIST`
|
|
247
|
+
- **`ConversionResult`** - Result from `convert()`:
|
|
248
|
+
- `original_url: str` - The URL as provided to convert()
|
|
249
|
+
- `normalized_source_url: str` - Normalized version of the source URL
|
|
250
|
+
- `metadata: MusicItemMetadata` - Item metadata
|
|
251
|
+
- `alternatives: list[ServiceLink]` - Links on other services
|
|
252
|
+
- **`UnifiedConversionResult`** - Result from `find_all_matches()`:
|
|
253
|
+
- `query_url: str` - The URL as provided to find_all_matches()
|
|
254
|
+
- `matches: list[ServiceMatch]` - Source + matched items with full metadata
|
|
255
|
+
- `outcomes: list[ServiceLookupOutcome]` - Per-service status (`SOURCE`, `MATCHED`, `NOT_FOUND`, `SEARCH_ERROR`, `METADATA_ERROR`)
|
|
256
|
+
- **`MusicItemMetadata`** - Standardized metadata:
|
|
257
|
+
- `title: str`
|
|
258
|
+
- `artists: list[str]`
|
|
259
|
+
- `item_type: ItemType`
|
|
260
|
+
- `album_title: str | None`
|
|
261
|
+
- `album_id: str | None`
|
|
262
|
+
- `release_year: int | None`
|
|
263
|
+
- `duration_s: int | None`
|
|
264
|
+
- `image_url: str | None`
|
|
265
|
+
- `isrc: str | None` - For songs
|
|
266
|
+
- `upc: str | None` - For albums
|
|
267
|
+
- **`ServiceLink`** - Alternative link:
|
|
268
|
+
- `service: Service`
|
|
269
|
+
- `url: str`
|
|
270
|
+
- `item_type: ItemType`
|
|
271
|
+
- `item_id: str`
|
|
272
|
+
- `confidence: float` - Match confidence score
|
|
273
|
+
|
|
274
|
+
### Utilities
|
|
275
|
+
|
|
276
|
+
- **`configure_logging(level)`** - Set structlog log level ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL")
|
|
277
|
+
- **`get_logger(name="mlc")`** - Get a structlog logger
|
|
278
|
+
|
|
279
|
+
## Supported URL Formats
|
|
280
|
+
|
|
281
|
+
### Spotify
|
|
282
|
+
|
|
283
|
+
- `https://open.spotify.com/track/{id}`
|
|
284
|
+
- `https://open.spotify.com/album/{id}`
|
|
285
|
+
- `https://open.spotify.com/artist/{id}`
|
|
286
|
+
- `https://open.spotify.com/intl-{locale}/track/{id}` (and album/artist variants)
|
|
287
|
+
|
|
288
|
+
### Apple Music
|
|
289
|
+
|
|
290
|
+
- `https://music.apple.com/{locale}/album/{name}/{id}?i={song_id}` (Song)
|
|
291
|
+
- `https://music.apple.com/{locale}/album/{name}/{id}` (Album)
|
|
292
|
+
- `https://music.apple.com/{locale}/artist/{name}/{id}` (Artist)
|
|
293
|
+
- `https://embed.music.apple.com/{locale}/album/{name}/{id}` (Album)
|
|
294
|
+
- `https://embed.music.apple.com/{locale}/album/{name}/{id}?i={song_id}` (Song)
|
|
295
|
+
- `https://embed.music.apple.com/{locale}/artist/{name}/{id}` (Artist)
|
|
296
|
+
|
|
297
|
+
### YouTube Music / YouTube
|
|
298
|
+
|
|
299
|
+
- `https://music.youtube.com/watch?v={id}` (Song)
|
|
300
|
+
- `https://www.youtube.com/watch?v={id}` (Song - must be music video)
|
|
301
|
+
- `https://music.youtube.com/playlist?list={id}` (Album)
|
|
302
|
+
- `https://www.youtube.com/playlist?list={id}` (Album)
|
|
303
|
+
- `https://music.youtube.com/channel/{id}` (Artist)
|
|
304
|
+
- `https://www.youtube.com/channel/{id}` (Artist)
|
|
305
|
+
- `https://music.youtube.com/browse/{id}` (Artist, `UC...` IDs)
|
|
306
|
+
|
|
307
|
+
### Tidal
|
|
308
|
+
|
|
309
|
+
- `https://tidal.com/track/{id}`
|
|
310
|
+
- `https://tidal.com/album/{id}`
|
|
311
|
+
- `https://tidal.com/artist/{id}`
|
|
312
|
+
- `https://listen.tidal.com/browse/track/{id}`
|
|
313
|
+
- `https://listen.tidal.com/browse/album/{id}`
|
|
314
|
+
- `https://listen.tidal.com/browse/artist/{id}`
|
|
315
|
+
- `https://listen.tidal.com/browse/album/{album_id}/track/{track_id}`
|
|
316
|
+
|
|
317
|
+
## Development
|
|
318
|
+
|
|
319
|
+
### Running Tests
|
|
320
|
+
|
|
321
|
+
```bash
|
|
322
|
+
uv run pytest
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
### Code Quality
|
|
326
|
+
|
|
327
|
+
```bash
|
|
328
|
+
uv run ruff format .
|
|
329
|
+
uv run ruff check --fix --extend-fixable F401 .
|
|
330
|
+
uv run ty check
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
### Adding a New Service
|
|
334
|
+
|
|
335
|
+
To add support for a new streaming service:
|
|
336
|
+
|
|
337
|
+
1. Add the service to `ServiceName` enum in `src/mlc/datamodels.py`
|
|
338
|
+
2. Create a configuration class in `src/mlc/config.py`
|
|
339
|
+
3. Implement the service adapter in `src/mlc/adapters/`
|
|
340
|
+
4. Update the factory in `src/mlc/adapters/factory.py`
|
|
341
|
+
5. Add tests for your implementation
|
|
342
|
+
6. Update documentation
|
|
343
|
+
|
|
344
|
+
See existing adapters for implementation patterns and best practices.
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
# Slipmat Music Link Converter
|
|
2
|
+
|
|
3
|
+
A Python library for converting music streaming service links between platforms. Supports bidirectional conversion between Spotify, Apple Music, Tidal, and YouTube Music for songs, albums, and artists.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Supported Services:** Apple Music, Spotify, Tidal, and YouTube Music
|
|
8
|
+
- **Supported Item Types:** Songs (Tracks), Albums, Artists
|
|
9
|
+
- **Bidirectional Conversion:** Convert from any supported service to any other configured service
|
|
10
|
+
- **Asynchronous:** Built with `asyncio` for efficient I/O operations
|
|
11
|
+
- **Smart Caching:** Bounded in-memory LRU cache per converter, keyed by normalized URL
|
|
12
|
+
- **Smart YouTube Filtering:** Automatically filters out non-music YouTube videos
|
|
13
|
+
- **URL Validation:** `is_song` method quickly validates if a URL points to music content
|
|
14
|
+
- **Error Handling:** Includes specific exceptions for common issues
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
Requires **Python 3.14 or newer**.
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
uv add slipmat-mlc
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Configuration
|
|
25
|
+
|
|
26
|
+
The library requires API credentials for each service you want to use. These are loaded from environment variables.
|
|
27
|
+
|
|
28
|
+
1. **Create an `.env` file** in your project root.
|
|
29
|
+
2. **Add credentials** for the services you want to use:
|
|
30
|
+
|
|
31
|
+
```dotenv
|
|
32
|
+
# Apple Music
|
|
33
|
+
APPLE_MUSIC_TEAM_ID="your_apple_developer_team_id"
|
|
34
|
+
APPLE_MUSIC_KEY_ID="your_apple_music_key_id"
|
|
35
|
+
# The secret key should be the base64-encoded content of your .p8 file:
|
|
36
|
+
# uv run python -c "import base64; print(base64.b64encode(open('AuthKey_YOUR_KEY_ID.p8', 'rb').read()).decode())"
|
|
37
|
+
APPLE_MUSIC_SECRET_KEY="your_base64_encoded_private_key_content"
|
|
38
|
+
|
|
39
|
+
# Spotify
|
|
40
|
+
SPOTIFY_CLIENT_ID="your_spotify_client_id"
|
|
41
|
+
SPOTIFY_CLIENT_SECRET="your_spotify_client_secret"
|
|
42
|
+
|
|
43
|
+
# Tidal
|
|
44
|
+
TIDAL_CLIENT_ID="foo"
|
|
45
|
+
TIDAL_CLIENT_SECRET="foo"
|
|
46
|
+
|
|
47
|
+
# YouTube Music
|
|
48
|
+
YOUTUBE_API_KEY="your_youtube_data_api_v3_key"
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Basic Usage
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
import asyncio
|
|
55
|
+
from mlc import MusicLinkConverter, Config, Service
|
|
56
|
+
|
|
57
|
+
async def main():
|
|
58
|
+
# Load configuration
|
|
59
|
+
config = Config.from_env()
|
|
60
|
+
|
|
61
|
+
# Create converter
|
|
62
|
+
converter = MusicLinkConverter.create(
|
|
63
|
+
config=config,
|
|
64
|
+
services=[Service.APPLE_MUSIC, Service.SPOTIFY, Service.TIDAL, Service.YOUTUBE_MUSIC]
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# Convert a Spotify track URL
|
|
68
|
+
url = "https://open.spotify.com/track/4cOdK2wGLETKBW3PvgPWqT"
|
|
69
|
+
result = await converter.convert(url)
|
|
70
|
+
|
|
71
|
+
# Access the results
|
|
72
|
+
print(f"Original URL: {result.original_url}")
|
|
73
|
+
print(f"Normalized URL: {result.normalized_source_url}")
|
|
74
|
+
print(f"Title: {result.metadata.title}")
|
|
75
|
+
print(f"Artists: {', '.join(result.metadata.artists)}")
|
|
76
|
+
|
|
77
|
+
# Alternative links on other services
|
|
78
|
+
for alt in result.alternatives:
|
|
79
|
+
print(f"{alt.service.value}: {alt.url} (Confidence: {alt.confidence:.2f})")
|
|
80
|
+
|
|
81
|
+
if __name__ == "__main__":
|
|
82
|
+
asyncio.run(main())
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Advanced Usage
|
|
86
|
+
|
|
87
|
+
### Resource Lifecycle
|
|
88
|
+
|
|
89
|
+
If you do not pass your own `httpx.AsyncClient`, the converter creates and owns one.
|
|
90
|
+
Close it with `await converter.close()` or use the async context manager:
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
import asyncio
|
|
94
|
+
from mlc import MusicLinkConverter, Config, Service
|
|
95
|
+
|
|
96
|
+
async def main():
|
|
97
|
+
config = Config.from_env()
|
|
98
|
+
async with MusicLinkConverter.create(
|
|
99
|
+
config=config,
|
|
100
|
+
services=[Service.SPOTIFY, Service.APPLE_MUSIC, Service.YOUTUBE_MUSIC],
|
|
101
|
+
) as converter:
|
|
102
|
+
result = await converter.convert("https://open.spotify.com/track/4cOdK2wGLETKBW3PvgPWqT")
|
|
103
|
+
print(result.metadata.title)
|
|
104
|
+
|
|
105
|
+
asyncio.run(main())
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
If you pass a client to `create(..., client=...)`, you own its lifecycle and should close it yourself.
|
|
109
|
+
|
|
110
|
+
### Converting Multiple URLs Concurrently
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
import asyncio
|
|
114
|
+
from mlc import MusicLinkConverter, Config, Service
|
|
115
|
+
|
|
116
|
+
async def convert_multiple(urls: list[str]):
|
|
117
|
+
config = Config.from_env()
|
|
118
|
+
converter = MusicLinkConverter.create(
|
|
119
|
+
config=config,
|
|
120
|
+
services=[Service.SPOTIFY, Service.APPLE_MUSIC, Service.YOUTUBE_MUSIC]
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# Convert URLs concurrently
|
|
124
|
+
results = await asyncio.gather(
|
|
125
|
+
*(converter.convert(url) for url in urls),
|
|
126
|
+
return_exceptions=True
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
for url, result in zip(urls, results):
|
|
130
|
+
if isinstance(result, Exception):
|
|
131
|
+
print(f"Error converting {url}: {result}")
|
|
132
|
+
else:
|
|
133
|
+
print(f"{result.metadata.title} by {', '.join(result.metadata.artists)}")
|
|
134
|
+
print(f" Alternatives: {len(result.alternatives)}")
|
|
135
|
+
|
|
136
|
+
# Example usage
|
|
137
|
+
urls = [
|
|
138
|
+
"https://open.spotify.com/track/0pakiWeYJcqrqka4SAaqa6",
|
|
139
|
+
"https://music.apple.com/us/album/tulivuoria/368880626?i=368880680",
|
|
140
|
+
"https://music.youtube.com/watch?v=zUSeGUsY1zk",
|
|
141
|
+
]
|
|
142
|
+
asyncio.run(convert_multiple(urls))
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### Validating Music URLs
|
|
146
|
+
|
|
147
|
+
Check if a URL points to valid music content. For YouTube, this verifies the video is actually a music video.
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
import asyncio
|
|
151
|
+
from mlc import MusicLinkConverter, Config, Service
|
|
152
|
+
|
|
153
|
+
async def validate_urls(urls: list[str]):
|
|
154
|
+
config = Config.from_env()
|
|
155
|
+
converter = MusicLinkConverter.create(
|
|
156
|
+
config=config,
|
|
157
|
+
services=[Service.SPOTIFY, Service.APPLE_MUSIC, Service.YOUTUBE_MUSIC]
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
for url in urls:
|
|
161
|
+
is_valid = await converter.is_song(url)
|
|
162
|
+
status = "✓ Valid music" if is_valid else "✗ Not music"
|
|
163
|
+
print(f"{url}: {status}")
|
|
164
|
+
|
|
165
|
+
urls = [
|
|
166
|
+
"https://open.spotify.com/track/4cOdK2wGLETKBW3PvgPWqT", # Valid track
|
|
167
|
+
"https://www.youtube.com/watch?v=dQw4w9WgXcQ", # Music video
|
|
168
|
+
"https://www.youtube.com/watch?v=someRandomVideo", # Non-music video
|
|
169
|
+
]
|
|
170
|
+
asyncio.run(validate_urls(urls))
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### Caching Behavior
|
|
174
|
+
|
|
175
|
+
- Cache is **per converter instance** and stored in memory only.
|
|
176
|
+
- Entries are keyed by the **normalized source URL** and use an LRU policy (default max 128).
|
|
177
|
+
- Successful metadata fetches are cached and reused across `is_song()` and `convert()`.
|
|
178
|
+
- Non-music YouTube results are cached as negative entries to avoid repeat API calls.
|
|
179
|
+
|
|
180
|
+
### Validation and Non-Music Content
|
|
181
|
+
|
|
182
|
+
- `MusicItemMetadata` enforces strict validation; missing values are `None` (never placeholder URLs).
|
|
183
|
+
- For **non-music YouTube videos**, `is_song()` returns `False`, and `convert()` returns:
|
|
184
|
+
- `metadata.title == "Non-music content"`
|
|
185
|
+
- `metadata.artists == []`
|
|
186
|
+
- `alternatives == []`
|
|
187
|
+
- Unsupported URLs raise `UnsupportedUrlError` in `convert()` and return `False` in `is_song()`.
|
|
188
|
+
|
|
189
|
+
### Error Handling
|
|
190
|
+
|
|
191
|
+
```python
|
|
192
|
+
from mlc import MusicLinkConverter, Config, Service
|
|
193
|
+
from mlc.exceptions import (
|
|
194
|
+
MusicConverterError,
|
|
195
|
+
UnsupportedUrlError
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
async def safe_convert(converter, url):
|
|
199
|
+
try:
|
|
200
|
+
result = await converter.convert(url)
|
|
201
|
+
return result
|
|
202
|
+
except UnsupportedUrlError:
|
|
203
|
+
print(f"URL not supported: {url}")
|
|
204
|
+
except MusicConverterError as e:
|
|
205
|
+
print(f"Conversion error: {e}")
|
|
206
|
+
except Exception as e:
|
|
207
|
+
print(f"Unexpected error: {e}")
|
|
208
|
+
return None
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
## API Reference
|
|
212
|
+
|
|
213
|
+
### Core Classes
|
|
214
|
+
|
|
215
|
+
- **`MusicLinkConverter`**
|
|
216
|
+
- `create(config, services, client=None, logger=None)` - Create a converter instance
|
|
217
|
+
- `async close()` - Close the owned HTTP client (no-op if a client was provided)
|
|
218
|
+
- `async convert(url)` - Convert a music URL to other services
|
|
219
|
+
- `async find_all_matches(url)` - Fetch complete metadata-backed matches and per-service outcomes
|
|
220
|
+
- `async is_song(url)` - Check if URL points to valid music content
|
|
221
|
+
- `async validate_and_normalize_url(url)` - Validate and normalize a URL
|
|
222
|
+
|
|
223
|
+
- **`Config`**
|
|
224
|
+
- `from_env(path=None)` - Load configuration from environment variables
|
|
225
|
+
|
|
226
|
+
### Models
|
|
227
|
+
|
|
228
|
+
- **`Service`** - Enum of supported services: `SPOTIFY`, `APPLE_MUSIC`, `TIDAL`, `YOUTUBE_MUSIC`
|
|
229
|
+
- **`ItemType`** - Enum of item types: `SONG`, `ALBUM`, `ARTIST`
|
|
230
|
+
- **`ConversionResult`** - Result from `convert()`:
|
|
231
|
+
- `original_url: str` - The URL as provided to convert()
|
|
232
|
+
- `normalized_source_url: str` - Normalized version of the source URL
|
|
233
|
+
- `metadata: MusicItemMetadata` - Item metadata
|
|
234
|
+
- `alternatives: list[ServiceLink]` - Links on other services
|
|
235
|
+
- **`UnifiedConversionResult`** - Result from `find_all_matches()`:
|
|
236
|
+
- `query_url: str` - The URL as provided to find_all_matches()
|
|
237
|
+
- `matches: list[ServiceMatch]` - Source + matched items with full metadata
|
|
238
|
+
- `outcomes: list[ServiceLookupOutcome]` - Per-service status (`SOURCE`, `MATCHED`, `NOT_FOUND`, `SEARCH_ERROR`, `METADATA_ERROR`)
|
|
239
|
+
- **`MusicItemMetadata`** - Standardized metadata:
|
|
240
|
+
- `title: str`
|
|
241
|
+
- `artists: list[str]`
|
|
242
|
+
- `item_type: ItemType`
|
|
243
|
+
- `album_title: str | None`
|
|
244
|
+
- `album_id: str | None`
|
|
245
|
+
- `release_year: int | None`
|
|
246
|
+
- `duration_s: int | None`
|
|
247
|
+
- `image_url: str | None`
|
|
248
|
+
- `isrc: str | None` - For songs
|
|
249
|
+
- `upc: str | None` - For albums
|
|
250
|
+
- **`ServiceLink`** - Alternative link:
|
|
251
|
+
- `service: Service`
|
|
252
|
+
- `url: str`
|
|
253
|
+
- `item_type: ItemType`
|
|
254
|
+
- `item_id: str`
|
|
255
|
+
- `confidence: float` - Match confidence score
|
|
256
|
+
|
|
257
|
+
### Utilities
|
|
258
|
+
|
|
259
|
+
- **`configure_logging(level)`** - Set structlog log level ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL")
|
|
260
|
+
- **`get_logger(name="mlc")`** - Get a structlog logger
|
|
261
|
+
|
|
262
|
+
## Supported URL Formats
|
|
263
|
+
|
|
264
|
+
### Spotify
|
|
265
|
+
|
|
266
|
+
- `https://open.spotify.com/track/{id}`
|
|
267
|
+
- `https://open.spotify.com/album/{id}`
|
|
268
|
+
- `https://open.spotify.com/artist/{id}`
|
|
269
|
+
- `https://open.spotify.com/intl-{locale}/track/{id}` (and album/artist variants)
|
|
270
|
+
|
|
271
|
+
### Apple Music
|
|
272
|
+
|
|
273
|
+
- `https://music.apple.com/{locale}/album/{name}/{id}?i={song_id}` (Song)
|
|
274
|
+
- `https://music.apple.com/{locale}/album/{name}/{id}` (Album)
|
|
275
|
+
- `https://music.apple.com/{locale}/artist/{name}/{id}` (Artist)
|
|
276
|
+
- `https://embed.music.apple.com/{locale}/album/{name}/{id}` (Album)
|
|
277
|
+
- `https://embed.music.apple.com/{locale}/album/{name}/{id}?i={song_id}` (Song)
|
|
278
|
+
- `https://embed.music.apple.com/{locale}/artist/{name}/{id}` (Artist)
|
|
279
|
+
|
|
280
|
+
### YouTube Music / YouTube
|
|
281
|
+
|
|
282
|
+
- `https://music.youtube.com/watch?v={id}` (Song)
|
|
283
|
+
- `https://www.youtube.com/watch?v={id}` (Song - must be music video)
|
|
284
|
+
- `https://music.youtube.com/playlist?list={id}` (Album)
|
|
285
|
+
- `https://www.youtube.com/playlist?list={id}` (Album)
|
|
286
|
+
- `https://music.youtube.com/channel/{id}` (Artist)
|
|
287
|
+
- `https://www.youtube.com/channel/{id}` (Artist)
|
|
288
|
+
- `https://music.youtube.com/browse/{id}` (Artist, `UC...` IDs)
|
|
289
|
+
|
|
290
|
+
### Tidal
|
|
291
|
+
|
|
292
|
+
- `https://tidal.com/track/{id}`
|
|
293
|
+
- `https://tidal.com/album/{id}`
|
|
294
|
+
- `https://tidal.com/artist/{id}`
|
|
295
|
+
- `https://listen.tidal.com/browse/track/{id}`
|
|
296
|
+
- `https://listen.tidal.com/browse/album/{id}`
|
|
297
|
+
- `https://listen.tidal.com/browse/artist/{id}`
|
|
298
|
+
- `https://listen.tidal.com/browse/album/{album_id}/track/{track_id}`
|
|
299
|
+
|
|
300
|
+
## Development
|
|
301
|
+
|
|
302
|
+
### Running Tests
|
|
303
|
+
|
|
304
|
+
```bash
|
|
305
|
+
uv run pytest
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
### Code Quality
|
|
309
|
+
|
|
310
|
+
```bash
|
|
311
|
+
uv run ruff format .
|
|
312
|
+
uv run ruff check --fix --extend-fixable F401 .
|
|
313
|
+
uv run ty check
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
### Adding a New Service
|
|
317
|
+
|
|
318
|
+
To add support for a new streaming service:
|
|
319
|
+
|
|
320
|
+
1. Add the service to `ServiceName` enum in `src/mlc/datamodels.py`
|
|
321
|
+
2. Create a configuration class in `src/mlc/config.py`
|
|
322
|
+
3. Implement the service adapter in `src/mlc/adapters/`
|
|
323
|
+
4. Update the factory in `src/mlc/adapters/factory.py`
|
|
324
|
+
5. Add tests for your implementation
|
|
325
|
+
6. Update documentation
|
|
326
|
+
|
|
327
|
+
See existing adapters for implementation patterns and best practices.
|