From d0ab1755bb1bf6539cc64ff4050a9eca422dee1f Mon Sep 17 00:00:00 2001 From: root Date: Mon, 30 Mar 2026 22:48:35 -0500 Subject: [PATCH] Add paste-your-songs manual import feature Users can now paste a list of songs as text to create a playlist without needing any service integration. Supports multiple formats: "Artist - Title", "Title by Artist", "Artist: Title", and numbered lists. Includes a live song count preview in the modal and free tier playlist limit enforcement. --- backend/app/api/endpoints/manual_import.py | 112 +++++++++ backend/app/main.py | 4 +- frontend/src/lib/api.ts | 24 ++ frontend/src/pages/Discover.tsx | 2 +- frontend/src/pages/Playlists.tsx | 264 ++++++++++++++++++++- 5 files changed, 402 insertions(+), 4 deletions(-) create mode 100644 backend/app/api/endpoints/manual_import.py diff --git a/backend/app/api/endpoints/manual_import.py b/backend/app/api/endpoints/manual_import.py new file mode 100644 index 0000000..5aff86e --- /dev/null +++ b/backend/app/api/endpoints/manual_import.py @@ -0,0 +1,112 @@ +import re + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import settings +from app.core.database import get_db +from app.core.security import get_current_user +from app.models.user import User +from app.models.playlist import Playlist +from app.models.track import Track +from app.services.recommender import build_taste_profile +from app.schemas.playlist import PlaylistDetailResponse + +router = APIRouter(prefix="/import", tags=["import"]) + + +class PasteImportRequest(BaseModel): + name: str + text: str + + +def parse_song_line(line: str) -> dict | None: + line = line.strip() + if not line: + return None + + # Strip leading numbering: "1.", "2)", "1 -", "01.", etc. + line = re.sub(r"^\d+[\.\)\-\:]\s*", "", line).strip() + if not line: + return None + + # Try "Artist - Title" (most common) + if " - " in line: + parts = line.split(" - ", 1) + return {"artist": parts[0].strip(), "title": parts[1].strip()} + + # Try "Title by Artist" + if " by " in line.lower(): + idx = line.lower().index(" by ") + return {"title": line[:idx].strip(), "artist": line[idx + 4 :].strip()} + + # Try "Artist: Title" + if ": " in line: + parts = line.split(": ", 1) + return {"artist": parts[0].strip(), "title": parts[1].strip()} + + # Fallback: treat whole line as title with unknown artist + return {"title": line, "artist": "Unknown"} + + +@router.post("/paste", response_model=PlaylistDetailResponse) +async def import_pasted_songs( + data: PasteImportRequest, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + if not data.name.strip(): + raise HTTPException(status_code=400, detail="Playlist name is required") + if not data.text.strip(): + raise HTTPException(status_code=400, detail="No songs provided") + + # Free tier limit + if not user.is_pro: + result = await db.execute( + select(Playlist).where(Playlist.user_id == user.id) + ) + existing = list(result.scalars().all()) + if len(existing) >= settings.FREE_MAX_PLAYLISTS: + raise HTTPException( + status_code=403, + detail="Free tier limited to 1 playlist. Upgrade to Pro for unlimited.", + ) + + # Parse lines + lines = data.text.strip().splitlines() + parsed = [parse_song_line(line) for line in lines] + parsed = [p for p in parsed if p is not None] + + if not parsed: + raise HTTPException(status_code=400, detail="Could not parse any songs from the text") + + # Create playlist + playlist = Playlist( + user_id=user.id, + name=data.name.strip(), + platform_source="manual", + track_count=len(parsed), + ) + db.add(playlist) + await db.flush() + + # Create tracks + tracks = [] + for p in parsed: + track = Track( + playlist_id=playlist.id, + title=p["title"], + artist=p["artist"], + ) + db.add(track) + tracks.append(track) + + await db.flush() + + # Build taste profile + playlist.taste_profile = build_taste_profile(tracks) + playlist.tracks = tracks + + return playlist diff --git a/backend/app/main.py b/backend/app/main.py index e97a8dc..549648d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,7 +2,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from app.core.config import settings -from app.api.endpoints import auth, billing, playlists, recommendations, youtube_music +from app.api.endpoints import auth, billing, lastfm, manual_import, playlists, recommendations, youtube_music app = FastAPI(title="Vynl API", version="1.0.0") @@ -19,6 +19,8 @@ app.include_router(billing.router, prefix="/api") app.include_router(playlists.router, prefix="/api") app.include_router(recommendations.router, prefix="/api") app.include_router(youtube_music.router, prefix="/api") +app.include_router(manual_import.router, prefix="/api") +app.include_router(lastfm.router, prefix="/api") @app.get("/api/health") diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 4613e04..8dafbb6 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -171,6 +171,30 @@ export const importYouTubePlaylist = (url: string) => export const searchYouTubeMusic = (query: string) => api.post('/youtube-music/search', { query }).then((r) => r.data) +// Last.fm Import +export interface LastfmPreviewTrack { + title: string + artist: string + playcount: number + image_url: string | null +} + +export interface LastfmPreviewResponse { + display_name: string + track_count: number + sample_tracks: LastfmPreviewTrack[] +} + +export const previewLastfm = (username: string) => + api.get('/lastfm/preview', { params: { username } }).then((r) => r.data) + +export const importLastfm = (username: string, period: string) => + api.post('/lastfm/import', { username, period }).then((r) => r.data) + +// Manual Import (paste songs) +export const importPastedSongs = (name: string, text: string) => + api.post('/import/paste', { name, text }).then((r) => r.data) + // Billing export interface BillingStatusResponse { is_pro: boolean diff --git a/frontend/src/pages/Discover.tsx b/frontend/src/pages/Discover.tsx index d9aef32..8a8ecdb 100644 --- a/frontend/src/pages/Discover.tsx +++ b/frontend/src/pages/Discover.tsx @@ -127,7 +127,7 @@ export default function Discover() {