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
- Uniform Matching: One scorer ranks every service's results, so confidence means the same thing everywhere
- URL Validation:
is_supported_urlparses a link,is_music_itemverifies it resolves to music - 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"
# Optional: two-letter storefront for catalogue requests and emitted URLs. Defaults to "us".
APPLE_MUSIC_STOREFRONT="us"
# 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 source identity and display metadata
print(f"Original URL: {result.source.original_url}")
print(f"Normalized URL: {result.source.normalized_url}")
print(f"Title: {result.metadata.title}")
print(f"Artists: {', '.join(result.metadata.artists)}")
# Equivalent links on other services
for link in result.links:
print(f"{link.service.value}: {link.url} (Confidence: {link.confidence:.2f})")
# Inspect explicit target-service outcomes
for outcome in result.outcomes:
print(f"{outcome.service.value}: {outcome.status.value}")
if __name__ == "__main__":
asyncio.run(main())
URL Identity
URL identification is synchronous and performs no provider request:
from mlc import ItemType
source = converter.identify_url("https://music.apple.com/fi/album/tulivuoria/368880626?i=368880680")
assert source.identity.service.slug == "apple"
assert source.identity.item_type == ItemType.SONG
assert source.identity.item_id == "368880680"
assert converter.is_supported_url(source.original_url)
ServiceItemIdentity is the stable identity contract. Display details — an Apple Music storefront, a slug,
percent-encoding, tracking parameters, query order — are not identity. Every URL emitted in a ServiceLink
round-trips through identify_url() back to that link's identity:
assert converter.identify_url(link.url).identity == link.identity
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" Equivalent links: {len(result.links)}")
# 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_music_item(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_music_item()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_music_item()returnsFalse, andconvert()returns:metadata is Nonelinks == []outcomes == []
- Unsupported URLs raise
UnsupportedUrlErrorinconvert(), and returnFalsefrom bothis_supported_url()andis_music_item().
Matching and Confidence
Adapters return every plausible search result; mlc.matching scores them and picks the winner, so confidence values
are comparable between services:
1.0- the two services agree on an ISRC (songs) or UPC (albums). This is an identity match, not a guess.- below
1.0- a text score over the normalized title, artist credit and duration. Text can never reach1.0. - no link - nothing cleared the threshold. The outcome distinguishes
NOT_FOUND(the service returned nothing) fromNO_CONFIDENT_MATCH(it returned results that all failed scoring).
A candidate is rejected outright, whatever else it scores, when it carries a conflicting identifier, a different
version token (Radio Edit vs Extended Mix), or a duration that cannot be the same recording. Missing a match costs
one empty lookup; a wrong link gets published and cached, so the scoring is deliberately biased towards missing.
When the source service publishes no identifier — YouTube never does — a matched service's ISRC or UPC is reused to retry the services that came up empty. That second pass is what makes YouTube sources convertible at all.
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)- Fetch source metadata and find equivalent links on other servicesidentify_url(url)- Parse a URL intoNormalizedLinkInfo; raisesUnsupportedUrlErrorif unrecognized (no API call)is_supported_url(url)- Check if a URL is parseable by a registered adapter (no API call; synchronous)async is_music_item(url)- Check if a URL resolves to music on its service
-
Configfrom_env(path=None)- Load configuration from environment variables
Models
Service- Enum of supported services:SPOTIFY,APPLE_MUSIC,TIDAL,YOUTUBE_MUSIC.slug- machine name used by Core:"spotify","apple","youtube","tidal"
ServiceItemIdentity- Frozen hashable(service, item_type, item_id)triple that identifies one item without its display URL. Two URLs for the same item on the same service produce the same identity regardless of storefront, slug, or query parameters. Returned byNormalizedLinkInfo.identityandServiceLink.identity.ItemType- Enum of item types:SONG,ALBUM,ARTISTConversionResult- Result fromconvert():source: NormalizedLinkInfo- Original and normalized source identitymetadata: MusicItemMetadata | None- Source metadata,Nonewhen the link is not musiclinks: list[ServiceLink]- Equivalent links on target servicesoutcomes: list[ServiceLookupOutcome]- Per-target status (MATCHED,NOT_FOUND,NO_CONFIDENT_MATCH,SEARCH_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- Equivalent link on a target service:service: Serviceurl: stritem_type: ItemTypeitem_id: strconfidence: float- Zero to one;1.0only for an identifier match
SearchCandidate- One unscored search result, as returned by an adapter:service,item_type,item_id,url,titleartists: list[str],duration_s: int | None,version: str | Noneisrc: str | None,upc: str | Nonerank: int- Position in the service's own relevance ordering
Matching and Normalization
These are part of the public API and usable on their own, without a converter or any API credentials. They are pure functions over plain strings, so they suit any place that has to decide whether two pieces of music metadata describe the same thing.
from mlc import blended_similarity, lookup_key, split_title, token_coverage
lookup_key("Gigi d'Agostino") # "gigi dagostino"
lookup_key("U.S.U.R.A.") # "usura"
parts = split_title("Sunset (Extended Mix)")
parts.base # "Sunset"
parts.version_key # ("extended mix",)
blended_similarity("rocket man", "rocket men") # 0.77
token_coverage({"rocket", "man", "elton"}, {"rocket", "man"}) # 1.0
Normalization (mlc.normalization) — text to stable comparison keys:
fold(text)- normalize unicode, unify quotes and dashes, casefold, collapse whitespacestrip_diacritics(text)-TeräsbetonibecomesTerasbetonicollapse_acronyms(text)-U.S.U.R.A.becomesUSURAlookup_key(text)- the full comparison key: folded, undecorated, punctuation-free, apostrophes deletedcompact_key(text)- a lookup key without spaces, for run-together names like channel handlescredit_key(text)- a lookup key with any feature marker (feat.,ft.,featuring,f/) unified tofeatkey_tokens(*texts)- the set of lookup-key tokens across one or more textssplit_title(title) -> TitleParts- separates the base title from its version tokens;TitlePartsexposesbase,versions,base_keyandversion_keysplit_credit(credit) -> CreditParts- separates the primary artist from featured artists;CreditPartsexposesprimary,featured,primary_keyandfeatured_keysstrip_leading_credit(title, artists)- removes anArtist -prefix when it matches one ofartistscontains_version_word(text),is_neutral_version(version),canonical_version(version)- version-token classification; a "neutral" version (Album Version,Remastered 2011,Official Video) describes the same recording as the bare title
Similarity (mlc.similarity) — measures, each returning zero to one:
jaro_winkler(left, right)- rewards a shared prefix; strong on short strings, weak on reordered wordstrigram_jaccard(left, right)- order-independent; handles insertions such as a subtitleblended_similarity(left, right)- an even blend of both, so neither weakness decides alonetoken_coverage(left, right)- overlap of two token sets over the smaller one, so extra words do not dilute it
Matching (mlc.matching) — scoring and selection over SearchCandidate values:
score_candidate(source, candidate, source_service) -> float | None- confidence, orNonewhen rejectedselect_best_match(source, candidates, source_service) -> ServiceLink | None- the best candidate above the thresholdtitle_similarity(source, candidate, candidate_base)- the better of text similarity and token coveragecredit_similarity(source_artists, candidate_artists)-Nonewhen the candidate carries no creditduration_similarity(source_s, candidate_s, tolerance_s)-Nonewhen either side has no durationcompare_identifiers(source, candidate) -> IdentifierVerdict-MATCH,CONFLICTorUNKNOWNMINIMUM_CONFIDENCE- the thresholdselect_best_matchapplies
source_service only selects the duration tolerance: a YouTube duration measures an upload rather than a recording,
so it is given a much wider one.
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://music.apple.com/{locale}/song/{id}(Song, direct form)https://music.apple.com/song/{id}(Song, direct form without locale)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 its own module under
src/mlc/adapters/, subclassingServiceAdapterfromsrc/mlc/adapters/base.py - 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.9.0
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.9.0.tar.gz | 67.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| slipmat_mlc-2026.9.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 153.0 kB
Release files / slipmat_mlc-2026.9.0.tar.gz
| Download URL | slipmat_mlc-2026.9.0.tar.gz |
|---|---|
| Size | 67.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
3f7f224974260c8a19d9d623f99fae0bebf172b4659301b6db449ad49977498b
|
|
BLAKE2b-256 checksum How to use checksums |
9f1ac9dad6ec422cc01d1d60c2f7a2a23bf66b32417c81723858f61174151168
|
| 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.9.0-py3-none-any.whl
| Download URL | slipmat_mlc-2026.9.0-py3-none-any.whl |
|---|---|
| Size | 86.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
1ee10e106878698c0b396c774f064b8f22ae59355fddfcdf2d5bb96eeb62876c
|
|
BLAKE2b-256 checksum How to use checksums |
4dd8b998b46c0579484453fc1d735e062a816ccdaf5559ee8e01fcf3d847555f
|
| 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}
|