Files
metadata-agregator/docs/research/minim/analysis/ARCHITECTURE.md
T
Alexander a1f6701bac feat: initial implementation of metadata aggregator
- gRPC service with MusicBrainz provider
- PostgreSQL schema with migrations
- Service layer with database-first caching
- Repository pattern for data access
- YAML configuration support
- Research documentation for 17 music metadata projects
2026-04-28 16:28:53 +02:00

715 lines
22 KiB
Markdown

# minim: Architecture
## Architectural Pattern
minim follows a **library architecture**, not a client-server or microservices pattern. There is no daemon, no HTTP server, no background processes. The library runs entirely within the caller's Python process.
**Invocation Model:**
```python
from minim import spotify, tidal, qobuz
from minim.audio import Audio
# Instantiate API client
client = spotify.WebAPI(client_id="...", client_secret="...")
# Make API calls
results = client.search("Radiohead", types=["artist", "album"])
# Process audio files
audio = Audio("track.flac")
audio.set_metadata_using_spotify(results["tracks"]["items"][0])
audio.write_metadata()
```
All operations are synchronous, blocking calls. No event loop, no async/await in v1.
## Module Organization
The codebase is organized into eight top-level modules:
```
minim/
├── __init__.py # Package initialization, version info
├── audio.py # Audio file handling, metadata I/O
├── discogs.py # Discogs API client
├── itunes.py # iTunes Search API client
├── qobuz.py # Qobuz API client
├── spotify.py # Spotify Web API + private lyrics
├── tidal.py # TIDAL public + private API
└── utility.py # Shared utilities
```
**No Subpackages:** All modules are at the top level. No hierarchical organization despite 35K+ lines of code.
**Module Independence:** Each API client module is self-contained. No cross-dependencies between `spotify.py`, `tidal.py`, etc. They share only `utility.py` and standard library imports.
## Class Hierarchy
### Audio Module
```
Audio (base class)
├── FLAC
├── MP3
├── MP4
├── OggVorbis
└── WAVE
```
**Factory Pattern:** `Audio(filepath)` auto-detects format and returns appropriate subclass instance.
**Detection Logic:**
1. Check file extension (`.flac`, `.mp3`, `.m4a`, `.ogg`, `.wav`)
2. If ambiguous, read magic bytes from file header
3. Instantiate corresponding subclass
4. Raise `ValueError` if format unsupported
**Shared Interface:** All subclasses implement:
- `read_metadata()`: Parse tags from file
- `write_metadata()`: Write tags to file
- `convert(output_path, format)`: Transcode via FFmpeg
- `set_metadata_using_{service}(data)`: Map service JSON to tags
### API Client Classes
Each service module defines one or more API client classes:
**discogs.py:**
- `API`: Main Discogs API client (database, marketplace, collection, wantlist)
**itunes.py:**
- `SearchAPI`: iTunes Search API client
**qobuz.py:**
- `PrivateAPI`: Qobuz API client (uses undocumented endpoints)
**spotify.py:**
- `WebAPI`: Official Spotify Web API client
- `PrivateLyricsService`: Undocumented Musixmatch integration for lyrics
**tidal.py:**
- `API`: Public TIDAL API (documented endpoints)
- `PrivateAPI`: Private TIDAL API (undocumented endpoints for streaming URLs, lyrics, credits)
**Naming Convention:** "Private" indicates use of undocumented endpoints. These are reverse-engineered from web/mobile apps and may break without notice.
## Authentication Flow
All API clients follow a consistent initialization and authentication pattern:
### 1. Initialization (`__init__`)
```python
def __init__(self, client_id=None, client_secret=None, access_token=None, ...):
# Check environment variables
self.client_id = client_id or os.getenv("SERVICE_CLIENT_ID")
self.client_secret = client_secret or os.getenv("SERVICE_CLIENT_SECRET")
# Load from config file
config = ConfigParser()
config.read(os.path.expanduser("~/minim.cfg"))
if config.has_section("service"):
self.access_token = config.get("service", "access_token", fallback=None)
self.refresh_token = config.get("service", "refresh_token", fallback=None)
# Use provided tokens if available
if access_token:
self.access_token = access_token
```
**Precedence:** Explicit parameters > environment variables > config file
### 2. Flow Selection (`set_flow`)
```python
def set_flow(self, flow_type="authorization_code", redirect_uri="http://localhost:8888", ...):
self.flow_type = flow_type
self.redirect_uri = redirect_uri
self.scopes = scopes
```
**Supported Flows (Spotify example):**
- `authorization_code`: Full user access, requires user login
- `pkce`: Proof Key for Code Exchange, for mobile/desktop apps
- `client_credentials`: App-only access, no user context
- `web_player`: Extract token from browser cookie (undocumented)
### 3. Token Acquisition (`set_access_token`)
```python
def set_access_token(self, method="http.server"):
if self.flow_type == "authorization_code":
# Generate authorization URL
auth_url = self._build_auth_url()
# Open browser or print URL
webbrowser.open(auth_url)
# Start callback server
if method == "http.server":
code = self._listen_http_server()
elif method == "flask":
code = self._listen_flask()
elif method == "playwright":
code = self._automate_browser()
# Exchange code for token
token_response = self._exchange_code(code)
self.access_token = token_response["access_token"]
self.refresh_token = token_response.get("refresh_token")
# Save to config
self._save_config()
```
**Callback Methods:**
**http.server (default):**
```python
def _listen_http_server(self):
server = HTTPServer(("localhost", 8888), CallbackHandler)
server.handle_request() # Block until callback received
return server.authorization_code
```
**Flask:**
```python
def _listen_flask(self):
app = Flask(__name__)
@app.route("/callback")
def callback():
code = request.args.get("code")
# Store code and shutdown
return "Authorization successful"
app.run(port=8888)
```
**Playwright:**
```python
def _automate_browser(self):
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
# Navigate to auth URL
page.goto(auth_url)
# Fill login form (service-specific selectors)
page.fill("#username", self.email)
page.fill("#password", self.password)
page.click("button[type=submit]")
# Wait for redirect
page.wait_for_url(f"{self.redirect_uri}*")
# Extract code from URL
code = parse_qs(urlparse(page.url).query)["code"][0]
browser.close()
return code
```
### 4. Token Persistence
```python
def _save_config(self):
config = ConfigParser()
config.read(os.path.expanduser("~/minim.cfg"))
if not config.has_section("service"):
config.add_section("service")
config.set("service", "access_token", self.access_token)
if self.refresh_token:
config.set("service", "refresh_token", self.refresh_token)
with open(os.path.expanduser("~/minim.cfg"), "w") as f:
config.write(f)
```
**File Format (INI):**
```ini
[spotify]
client_id = abc123
client_secret = def456
access_token = BQC...
refresh_token = AQD...
expires_at = 1672531200
[tidal]
client_id = xyz789
access_token = eyJ...
refresh_token = eyJ...
```
**Security:** Plain text storage. File permissions default to user-readable (0644 on Unix). No encryption, no OS keychain integration.
### 5. Token Refresh
```python
def _request(self, method, url, **kwargs):
# Check if token expired
if self.expires_at and time.time() >= self.expires_at:
self._refresh_access_token()
# Make request with current token
response = requests.request(
method, url,
headers=self._get_headers(),
**kwargs
)
# Handle 401 Unauthorized (token invalid)
if response.status_code == 401:
self._refresh_access_token()
# Retry request
response = requests.request(method, url, headers=self._get_headers(), **kwargs)
return response
def _refresh_access_token(self):
response = requests.post(
self.token_url,
data={
"grant_type": "refresh_token",
"refresh_token": self.refresh_token,
"client_id": self.client_id,
"client_secret": self.client_secret
}
)
token_data = response.json()
self.access_token = token_data["access_token"]
self.expires_at = time.time() + token_data["expires_in"]
# Update refresh token if provided
if "refresh_token" in token_data:
self.refresh_token = token_data["refresh_token"]
self._save_config()
```
**Automatic Refresh:** Transparent to caller. If a request fails with 401, the client refreshes the token and retries automatically.
## Request Handling
All API clients implement a common `_request()` method:
```python
def _request(self, method: str, url: str, **kwargs) -> dict:
"""
Make HTTP request with authentication.
Args:
method: HTTP method (GET, POST, PUT, DELETE)
url: Full URL or path (prepended with base_url if relative)
**kwargs: Passed to requests.request()
Returns:
JSON response as dict
Raises:
RuntimeError: If response status is not 2xx
"""
# Prepend base URL if path is relative
if not url.startswith("http"):
url = self.base_url + url
# Add authentication headers
headers = kwargs.pop("headers", {})
headers.update(self._get_headers())
# Make request
response = requests.request(method, url, headers=headers, **kwargs)
# Check status
if not response.ok:
raise RuntimeError(
f"{method} {url} failed: {response.status_code} {response.text}"
)
# Parse JSON
return response.json()
```
**Header Injection:** Each service implements `_get_headers()`:
**Spotify (Bearer token):**
```python
def _get_headers(self):
return {"Authorization": f"Bearer {self.access_token}"}
```
**Discogs (OAuth 1.0a signature):**
```python
def _get_headers(self):
oauth = OAuth1(
self.consumer_key,
client_secret=self.consumer_secret,
resource_owner_key=self.access_token,
resource_owner_secret=self.access_token_secret
)
return oauth # requests-oauthlib handles header generation
```
**Qobuz (X-App-Id header + Bearer token):**
```python
def _get_headers(self):
return {
"X-App-Id": self.app_id,
"Authorization": f"Bearer {self.access_token}"
}
```
**Error Handling:** All HTTP errors raise `RuntimeError` with status code and response body. No typed exceptions, no retry logic, no exponential backoff.
**Rate Limiting:** Not implemented. Caller responsible for respecting service rate limits.
## Metadata Mapping Architecture
The `Audio` class provides service-specific metadata setters that normalize API responses to a common schema:
```python
class Audio:
def set_metadata_using_spotify(self, track_data: dict):
"""Map Spotify track object to audio metadata."""
self.title = track_data["name"]
self.artist = ", ".join(a["name"] for a in track_data["artists"])
self.album = track_data["album"]["name"]
self.date = track_data["album"]["release_date"]
self.track_number = track_data["track_number"]
self.disc_number = track_data["disc_number"]
self.isrc = track_data.get("external_ids", {}).get("isrc")
# Fetch artwork
if track_data["album"]["images"]:
artwork_url = track_data["album"]["images"][0]["url"]
self.artwork = requests.get(artwork_url).content
def set_metadata_using_tidal(self, track_data: dict):
"""Map TIDAL track object to audio metadata."""
self.title = track_data["title"]
self.artist = ", ".join(a["name"] for a in track_data["artists"])
self.album = track_data["album"]["title"]
self.date = track_data["streamStartDate"][:10] # ISO date to YYYY-MM-DD
self.track_number = track_data["trackNumber"]
self.disc_number = track_data["volumeNumber"]
self.isrc = track_data.get("isrc")
# Fetch artwork (construct URL from cover ID)
if track_data["album"]["cover"]:
cover_id = track_data["album"]["cover"].replace("-", "/")
artwork_url = f"https://resources.tidal.com/images/{cover_id}/1280x1280.jpg"
self.artwork = requests.get(artwork_url).content
```
**Normalization Challenges:**
1. **Artist Representation:**
- Spotify: Array of objects `[{"name": "Artist"}]`
- TIDAL: Array of objects `[{"name": "Artist"}]`
- iTunes: String `"Artist"`
- Qobuz: Object `{"name": "Artist"}` (single artist)
2. **Date Formats:**
- Spotify: ISO 8601 `"2023-01-15"` or year-only `"2023"`
- TIDAL: ISO 8601 with time `"2023-01-15T00:00:00.000Z"`
- iTunes: ISO 8601 `"2023-01-15T00:00:00Z"`
- Qobuz: Unix timestamp or ISO 8601
3. **Artwork URLs:**
- Spotify: Array of images with different sizes `[{"url": "...", "width": 640, "height": 640}]`
- TIDAL: Cover ID requiring URL construction
- iTunes: Direct URL `"artworkUrl100"`, `"artworkUrl600"`
- Qobuz: Direct URL with size parameter
4. **Track/Disc Numbers:**
- Spotify: Separate `track_number` and `disc_number` fields
- TIDAL: `trackNumber` and `volumeNumber`
- iTunes: Combined `"trackNumber": "3/12"` (track 3 of 12)
- Qobuz: Separate `track_number` and `media_number`
**Mapping Strategy:** Each `set_metadata_using_*()` method handles service-specific quirks and normalizes to the `Audio` class's internal representation.
## Audio File I/O Architecture
The `Audio` class uses `mutagen` for reading and writing metadata:
```python
class Audio:
def __init__(self, filepath: str):
self.filepath = filepath
self._file = mutagen.File(filepath)
if isinstance(self._file, mutagen.flac.FLAC):
self.__class__ = FLAC
elif isinstance(self._file, mutagen.mp3.MP3):
self.__class__ = MP3
elif isinstance(self._file, mutagen.mp4.MP4):
self.__class__ = MP4
# ... etc
def write_metadata(self):
"""Write metadata to file. Implemented by subclasses."""
raise NotImplementedError
class FLAC(Audio):
def write_metadata(self):
"""Write Vorbis Comments to FLAC file."""
self._file["TITLE"] = self.title
self._file["ARTIST"] = self.artist
self._file["ALBUM"] = self.album
self._file["DATE"] = self.date
self._file["TRACKNUMBER"] = str(self.track_number)
self._file["DISCNUMBER"] = str(self.disc_number)
if self.artwork:
picture = mutagen.flac.Picture()
picture.data = self.artwork
picture.type = 3 # Front cover
picture.mime = "image/jpeg"
self._file.add_picture(picture)
self._file.save()
class MP3(Audio):
def write_metadata(self):
"""Write ID3v2 tags to MP3 file."""
from mutagen.id3 import TIT2, TPE1, TALB, TDRC, TRCK, TPOS, APIC
self._file["TIT2"] = TIT2(encoding=3, text=self.title)
self._file["TPE1"] = TPE1(encoding=3, text=self.artist)
self._file["TALB"] = TALB(encoding=3, text=self.album)
self._file["TDRC"] = TDRC(encoding=3, text=self.date)
self._file["TRCK"] = TRCK(encoding=3, text=str(self.track_number))
self._file["TPOS"] = TPOS(encoding=3, text=str(self.disc_number))
if self.artwork:
self._file["APIC"] = APIC(
encoding=3,
mime="image/jpeg",
type=3, # Front cover
desc="Cover",
data=self.artwork
)
self._file.save()
```
**Tag Format Mapping:**
| Field | FLAC (Vorbis) | MP3 (ID3v2) | MP4 (Atoms) |
|-------|---------------|-------------|-------------|
| Title | `TITLE` | `TIT2` | `\xa9nam` |
| Artist | `ARTIST` | `TPE1` | `\xa9ART` |
| Album | `ALBUM` | `TALB` | `\xa9alb` |
| Date | `DATE` | `TDRC` | `\xa9day` |
| Track # | `TRACKNUMBER` | `TRCK` | `trkn` |
| Disc # | `DISCNUMBER` | `TPOS` | `disk` |
| Artwork | `METADATA_BLOCK_PICTURE` | `APIC` | `covr` |
**Format Conversion:**
```python
def convert(self, output_path: str, format: str, **ffmpeg_options):
"""Convert audio file to different format using FFmpeg."""
import subprocess
cmd = [
"ffmpeg",
"-i", self.filepath,
"-c:a", self._get_codec(format),
**self._build_ffmpeg_args(ffmpeg_options),
output_path
]
subprocess.run(cmd, check=True)
# Copy metadata to converted file
converted = Audio(output_path)
converted.title = self.title
converted.artist = self.artist
# ... copy all fields
converted.write_metadata()
def _get_codec(self, format: str) -> str:
"""Map format to FFmpeg codec."""
codecs = {
"flac": "flac",
"mp3": "libmp3lame",
"m4a": "aac",
"ogg": "libvorbis",
"wav": "pcm_s16le"
}
return codecs.get(format, format)
```
## Configuration Architecture
**File Location:** `~/minim.cfg` (expands to user's home directory)
**Format:** INI-style via Python's `ConfigParser`
**Structure:**
```ini
[discogs]
consumer_key = ...
consumer_secret = ...
access_token = ...
access_token_secret = ...
[qobuz]
app_id = ...
app_secret = ...
email = user@example.com
password = ...
access_token = ...
[spotify]
client_id = ...
client_secret = ...
access_token = ...
refresh_token = ...
expires_at = 1672531200
[tidal]
client_id = ...
client_secret = ...
access_token = ...
refresh_token = ...
user_id = 12345
country_code = US
```
**Reading:**
```python
config = ConfigParser()
config.read(os.path.expanduser("~/minim.cfg"))
if config.has_section("spotify"):
access_token = config.get("spotify", "access_token", fallback=None)
refresh_token = config.get("spotify", "refresh_token", fallback=None)
```
**Writing:**
```python
config = ConfigParser()
config.read(os.path.expanduser("~/minim.cfg"))
if not config.has_section("spotify"):
config.add_section("spotify")
config.set("spotify", "access_token", new_token)
with open(os.path.expanduser("~/minim.cfg"), "w") as f:
config.write(f)
```
**Thread Safety:** Not thread-safe. Concurrent writes from multiple processes can corrupt the file. No file locking implemented.
## Error Handling Architecture
**Strategy:** Fail-fast with `RuntimeError`
**API Errors:**
```python
def _request(self, method, url, **kwargs):
response = requests.request(method, url, **kwargs)
if not response.ok:
raise RuntimeError(
f"{method} {url} failed with status {response.status_code}: {response.text}"
)
return response.json()
```
**File Errors:**
```python
def __init__(self, filepath):
if not os.path.exists(filepath):
raise FileNotFoundError(f"Audio file not found: {filepath}")
self._file = mutagen.File(filepath)
if self._file is None:
raise ValueError(f"Unsupported audio format: {filepath}")
```
**No Typed Exceptions:** All errors are generic `RuntimeError`, `ValueError`, `FileNotFoundError`. No custom exception hierarchy.
**No Retry Logic:** Failed requests are not retried. Caller must implement retry logic if needed.
**No Logging:** Errors are raised, not logged. No warning messages for non-critical issues.
## Dependency Injection
minim does not use formal dependency injection. Configuration is passed via:
1. **Constructor parameters:** `WebAPI(client_id="...", client_secret="...")`
2. **Environment variables:** `os.getenv("SPOTIFY_CLIENT_ID")`
3. **Config file:** `ConfigParser().read("~/minim.cfg")`
**No DI Framework:** No use of `injector`, `dependency-injector`, or similar libraries.
**Testing Implications:** Difficult to mock API clients. Tests use real API calls with credentials from environment variables or config file.
## Concurrency Model
**Synchronous Only:** All operations are blocking, synchronous calls.
**No Async Support:** No `async`/`await`, no `asyncio`, no `aiohttp`.
**Threading:** Not thread-safe. Shared state (config file, token refresh) can cause race conditions.
**Multiprocessing:** Safe for read-only operations. Token refresh in multiple processes can corrupt config file.
## Extensibility
**Adding New Services:**
1. Create new module (e.g., `apple_music.py`)
2. Define API client class with `__init__`, `set_flow`, `set_access_token`, `_request`, `_get_headers`
3. Implement service-specific methods (`search`, `get_track`, etc.)
4. Add `set_metadata_using_apple_music()` to `Audio` class
**No Plugin System:** No formal extension mechanism. New services require modifying the library code.
**Subclassing:** API client classes can be subclassed to override behavior:
```python
class CustomSpotifyAPI(spotify.WebAPI):
def _request(self, method, url, **kwargs):
# Add custom logging
print(f"Making request: {method} {url}")
return super()._request(method, url, **kwargs)
```
## Deployment Architecture
**Not Applicable:** minim is a library, not a deployable service. No server, no containers, no orchestration.
**Distribution:** Install via pip from source repository.
**Runtime:** Runs in caller's Python process. No separate runtime environment.
## Summary
minim's architecture is straightforward and pragmatic:
- **Library pattern** with no server components
- **Synchronous, blocking** operations throughout
- **Consistent authentication flow** across all services
- **Automatic token management** with file-based persistence
- **Service-specific metadata mapping** to common schema
- **Format-agnostic audio I/O** via mutagen
- **Fail-fast error handling** with generic exceptions
The architecture prioritizes simplicity and ease of use over scalability and robustness. It's well-suited for personal projects, scripts, and research but lacks features needed for production services (async, rate limiting, typed exceptions, secure storage).
The v2 rewrite on the `dev` branch addresses many architectural limitations while preserving the core design philosophy.