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.
Files changed (32) hide show
  1. slipmat_mlc-2026.7/PKG-INFO +344 -0
  2. slipmat_mlc-2026.7/README.md +327 -0
  3. slipmat_mlc-2026.7/pyproject.toml +49 -0
  4. slipmat_mlc-2026.7/src/mlc/.DS_Store +0 -0
  5. slipmat_mlc-2026.7/src/mlc/__init__.py +48 -0
  6. slipmat_mlc-2026.7/src/mlc/adapters/__init__.py +107 -0
  7. slipmat_mlc-2026.7/src/mlc/adapters/apple.py +565 -0
  8. slipmat_mlc-2026.7/src/mlc/adapters/factory.py +61 -0
  9. slipmat_mlc-2026.7/src/mlc/adapters/spotify.py +543 -0
  10. slipmat_mlc-2026.7/src/mlc/adapters/tidal.py +719 -0
  11. slipmat_mlc-2026.7/src/mlc/adapters/youtube.py +419 -0
  12. slipmat_mlc-2026.7/src/mlc/config.py +91 -0
  13. slipmat_mlc-2026.7/src/mlc/converter.py +574 -0
  14. slipmat_mlc-2026.7/src/mlc/datamodels.py +246 -0
  15. slipmat_mlc-2026.7/src/mlc/exceptions.py +54 -0
  16. slipmat_mlc-2026.7/src/mlc/logging.py +42 -0
  17. slipmat_mlc-2026.7/src/mlc/parsers/__init__.py +0 -0
  18. slipmat_mlc-2026.7/src/mlc/parsers/youtube.py +241 -0
  19. slipmat_mlc-2026.7/src/mlc/tests/__init__.py +0 -0
  20. slipmat_mlc-2026.7/src/mlc/tests/test_apple_adapter.py +761 -0
  21. slipmat_mlc-2026.7/src/mlc/tests/test_cache_and_non_music.py +185 -0
  22. slipmat_mlc-2026.7/src/mlc/tests/test_config.py +175 -0
  23. slipmat_mlc-2026.7/src/mlc/tests/test_converter.py +1055 -0
  24. slipmat_mlc-2026.7/src/mlc/tests/test_factory.py +204 -0
  25. slipmat_mlc-2026.7/src/mlc/tests/test_is_valid_song.py +109 -0
  26. slipmat_mlc-2026.7/src/mlc/tests/test_logging.py +19 -0
  27. slipmat_mlc-2026.7/src/mlc/tests/test_models.py +327 -0
  28. slipmat_mlc-2026.7/src/mlc/tests/test_spotify_adapter.py +534 -0
  29. slipmat_mlc-2026.7/src/mlc/tests/test_tidal_adapter.py +729 -0
  30. slipmat_mlc-2026.7/src/mlc/tests/test_youtube_adapter.py +500 -0
  31. slipmat_mlc-2026.7/src/mlc/tests/test_youtube_parser.py +51 -0
  32. 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.