Skip to content

Commit 53aa989

Browse files
jeffcrouseclaude
andcommitted
feat: add Missing Track System with external tracks, Spotify import, and wishlist
- Add ExternalTrack model for tracks not in local library - Playlists now support mixed local + external tracks - Add external track matching service (ISRC, exact, fuzzy) - Add Spotify playlist import with local/external splitting - Add LLM tools for Spotify playlist operations - Add "Add to Wishlist" action in Discovery views - Add preview playback support for external tracks - Auto-match external tracks when new local tracks added Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent c7f3e19 commit 53aa989

33 files changed

+3096
-156
lines changed

CHANGELOG.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.1.0-alpha.14] - 2026-01-25
11+
1012
### Added
1113

14+
- **Missing Track System** - first-class support for "missing tracks" (tracks you want but don't have locally)
15+
- **External Tracks model** - new `ExternalTrack` table to store metadata for tracks not in your library
16+
- **Mixed playlists** - playlists can now contain both local tracks and external track placeholders
17+
- **Visual distinction** - external tracks appear at 75% opacity with "Not in library" label
18+
- **Preview playback** - 30-second previews from Spotify/Deezer for external tracks (amber Radio icon)
19+
- **Purchase links** - external link pills to find tracks on Bandcamp, Spotify, Last.fm
20+
- **Auto-matching** - when you add tracks to library, they automatically link to matching external tracks
21+
- **Wishlist playlist** - special system playlist for tracks you're interested in
22+
- **Add to Wishlist** - purple "+" button on discovery items to save tracks you want
23+
- **Spotify playlist import** - import Spotify playlists with automatic local/external track splitting
24+
- `GET /spotify/playlists` - list your Spotify playlists
25+
- `GET /spotify/playlists/{id}/tracks` - preview tracks with local match status
26+
- `POST /spotify/playlists/{id}/import` - import playlist with local matches + external placeholders
27+
- **External track matching service** - intelligent matching algorithm
28+
- ISRC exact match (highest confidence)
29+
- Exact artist + title match
30+
- Partial match with title/artist contains
31+
- Fuzzy match with 85% threshold using rapidfuzz
32+
- Background re-matching after library scans
33+
- **LLM tools for Spotify playlists** - Claude can now work with your Spotify playlists
34+
- `list_spotify_playlists` - "What Spotify playlists do I have?"
35+
- `get_spotify_playlist_tracks` - "What's in my Spotify workout playlist?" (shows match rate)
36+
- `import_spotify_playlist` - "Import my Spotify chill playlist"
1237
- **Downloads view** - dedicated section for browsing all downloaded/offline tracks
1338
- New "Downloads" button in Playlists tab (below Favorites) with green gradient styling
1439
- Shows total track count and storage size used
@@ -21,6 +46,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2146
- Filter state persists in URL for Library view (`?downloadedOnly=true`)
2247
- **useDownloadedTracks hook** - returns downloaded tracks with metadata, total count, and storage size
2348

49+
### Changed
50+
51+
- **Playlist API responses** - now include `is_wishlist`, `local_track_count`, `external_track_count` fields
52+
- **Playlist tracks** - unified `PlaylistTrackItem` type with `type: 'local' | 'external'` discriminator
53+
- **Discovery components** - all discovery views now support "Add to Wishlist" action for non-library items
54+
2455
## [0.1.0-alpha.13] - 2026-01-23
2556

2657
### Added
Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,316 @@
1+
"""External track management endpoints.
2+
3+
External tracks are tracks the user wants but doesn't have locally.
4+
They appear in playlists alongside local tracks with visual distinction.
5+
"""
6+
7+
from uuid import UUID
8+
9+
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, status
10+
from pydantic import BaseModel, Field
11+
from sqlalchemy import func, select
12+
13+
from app.api.deps import DbSession, RequiredProfile
14+
from app.db.models import ExternalTrack, ExternalTrackSource
15+
from app.services.external_track_matcher import ExternalTrackMatcher
16+
17+
router = APIRouter(prefix="/external-tracks", tags=["external-tracks"])
18+
19+
20+
class ExternalTrackCreate(BaseModel):
21+
"""Request to create an external track."""
22+
23+
title: str = Field(..., min_length=1, max_length=500)
24+
artist: str = Field(..., min_length=1, max_length=500)
25+
album: str | None = Field(None, max_length=500)
26+
duration_seconds: float | None = None
27+
year: int | None = None
28+
isrc: str | None = Field(None, max_length=12)
29+
spotify_id: str | None = Field(None, max_length=50)
30+
preview_url: str | None = Field(None, max_length=500)
31+
preview_source: str | None = Field(None, max_length=20)
32+
external_data: dict | None = None
33+
34+
35+
class ExternalTrackResponse(BaseModel):
36+
"""External track response."""
37+
38+
id: str
39+
title: str
40+
artist: str
41+
album: str | None
42+
duration_seconds: float | None
43+
track_number: int | None
44+
year: int | None
45+
source: str
46+
preview_url: str | None
47+
preview_source: str | None
48+
external_data: dict
49+
50+
# Matching status
51+
is_matched: bool
52+
matched_track_id: str | None
53+
matched_at: str | None
54+
match_confidence: float | None
55+
match_method: str | None
56+
57+
# External IDs
58+
spotify_id: str | None
59+
isrc: str | None
60+
61+
created_at: str
62+
63+
64+
class ManualMatchRequest(BaseModel):
65+
"""Request to manually match an external track."""
66+
67+
track_id: str = Field(..., description="ID of local track to match to")
68+
69+
70+
class RematchResponse(BaseModel):
71+
"""Response from rematch operation."""
72+
73+
processed: int
74+
matched: int
75+
task_id: str | None = None
76+
77+
78+
@router.get("", response_model=list[ExternalTrackResponse])
79+
async def list_external_tracks(
80+
db: DbSession,
81+
profile: RequiredProfile,
82+
matched: bool | None = Query(None, description="Filter by matched status"),
83+
source: str | None = Query(None, description="Filter by source"),
84+
limit: int = Query(100, ge=1, le=500),
85+
offset: int = Query(0, ge=0),
86+
) -> list[ExternalTrackResponse]:
87+
"""List external tracks with optional filtering."""
88+
query = select(ExternalTrack)
89+
90+
if matched is not None:
91+
if matched:
92+
query = query.where(ExternalTrack.matched_track_id.isnot(None))
93+
else:
94+
query = query.where(ExternalTrack.matched_track_id.is_(None))
95+
96+
if source:
97+
try:
98+
source_enum = ExternalTrackSource(source)
99+
query = query.where(ExternalTrack.source == source_enum)
100+
except ValueError:
101+
pass # Invalid source, ignore filter
102+
103+
query = query.order_by(ExternalTrack.created_at.desc()).limit(limit).offset(offset)
104+
105+
result = await db.execute(query)
106+
tracks = result.scalars().all()
107+
108+
return [_external_track_to_response(t) for t in tracks]
109+
110+
111+
@router.get("/stats")
112+
async def get_external_track_stats(
113+
db: DbSession,
114+
profile: RequiredProfile,
115+
) -> dict:
116+
"""Get statistics about external tracks."""
117+
# Total count
118+
total = await db.scalar(select(func.count(ExternalTrack.id))) or 0
119+
120+
# Matched count
121+
matched = await db.scalar(
122+
select(func.count(ExternalTrack.id)).where(
123+
ExternalTrack.matched_track_id.isnot(None)
124+
)
125+
) or 0
126+
127+
# Count by source
128+
source_counts = {}
129+
for source in ExternalTrackSource:
130+
count = await db.scalar(
131+
select(func.count(ExternalTrack.id)).where(
132+
ExternalTrack.source == source
133+
)
134+
) or 0
135+
source_counts[source.value] = count
136+
137+
return {
138+
"total": total,
139+
"matched": matched,
140+
"unmatched": total - matched,
141+
"match_rate": round(matched / total * 100, 1) if total > 0 else 0,
142+
"by_source": source_counts,
143+
}
144+
145+
146+
@router.post("", response_model=ExternalTrackResponse, status_code=status.HTTP_201_CREATED)
147+
async def create_external_track(
148+
request: ExternalTrackCreate,
149+
db: DbSession,
150+
profile: RequiredProfile,
151+
) -> ExternalTrackResponse:
152+
"""Create a new external track (manually added)."""
153+
matcher = ExternalTrackMatcher(db)
154+
155+
external_track = await matcher.create_external_track(
156+
title=request.title,
157+
artist=request.artist,
158+
album=request.album,
159+
source=ExternalTrackSource.MANUAL,
160+
spotify_id=request.spotify_id,
161+
isrc=request.isrc,
162+
duration_seconds=request.duration_seconds,
163+
preview_url=request.preview_url,
164+
preview_source=request.preview_source,
165+
external_data=request.external_data,
166+
try_match=True,
167+
)
168+
169+
return _external_track_to_response(external_track)
170+
171+
172+
@router.get("/{external_track_id}", response_model=ExternalTrackResponse)
173+
async def get_external_track(
174+
external_track_id: UUID,
175+
db: DbSession,
176+
profile: RequiredProfile,
177+
) -> ExternalTrackResponse:
178+
"""Get an external track by ID."""
179+
external_track = await db.get(ExternalTrack, external_track_id)
180+
181+
if not external_track:
182+
raise HTTPException(
183+
status_code=status.HTTP_404_NOT_FOUND,
184+
detail="External track not found",
185+
)
186+
187+
return _external_track_to_response(external_track)
188+
189+
190+
@router.delete("/{external_track_id}", status_code=status.HTTP_204_NO_CONTENT)
191+
async def delete_external_track(
192+
external_track_id: UUID,
193+
db: DbSession,
194+
profile: RequiredProfile,
195+
) -> None:
196+
"""Delete an external track."""
197+
external_track = await db.get(ExternalTrack, external_track_id)
198+
199+
if not external_track:
200+
raise HTTPException(
201+
status_code=status.HTTP_404_NOT_FOUND,
202+
detail="External track not found",
203+
)
204+
205+
await db.delete(external_track)
206+
await db.commit()
207+
208+
209+
@router.post("/{external_track_id}/match", response_model=ExternalTrackResponse)
210+
async def manual_match(
211+
external_track_id: UUID,
212+
request: ManualMatchRequest,
213+
db: DbSession,
214+
profile: RequiredProfile,
215+
) -> ExternalTrackResponse:
216+
"""Manually match an external track to a local track."""
217+
matcher = ExternalTrackMatcher(db)
218+
219+
try:
220+
track_id = UUID(request.track_id)
221+
except ValueError:
222+
raise HTTPException(
223+
status_code=status.HTTP_400_BAD_REQUEST,
224+
detail="Invalid track_id format",
225+
)
226+
227+
try:
228+
external_track = await matcher.manual_match(external_track_id, track_id)
229+
except ValueError as e:
230+
raise HTTPException(
231+
status_code=status.HTTP_404_NOT_FOUND,
232+
detail=str(e),
233+
)
234+
235+
if not external_track:
236+
raise HTTPException(
237+
status_code=status.HTTP_404_NOT_FOUND,
238+
detail="External track not found",
239+
)
240+
241+
return _external_track_to_response(external_track)
242+
243+
244+
@router.delete("/{external_track_id}/match", response_model=ExternalTrackResponse)
245+
async def remove_match(
246+
external_track_id: UUID,
247+
db: DbSession,
248+
profile: RequiredProfile,
249+
) -> ExternalTrackResponse:
250+
"""Remove the match from an external track."""
251+
matcher = ExternalTrackMatcher(db)
252+
external_track = await matcher.remove_match(external_track_id)
253+
254+
if not external_track:
255+
raise HTTPException(
256+
status_code=status.HTTP_404_NOT_FOUND,
257+
detail="External track not found",
258+
)
259+
260+
return _external_track_to_response(external_track)
261+
262+
263+
@router.post("/rematch", response_model=RematchResponse)
264+
async def rematch_all(
265+
db: DbSession,
266+
profile: RequiredProfile,
267+
background_tasks: BackgroundTasks,
268+
run_in_background: bool = Query(False, description="Run in background"),
269+
) -> RematchResponse:
270+
"""Re-run matching for all unmatched external tracks.
271+
272+
If run_in_background=True, returns immediately with task_id.
273+
Otherwise, runs synchronously and returns results.
274+
"""
275+
matcher = ExternalTrackMatcher(db)
276+
277+
if run_in_background:
278+
# For now, just run synchronously but return task structure
279+
# TODO: Implement proper background task tracking
280+
stats = await matcher.rematch_all_unmatched()
281+
return RematchResponse(
282+
processed=stats["processed"],
283+
matched=stats["matched"],
284+
task_id=None,
285+
)
286+
else:
287+
stats = await matcher.rematch_all_unmatched()
288+
return RematchResponse(
289+
processed=stats["processed"],
290+
matched=stats["matched"],
291+
)
292+
293+
294+
def _external_track_to_response(track: ExternalTrack) -> ExternalTrackResponse:
295+
"""Convert ExternalTrack model to response."""
296+
return ExternalTrackResponse(
297+
id=str(track.id),
298+
title=track.title,
299+
artist=track.artist,
300+
album=track.album,
301+
duration_seconds=track.duration_seconds,
302+
track_number=track.track_number,
303+
year=track.year,
304+
source=track.source.value,
305+
preview_url=track.preview_url,
306+
preview_source=track.preview_source,
307+
external_data=track.external_data or {},
308+
is_matched=track.matched_track_id is not None,
309+
matched_track_id=str(track.matched_track_id) if track.matched_track_id else None,
310+
matched_at=track.matched_at.isoformat() if track.matched_at else None,
311+
match_confidence=track.match_confidence,
312+
match_method=track.match_method,
313+
spotify_id=track.spotify_id,
314+
isrc=track.isrc,
315+
created_at=track.created_at.isoformat(),
316+
)

0 commit comments

Comments
 (0)