# python-playlist-sync

Python replacement for the .NET playlist duplication tool. Replaces `Sony.Filtr.AdminAPI` (PlaylistSync + app-markets routes) and `Sony.Filtr.Worker` (sync task) with a modern FastAPI + Celery stack.

## Prerequisites

- [uv](https://docs.astral.sh/uv/) — Python package manager (`brew install uv` on macOS)
- Docker & Docker Compose — for the full stack (MySQL + Redis)

## Quick Start

```sh
# Clone and enter
cd python-playlist-sync

# Install dependencies
uv sync

# Copy example config
cp .env.shadow .env   # edit DATABASE_URL, Spotify/YouTube credentials etc.

# Run the API (dev mode, SQLite)
uv run python dev.py

# Run tests (no Docker needed)
uv run pytest
```

## Running the Full Stack

```sh
# Start MySQL 8.0 + Redis + API + Celery worker + Celery Beat scheduler
docker compose up --build
```

The API is available at `http://localhost:8000`. Interactive docs at `http://localhost:8000/docs`.

The `beat` service runs the periodic sweep on the interval configured by `SYNC_SWEEP_INTERVAL_SECONDS` (default `1800` seconds / 30 minutes in `docker-compose.yml`).

To run Beat manually outside Docker:
```sh
SYNC_SWEEP_INTERVAL_SECONDS=1800 uv run celery -A worker.tasks.celery_app beat --loglevel=info
```

## Configuration

Environment variables (set in `.env` or shell):

| Variable | Default | Description |
|----------|---------|-------------|
| `DATABASE_URL` | `sqlite+aiosqlite:///./test.db` | Async DB URL. Use `mysql+aiomysql://user:pass@host/db` for MySQL. |
| `REDIS_URL` | `redis://localhost:6379/0` | Redis (health check) |
| `CELERY_BROKER_URL` | `redis://localhost:6379/0` | Celery broker |
| `CELERY_RESULT_BACKEND` | `redis://localhost:6379/0` | Celery result backend |
| `FILTR_API_KEY` | `""` (disabled) | Value for the `FiltrAuthentication` request header; auth is a no-op when empty |
| `SPOTIFY_CLIENT_ID` / `SPOTIFY_CLIENT_SECRET` | — | Spotify Web API credentials |
| `YOUTUBE_CLIENT_ID` / `YOUTUBE_CLIENT_SECRET` | — | YouTube OAuth2 credentials |
| `SYNC_SWEEP_INTERVAL_SECONDS` | `0` (disabled) | Celery Beat sweep cadence (seconds) |

## API Overview

All routes except `/health` require the `FiltrAuthentication` header when `FILTR_API_KEY` is set.

### Playlist Sync

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/health` | Deep health check (DB + Redis); auth-exempt |
| `GET` | `/apollo-api/app-markets/` | List all application markets |
| `GET` | `/PlaylistSync/playlists` | All syncs (active and inactive) |
| `GET` | `/PlaylistSync/{market}/playlists/` | Syncs for a market with latest log data |
| `GET` | `/PlaylistSync/{market}/playlists/{id}` | Single sync with latest log data |
| `POST` | `/PlaylistSync/{market}/playlists` | Create sync |
| `PUT` | `/PlaylistSync/{market}/playlists/{id}` | Update sync |
| `DELETE` | `/PlaylistSync/{market}/playlists/{id}` | Delete sync |
| `POST` | `/PlaylistSync/{market}/playlists/{id}/execute` | Trigger sync now |
| `GET` | `/PlaylistSync/{market}/playlists/{id}/log` | Sync history (limit/offset) |

### Service Accounts

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/serviceaccounts` | List all service accounts (no tokens) |
| `GET` | `/serviceaccounts/{market}` | List service accounts for a market (`?include_tokens=true`) |
| `POST` | `/serviceaccounts/{market}` | Create service account |
| `PUT` | `/serviceaccounts/{market}/{id}` | Update service account |
| `DELETE` | `/serviceaccounts/{id}` | Delete service account |

### Create sync example

```sh
curl -X POST http://localhost:8000/PlaylistSync/us/playlists \
  -H "Content-Type: application/json" \
  -d '{
    "from_playlist_id": "spotify:playlist:3cEYpjA9oz9GiPac4AsH4n",
    "to_playlist_id": "spotify:playlist:TARGET_ID",
    "to_service_account_id": 42,
    "title": "My Synced Playlist",
    "title_copy_mode": 0,
    "description_copy_mode": 0
  }'
```

**`title_copy_mode` / `description_copy_mode` values:**
- `0` — `NoUpdate`: leave the target playlist title/description unchanged
- `1` — `UseSetting`: use the `title`/`description` value stored on the sync task
- `2` — `CopySource`: always copy from the source Spotify playlist

### Create service account example

```sh
# Spotify account (service_type=0, music_service_id auto-set to 1)
curl -X POST http://localhost:8000/serviceaccounts/us \
  -H "Content-Type: application/json" \
  -d '{
    "service_type": 0,
    "display_name": "Sony Spotify US",
    "user_identifier": "spotify_user_id",
    "access_token": "BQtest...",
    "refresh_token": "AQtest...",
    "access_token_expiry": 3600
  }'

# Deezer account (service_type=1, music_service_id auto-set to 2)
curl -X POST http://localhost:8000/serviceaccounts/us \
  -H "Content-Type: application/json" \
  -d '{
    "service_type": 1,
    "display_name": "Sony Deezer US",
    "user_identifier": "deezer_user_id",
    "access_token": "deezer_token..."
  }'
```

## Development

```sh
# Lint
uv run ruff check playlist_sync worker tests

# Type check
uv run mypy playlist_sync worker

# Tests with verbose output
uv run pytest -v

# Run a single test
uv run pytest tests/integration/test_routes.py::test_get_app_markets_returns_all -v
```

## Architecture

```
playlist_sync/api.py  →  SyncTaskService / SyncLogService     →  MySQL (tblPlaylistSynchronization)
playlist_sync/api.py  →  ApplicationService                   →  MySQL (tblApplicationInstance)
playlist_sync/api.py  →  execute_single_sync_task.delay()     →  Celery worker
                                                                   → SpotifySynchronizer
                                                                   → DeezerSynchronizer
                                                                   → YoutubeSynchronizer
                                                                   → SoundCloudSynchronizer
```

See `CLAUDE.md` for full architecture details, field mappings, and test layer documentation.
