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
This commit is contained in:
@@ -0,0 +1,714 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user