a1f6701bac
- 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
715 lines
22 KiB
Markdown
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.
|