191 lines
5.2 KiB
Markdown
191 lines
5.2 KiB
Markdown
# Spotify Import - Code Reference
|
|
|
|
Quick reference to key code sections for the next agent.
|
|
|
|
## Backend Entry Points
|
|
|
|
### Preview Playlist
|
|
**File**: `backend/src/routes/spotify.ts`
|
|
**Endpoint**: `POST /spotify/preview`
|
|
**Handler**: Lines ~50-120
|
|
|
|
```typescript
|
|
// Fetches Spotify playlist, searches MusicBrainz for albums
|
|
const preview = await spotifyImportService.previewPlaylist(url);
|
|
// Returns: matchedTracks, unmatchedTracks, albumsToDownload
|
|
```
|
|
|
|
### Execute Import
|
|
**File**: `backend/src/routes/spotify.ts`
|
|
**Endpoint**: `POST /spotify/import`
|
|
**Handler**: Lines ~130-200
|
|
|
|
```typescript
|
|
// Starts async import job
|
|
const job = await spotifyImportService.executeImport(preview, userId, playlistName);
|
|
// Returns: jobId for status polling
|
|
```
|
|
|
|
### Retry Pending Track
|
|
**File**: `backend/src/routes/playlists.ts`
|
|
**Endpoint**: `POST /playlists/:id/pending/:trackId/retry`
|
|
**Handler**: Lines ~630-745
|
|
|
|
```typescript
|
|
// Non-blocking retry flow:
|
|
// 1. Search Soulseek (15s timeout)
|
|
// 2. Return immediately with success/failure
|
|
// 3. Download in background
|
|
// 4. Trigger library scan after download
|
|
```
|
|
|
|
## Core Import Logic
|
|
|
|
### spotifyImportService.executeImport()
|
|
**File**: `backend/src/services/spotifyImport.ts`
|
|
**Function**: Lines ~150-350
|
|
|
|
Key sections:
|
|
- **Lines ~180-220**: Download albums via Lidarr or Soulseek
|
|
- **Lines ~230-280**: Wait for downloads, handle failures
|
|
- **Lines ~290-350**: Create playlist, match tracks, store pending
|
|
|
|
### Soulseek Download Flow
|
|
**File**: `backend/src/services/soulseek.ts`
|
|
|
|
Key methods:
|
|
- `searchTrack()` - Lines ~150-250: Search with 15s timeout
|
|
- `downloadTrack()` - Lines ~300-400: Download single file with 180s timeout
|
|
- `searchAndDownloadBatch()` - Lines ~525-600: Parallel search, concurrent download
|
|
- `downloadBestMatch()` - Lines ~465-520: Download from pre-searched results
|
|
|
|
### Track Matching
|
|
**File**: `backend/src/services/spotifyImport.ts`
|
|
**Function**: `matchTrackToLibrary()` - Lines ~400-500
|
|
|
|
Matching strategies (in order):
|
|
1. Exact normalized title + artist first word
|
|
2. Stripped title (remove remaster/remix suffixes)
|
|
3. Contains search
|
|
4. Fuzzy artist + title
|
|
5. StartsWith search
|
|
6. Last resort fuzzy
|
|
|
|
### Pending Track Reconciliation
|
|
**File**: `backend/src/services/spotifyImport.ts`
|
|
**Function**: `reconcilePendingTracks()` - Lines ~550-650
|
|
|
|
Called after library scan to match pending tracks to newly added files.
|
|
|
|
## Frontend Components
|
|
|
|
### Import Wizard
|
|
**File**: `frontend/app/import/spotify/page.tsx`
|
|
|
|
Key state:
|
|
- `step`: "url" | "preview" | "importing" | "complete"
|
|
- `preview`: PreviewResult from API
|
|
- `jobStatus`: Polling status during import
|
|
|
|
### Playlist Detail - Pending Tracks
|
|
**File**: `frontend/app/playlist/[id]/page.tsx`
|
|
|
|
Key handlers (Lines ~100-160):
|
|
- `handlePlayPreview()` - Fetches fresh Deezer URL, plays audio
|
|
- `handleRetryPendingTrack()` - Calls retry API, shows toast
|
|
- `handleRemovePendingTrack()` - Removes from playlist
|
|
|
|
Pending track rendering: Lines ~555-650
|
|
|
|
## Database Queries
|
|
|
|
### Get Playlist with Pending Tracks
|
|
```typescript
|
|
const playlist = await prisma.playlist.findUnique({
|
|
where: { id: playlistId },
|
|
include: {
|
|
items: { include: { track: { include: { album: { include: { artist: true }} }} }},
|
|
pendingTracks: { orderBy: { sort: 'asc' } }
|
|
}
|
|
});
|
|
```
|
|
|
|
### Create Pending Track
|
|
```typescript
|
|
await prisma.playlistPendingTrack.create({
|
|
data: {
|
|
playlistId,
|
|
spotifyArtist: track.artist,
|
|
spotifyTitle: track.title,
|
|
spotifyAlbum: resolvedAlbum,
|
|
spotifyTrackId: track.spotifyId,
|
|
deezerPreviewUrl: previewUrl,
|
|
sort: index
|
|
}
|
|
});
|
|
```
|
|
|
|
### Reconcile Pending Track (convert to real track)
|
|
```typescript
|
|
// Delete pending, add real track
|
|
await prisma.$transaction([
|
|
prisma.playlistPendingTrack.delete({ where: { id: pendingId } }),
|
|
prisma.playlistItem.create({
|
|
data: { playlistId, trackId: matchedTrack.id, sort: pending.sort }
|
|
})
|
|
]);
|
|
```
|
|
|
|
## Configuration Check
|
|
|
|
```typescript
|
|
const settings = await getSystemSettings();
|
|
// Key fields:
|
|
// - settings.downloadSource: "soulseek" | "lidarr"
|
|
// - settings.soulseekFallback: "none" | "failed" | "always"
|
|
// - settings.musicPath: where files are downloaded
|
|
// - settings.soulseekUsername / soulseekPassword
|
|
// - settings.lidarrUrl / lidarrApiKey
|
|
```
|
|
|
|
## Error Handling Patterns
|
|
|
|
### Soulseek Connection
|
|
```typescript
|
|
try {
|
|
await soulseekService.ensureConnected();
|
|
} catch (err) {
|
|
// Credentials not configured or connection failed
|
|
return { success: false, error: "Soulseek connection failed" };
|
|
}
|
|
```
|
|
|
|
### Download Retry Logic
|
|
```typescript
|
|
const matchesToTry = allMatches.slice(0, MAX_DOWNLOAD_RETRIES); // 3 attempts
|
|
for (const match of matchesToTry) {
|
|
const result = await this.downloadTrack(match, destPath);
|
|
if (result.success) return { success: true, filePath: destPath };
|
|
// Try next user on failure
|
|
}
|
|
return { success: false, error: "All attempts failed" };
|
|
```
|
|
|
|
## Logging
|
|
|
|
Session logging for debugging:
|
|
```typescript
|
|
import { sessionLog } from "../utils/playlistLogger";
|
|
sessionLog("SOULSEEK", "Message here"); // INFO level
|
|
sessionLog("SOULSEEK", "Error message", "ERROR");
|
|
sessionLog("SOULSEEK", "Warning", "WARN");
|
|
```
|
|
|
|
Job-specific logging:
|
|
```typescript
|
|
import { createPlaylistLogger } from "../utils/playlistLogger";
|
|
const logger = createPlaylistLogger(jobId);
|
|
logger.info("Message");
|
|
logger.error("Error");
|
|
logger.debug("Debug info");
|
|
``` |