Metadata-Version: 2.3
Name: slipmat-mlc
Version: 2026.9.0
Summary: Slipmat Music Link Converter
Author: Ville Säävuori
Author-email: Ville Säävuori <ville@slipmat.io>
Requires-Dist: cryptography>=50.0.1
Requires-Dist: environs>=15.2.0
Requires-Dist: google-api-python-client>=2.200.0
Requires-Dist: httpcore>=1.0.9
Requires-Dist: httpx>=0.28.1
Requires-Dist: pydantic>=2.13.5
Requires-Dist: pyjwt>=2.13.0
Requires-Dist: structlog>=26.1.0
Requires-Python: >=3.14
Description-Content-Type: text/markdown

# 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**.

```bash
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:

```dotenv
# 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

```python
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:

```python
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:

```python
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:

```python
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

```python
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.

```python
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

```python
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.

```python
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

```bash
uv run pytest
```

### Code Quality

```bash
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.
