Metadata-Version: 2.3
Name: slipmat-mlc
Version: 2026.7
Summary: Slipmat Music Link Converter
Author: Ville Säävuori
Author-email: Ville Säävuori <ville@slipmat.io>
Requires-Dist: cryptography>=49.0.0
Requires-Dist: environs>=15.0.1
Requires-Dist: google-api-python-client>=2.198.0
Requires-Dist: httpcore>=1.0.9
Requires-Dist: httpx>=0.28.1
Requires-Dist: pydantic>=2.13.4
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
- **URL Validation:** `is_song` method quickly validates if a URL points to music content
- **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"

# 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 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:

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

```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_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()` 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_song()` returns `False`, and `convert()` returns:
  - `metadata.title == "Non-music content"`
  - `metadata.artists == []`
  - `alternatives == []`
- Unsupported URLs raise `UnsupportedUrlError` in `convert()` and return `False` in `is_song()`.

### 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)` - Convert a music URL to other services
  - `async find_all_matches(url)` - Fetch complete metadata-backed matches and per-service outcomes
  - `async is_song(url)` - Check if URL points to valid music content
  - `async validate_and_normalize_url(url)` - Validate and normalize a URL

- **`Config`**
  - `from_env(path=None)` - Load configuration from environment variables

### Models

- **`Service`** - Enum of supported services: `SPOTIFY`, `APPLE_MUSIC`, `TIDAL`, `YOUTUBE_MUSIC`
- **`ItemType`** - Enum of item types: `SONG`, `ALBUM`, `ARTIST`
- **`ConversionResult`** - Result from `convert()`:
  - `original_url: str` - The URL as provided to convert()
  - `normalized_source_url: str` - Normalized version of the source URL
  - `metadata: MusicItemMetadata` - Item metadata
  - `alternatives: list[ServiceLink]` - Links on other services
- **`UnifiedConversionResult`** - Result from `find_all_matches()`:
  - `query_url: str` - The URL as provided to find_all_matches()
  - `matches: list[ServiceMatch]` - Source + matched items with full metadata
  - `outcomes: list[ServiceLookupOutcome]` - Per-service status (`SOURCE`, `MATCHED`, `NOT_FOUND`, `SEARCH_ERROR`, `METADATA_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`** - Alternative link:
  - `service: Service`
  - `url: str`
  - `item_type: ItemType`
  - `item_id: str`
  - `confidence: 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

```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 `src/mlc/adapters/`
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.
