Slipmat Music Link Converter
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.
Features
- Supported Services: Apple Music, Spotify, Tidal, and YouTube Music
- Supported Item Types: Songs (Tracks), Albums, Artists
- Bidirectional Conversion: Convert from any supported service to any other configured service
- Asynchronous: Built with
asynciofor efficient I/O operations - Smart Caching: Bounded in-memory LRU cache per converter, keyed by normalized URL
- Smart YouTube Filtering: Automatically filters out non-music YouTube videos
- URL Validation:
is_songmethod quickly validates if a URL points to music content - Error Handling: Includes specific exceptions for common issues
Installation
Requires Python 3.14 or newer.
uv add slipmat-mlc
Configuration
The library requires API credentials for each service you want to use. These are loaded from environment variables.
- Create an
.envfile in your project root. - Add credentials for the services you want to use:
# Apple Music
APPLE_MUSIC_TEAM_ID="your_apple_developer_team_id"
APPLE_MUSIC_KEY_ID="your_apple_music_key_id"
# The secret key should be the base64-encoded content of your .p8 file:
# uv run python -c "import base64; print(base64.b64encode(open('AuthKey_YOUR_KEY_ID.p8', 'rb').read()).decode())"
APPLE_MUSIC_SECRET_KEY="your_base64_encoded_private_key_content"
# Spotify
SPOTIFY_CLIENT_ID="your_spotify_client_id"
SPOTIFY_CLIENT_SECRET="your_spotify_client_secret"
# Tidal
TIDAL_CLIENT_ID="foo"
TIDAL_CLIENT_SECRET="foo"
# YouTube Music
YOUTUBE_API_KEY="your_youtube_data_api_v3_key"
Basic Usage
import asyncio
from mlc import MusicLinkConverter, Config, Service
async def main():
# Load configuration
config = Config.from_env()
# Create converter
converter = MusicLinkConverter.create(
config=config,
services=[Service.APPLE_MUSIC, Service.SPOTIFY, Service.TIDAL, Service.YOUTUBE_MUSIC]
)
# Convert a Spotify track URL
url = "https://open.spotify.com/track/4cOdK2wGLETKBW3PvgPWqT"
result = await converter.convert(url)
# Access the results
print(f"Original URL: {result.original_url}")
print(f"Normalized URL: {result.normalized_source_url}")
print(f"Title: {result.metadata.title}")
print(f"Artists: {', '.join(result.metadata.artists)}")
# Alternative links on other services
for alt in result.alternatives:
print(f"{alt.service.value}: {alt.url} (Confidence: {alt.confidence:.2f})")
if __name__ == "__main__":
asyncio.run(main())
Advanced Usage
Resource Lifecycle
If you do not pass your own httpx.AsyncClient, the converter creates and owns one.
Close it with await converter.close() or use the async context manager:
import asyncio
from mlc import MusicLinkConverter, Config, Service
async def main():
config = Config.from_env()
async with MusicLinkConverter.create(
config=config,
services=[Service.SPOTIFY, Service.APPLE_MUSIC, Service.YOUTUBE_MUSIC],
) as converter:
result = await converter.convert("https://open.spotify.com/track/4cOdK2wGLETKBW3PvgPWqT")
print(result.metadata.title)
asyncio.run(main())
If you pass a client to create(..., client=...), you own its lifecycle and should close it yourself.
Converting Multiple URLs Concurrently
import asyncio
from mlc import MusicLinkConverter, Config, Service
async def convert_multiple(urls: list[str]):
config = Config.from_env()
converter = MusicLinkConverter.create(
config=config,
services=[Service.SPOTIFY, Service.APPLE_MUSIC, Service.YOUTUBE_MUSIC]
)
# Convert URLs concurrently
results = await asyncio.gather(
*(converter.convert(url) for url in urls),
return_exceptions=True
)
for url, result in zip(urls, results):
if isinstance(result, Exception):
print(f"Error converting {url}: {result}")
else:
print(f"{result.metadata.title} by {', '.join(result.metadata.artists)}")
print(f" Alternatives: {len(result.alternatives)}")
# Example usage
urls = [
"https://open.spotify.com/track/0pakiWeYJcqrqka4SAaqa6",
"https://music.apple.com/us/album/tulivuoria/368880626?i=368880680",
"https://music.youtube.com/watch?v=zUSeGUsY1zk",
]
asyncio.run(convert_multiple(urls))
Validating Music URLs
Check if a URL points to valid music content. For YouTube, this verifies the video is actually a music video.
import asyncio
from mlc import MusicLinkConverter, Config, Service
async def validate_urls(urls: list[str]):
config = Config.from_env()
converter = MusicLinkConverter.create(
config=config,
services=[Service.SPOTIFY, Service.APPLE_MUSIC, Service.YOUTUBE_MUSIC]
)
for url in urls:
is_valid = await converter.is_song(url)
status = "✓ Valid music" if is_valid else "✗ Not music"
print(f"{url}: {status}")
urls = [
"https://open.spotify.com/track/4cOdK2wGLETKBW3PvgPWqT", # Valid track
"https://www.youtube.com/watch?v=dQw4w9WgXcQ", # Music video
"https://www.youtube.com/watch?v=someRandomVideo", # Non-music video
]
asyncio.run(validate_urls(urls))
Caching Behavior
- Cache is per converter instance and stored in memory only.
- Entries are keyed by the normalized source URL and use an LRU policy (default max 128).
- Successful metadata fetches are cached and reused across
is_song()andconvert(). - Non-music YouTube results are cached as negative entries to avoid repeat API calls.
Validation and Non-Music Content
MusicItemMetadataenforces strict validation; missing values areNone(never placeholder URLs).- For non-music YouTube videos,
is_song()returnsFalse, andconvert()returns:metadata.title == "Non-music content"metadata.artists == []alternatives == []
- Unsupported URLs raise
UnsupportedUrlErrorinconvert()and returnFalseinis_song().
Error Handling
from mlc import MusicLinkConverter, Config, Service
from mlc.exceptions import (
MusicConverterError,
UnsupportedUrlError
)
async def safe_convert(converter, url):
try:
result = await converter.convert(url)
return result
except UnsupportedUrlError:
print(f"URL not supported: {url}")
except MusicConverterError as e:
print(f"Conversion error: {e}")
except Exception as e:
print(f"Unexpected error: {e}")
return None
API Reference
Core Classes
-
MusicLinkConvertercreate(config, services, client=None, logger=None)- Create a converter instanceasync close()- Close the owned HTTP client (no-op if a client was provided)async convert(url)- Convert a music URL to other servicesasync find_all_matches(url)- Fetch complete metadata-backed matches and per-service outcomesasync is_song(url)- Check if URL points to valid music contentasync validate_and_normalize_url(url)- Validate and normalize a URL
-
Configfrom_env(path=None)- Load configuration from environment variables
Models
Service- Enum of supported services:SPOTIFY,APPLE_MUSIC,TIDAL,YOUTUBE_MUSICItemType- Enum of item types:SONG,ALBUM,ARTISTConversionResult- Result fromconvert():original_url: str- The URL as provided to convert()normalized_source_url: str- Normalized version of the source URLmetadata: MusicItemMetadata- Item metadataalternatives: list[ServiceLink]- Links on other services
UnifiedConversionResult- Result fromfind_all_matches():query_url: str- The URL as provided to find_all_matches()matches: list[ServiceMatch]- Source + matched items with full metadataoutcomes: list[ServiceLookupOutcome]- Per-service status (SOURCE,MATCHED,NOT_FOUND,SEARCH_ERROR,METADATA_ERROR)
MusicItemMetadata- Standardized metadata:title: strartists: list[str]item_type: ItemTypealbum_title: str | Nonealbum_id: str | Nonerelease_year: int | Noneduration_s: int | Noneimage_url: str | Noneisrc: str | None- For songsupc: str | None- For albums
ServiceLink- Alternative link:service: Serviceurl: stritem_type: ItemTypeitem_id: strconfidence: float- Match confidence score
Utilities
configure_logging(level)- Set structlog log level ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL")get_logger(name="mlc")- Get a structlog logger
Supported URL Formats
Spotify
https://open.spotify.com/track/{id}https://open.spotify.com/album/{id}https://open.spotify.com/artist/{id}https://open.spotify.com/intl-{locale}/track/{id}(and album/artist variants)
Apple Music
https://music.apple.com/{locale}/album/{name}/{id}?i={song_id}(Song)https://music.apple.com/{locale}/album/{name}/{id}(Album)https://music.apple.com/{locale}/artist/{name}/{id}(Artist)https://embed.music.apple.com/{locale}/album/{name}/{id}(Album)https://embed.music.apple.com/{locale}/album/{name}/{id}?i={song_id}(Song)https://embed.music.apple.com/{locale}/artist/{name}/{id}(Artist)
YouTube Music / YouTube
https://music.youtube.com/watch?v={id}(Song)https://www.youtube.com/watch?v={id}(Song - must be music video)https://music.youtube.com/playlist?list={id}(Album)https://www.youtube.com/playlist?list={id}(Album)https://music.youtube.com/channel/{id}(Artist)https://www.youtube.com/channel/{id}(Artist)https://music.youtube.com/browse/{id}(Artist,UC...IDs)
Tidal
https://tidal.com/track/{id}https://tidal.com/album/{id}https://tidal.com/artist/{id}https://listen.tidal.com/browse/track/{id}https://listen.tidal.com/browse/album/{id}https://listen.tidal.com/browse/artist/{id}https://listen.tidal.com/browse/album/{album_id}/track/{track_id}
Development
Running Tests
uv run pytest
Code Quality
uv run ruff format .
uv run ruff check --fix --extend-fixable F401 .
uv run ty check
Adding a New Service
To add support for a new streaming service:
- Add the service to
ServiceNameenum insrc/mlc/datamodels.py - Create a configuration class in
src/mlc/config.py - Implement the service adapter in
src/mlc/adapters/ - Update the factory in
src/mlc/adapters/factory.py - Add tests for your implementation
- Update documentation
See existing adapters for implementation patterns and best practices.
Release files for slipmat-mlc 2026.7
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| slipmat_mlc-2026.7.tar.gz | 57.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| slipmat_mlc-2026.7-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 130.7 kB
Release files / slipmat_mlc-2026.7.tar.gz
| Download URL | slipmat_mlc-2026.7.tar.gz |
|---|---|
| Size | 57.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
45a8e6ab423f19a36286f476c1a54a1ac9cef4c68161fd2da4d34a7884cffa76
|
|
BLAKE2b-256 checksum How to use checksums |
f1c3eed54e70bc6014f12206d5eaf97d2016cc58fa9ca379bb39dbd5a82caa0e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.8 {"installer":{"name":"uv","version":"0.11.8","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / slipmat_mlc-2026.7-py3-none-any.whl
| Download URL | slipmat_mlc-2026.7-py3-none-any.whl |
|---|---|
| Size | 73.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
097ed45d83a6ee3d6970c7424e1c1e30c4e114cafdea852a44ec4324c6acdeae
|
|
BLAKE2b-256 checksum How to use checksums |
abaf3b9540a9a6232e8aa5bf6bb1b411271618cd438e42c23818a757ad74c62d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.8 {"installer":{"name":"uv","version":"0.11.8","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|