Skip to main content

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 asyncio for 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_url parses a link, is_music_item verifies 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.

  1. Create an .env file in your project root.
  2. 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() and convert().
  • Non-music YouTube results are cached as negative entries to avoid repeat API calls.

Validation and Non-Music Content

  • MusicItemMetadata enforces strict validation; missing values are None (never placeholder URLs).
  • For non-music YouTube videos, is_music_item() returns False, and convert() returns:
    • metadata is None
    • links == []
    • outcomes == []
  • Unsupported URLs raise UnsupportedUrlError in convert(), and return False from both is_supported_url() and is_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 reach 1.0.
  • no link - nothing cleared the threshold. The outcome distinguishes NOT_FOUND (the service returned nothing) from NO_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

  • MusicLinkConverter

    • create(config, services, client=None, logger=None) - Create a converter instance
    • async 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 services
    • identify_url(url) - Parse a URL into NormalizedLinkInfo; raises UnsupportedUrlError if 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
  • Config

    • from_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 by NormalizedLinkInfo.identity and ServiceLink.identity.
  • ItemType - Enum of item types: SONG, ALBUM, ARTIST
  • ConversionResult - Result from convert():
    • source: NormalizedLinkInfo - Original and normalized source identity
    • metadata: MusicItemMetadata | None - Source metadata, None when the link is not music
    • links: list[ServiceLink] - Equivalent links on target services
    • outcomes: list[ServiceLookupOutcome] - Per-target status (MATCHED, NOT_FOUND, NO_CONFIDENT_MATCH, SEARCH_ERROR)
  • MusicItemMetadata - Standardized metadata:
    • title: str
    • artists: list[str]
    • item_type: ItemType
    • album_title: str | None
    • album_id: str | None
    • release_year: int | None
    • duration_s: int | None
    • image_url: str | None
    • isrc: str | None - For songs
    • upc: str | None - For albums
  • ServiceLink - Equivalent link on a target service:
    • service: Service
    • url: str
    • item_type: ItemType
    • item_id: str
    • confidence: float - Zero to one; 1.0 only for an identifier match
  • SearchCandidate - One unscored search result, as returned by an adapter:
    • service, item_type, item_id, url, title
    • artists: list[str], duration_s: int | None, version: str | None
    • isrc: str | None, upc: str | None
    • rank: 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 whitespace
  • strip_diacritics(text) - Teräsbetoni becomes Terasbetoni
  • collapse_acronyms(text) - U.S.U.R.A. becomes USURA
  • lookup_key(text) - the full comparison key: folded, undecorated, punctuation-free, apostrophes deleted
  • compact_key(text) - a lookup key without spaces, for run-together names like channel handles
  • credit_key(text) - a lookup key with any feature marker (feat., ft., featuring, f/) unified to feat
  • key_tokens(*texts) - the set of lookup-key tokens across one or more texts
  • split_title(title) -> TitleParts - separates the base title from its version tokens; TitleParts exposes base, versions, base_key and version_key
  • split_credit(credit) -> CreditParts - separates the primary artist from featured artists; CreditParts exposes primary, featured, primary_key and featured_keys
  • strip_leading_credit(title, artists) - removes an Artist - prefix when it matches one of artists
  • contains_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 words
  • trigram_jaccard(left, right) - order-independent; handles insertions such as a subtitle
  • blended_similarity(left, right) - an even blend of both, so neither weakness decides alone
  • token_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, or None when rejected
  • select_best_match(source, candidates, source_service) -> ServiceLink | None - the best candidate above the threshold
  • title_similarity(source, candidate, candidate_base) - the better of text similarity and token coverage
  • credit_similarity(source_artists, candidate_artists) - None when the candidate carries no credit
  • duration_similarity(source_s, candidate_s, tolerance_s) - None when either side has no duration
  • compare_identifiers(source, candidate) -> IdentifierVerdict - MATCH, CONFLICT or UNKNOWN
  • MINIMUM_CONFIDENCE - the threshold select_best_match applies

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:

  1. Add the service to ServiceName enum in src/mlc/datamodels.py
  2. Create a configuration class in src/mlc/config.py
  3. Implement the service adapter in its own module under src/mlc/adapters/, subclassing ServiceAdapter from src/mlc/adapters/base.py
  4. Update the factory in src/mlc/adapters/factory.py
  5. Add tests for your implementation
  6. 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)

Source distribution for slipmat-mlc 2026.9.0
File Size Uploaded
slipmat_mlc-2026.9.0.tar.gz 67.0 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for slipmat-mlc 2026.9.0
File Interpreter ABI Platform
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}

Release history Release notifications | RSS feed

This release

2026.9.0 This release

2 release files

2026.7

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page