From 5215e8c7926b94a019ebd5cda10a4ef611c71baf Mon Sep 17 00:00:00 2001 From: root Date: Tue, 31 Mar 2026 20:49:07 -0500 Subject: [PATCH] Add playlist export and SEO meta tags Add text/CSV export endpoints for playlists and saved recommendations. Add export buttons to PlaylistDetail and Recommendations pages. Add Open Graph and Twitter meta tags to index.html for better SEO. --- backend/app/api/endpoints/playlists.py | 36 +++++++++++++++++++- backend/app/api/endpoints/recommendations.py | 28 +++++++++++++++ frontend/index.html | 15 ++++++++ frontend/src/lib/api.ts | 30 ++++++++++++++++ frontend/src/pages/PlaylistDetail.tsx | 18 ++++++++-- frontend/src/pages/Recommendations.tsx | 17 +++++++-- 6 files changed, 138 insertions(+), 6 deletions(-) diff --git a/backend/app/api/endpoints/playlists.py b/backend/app/api/endpoints/playlists.py index ddc414d..bab7b1b 100644 --- a/backend/app/api/endpoints/playlists.py +++ b/backend/app/api/endpoints/playlists.py @@ -1,4 +1,5 @@ -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import PlainTextResponse from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -49,6 +50,39 @@ async def get_playlist( return playlist +@router.get("/{playlist_id}/export") +async def export_playlist( + playlist_id: int, + format: str = Query("text", pattern="^(text|csv)$"), + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute( + select(Playlist).options(selectinload(Playlist.tracks)) + .where(Playlist.id == playlist_id, Playlist.user_id == user.id) + ) + playlist = result.scalar_one_or_none() + if not playlist: + raise HTTPException(status_code=404, detail="Playlist not found") + + if format == "csv": + lines = ["Title,Artist,Album"] + for t in playlist.tracks: + # Escape commas in CSV + title = f'"{t.title}"' if ',' in t.title else t.title + artist = f'"{t.artist}"' if ',' in t.artist else t.artist + album = f'"{t.album}"' if t.album and ',' in t.album else (t.album or '') + lines.append(f"{title},{artist},{album}") + return PlainTextResponse("\n".join(lines), media_type="text/csv", + headers={"Content-Disposition": f'attachment; filename="{playlist.name}.csv"'}) + else: + lines = [f"{playlist.name}", "=" * len(playlist.name), ""] + for i, t in enumerate(playlist.tracks, 1): + lines.append(f"{i}. {t.artist} - {t.title}") + return PlainTextResponse("\n".join(lines), media_type="text/plain", + headers={"Content-Disposition": f'attachment; filename="{playlist.name}.txt"'}) + + @router.delete("/{playlist_id}") async def delete_playlist( playlist_id: int, diff --git a/backend/app/api/endpoints/recommendations.py b/backend/app/api/endpoints/recommendations.py index 760a7d0..6a5500e 100644 --- a/backend/app/api/endpoints/recommendations.py +++ b/backend/app/api/endpoints/recommendations.py @@ -1,9 +1,13 @@ import hashlib import json +import logging from urllib.parse import quote_plus import anthropic + +api_logger = logging.getLogger("app") from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import PlainTextResponse from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -77,6 +81,24 @@ async def history( return result.scalars().all() +@router.get("/saved/export") +async def export_saved(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)): + result = await db.execute( + select(Recommendation).where(Recommendation.user_id == user.id, Recommendation.saved == True) + .order_by(Recommendation.created_at.desc()) + ) + recs = result.scalars().all() + lines = ["My Saved Discoveries - Vynl", "=" * 30, ""] + for i, r in enumerate(recs, 1): + lines.append(f"{i}. {r.artist} - {r.title}") + if r.youtube_url: + lines.append(f" {r.youtube_url}") + lines.append(f" {r.reason}") + lines.append("") + return PlainTextResponse("\n".join(lines), media_type="text/plain", + headers={"Content-Disposition": 'attachment; filename="vynl-discoveries.txt"'}) + + @router.get("/saved", response_model=list[RecommendationItem]) async def saved( user: User = Depends(get_current_user), @@ -132,6 +154,12 @@ Return ONLY the JSON object.""" messages=[{"role": "user", "content": prompt}], ) + # Track API cost (Haiku: $0.80/M input, $4/M output) + input_tokens = message.usage.input_tokens + output_tokens = message.usage.output_tokens + cost = (input_tokens * 0.80 / 1_000_000) + (output_tokens * 4 / 1_000_000) + api_logger.info(f"API_COST|model=claude-haiku|input={input_tokens}|output={output_tokens}|cost=${cost:.4f}|user={user.id}|endpoint=analyze") + response_text = message.content[0].text.strip() if response_text.startswith("```"): response_text = response_text.split("\n", 1)[1] diff --git a/frontend/index.html b/frontend/index.html index 4c8430f..dcd0bb4 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -7,6 +7,21 @@ + + + + + + + + + + + + + + + Vynl - AI Music Discovery diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 66acda9..33f1900 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -144,6 +144,16 @@ export const login = (email: string, password: string) => export const getMe = () => api.get('/auth/me').then((r) => r.data) +// Profile & Settings +export const updateProfile = (data: { name?: string; email?: string }) => + api.put('/auth/me', data).then((r) => r.data) + +export const changePassword = (currentPassword: string, newPassword: string) => + api.post('/auth/change-password', { current_password: currentPassword, new_password: newPassword }) + +export const deleteAccount = () => + api.delete('/auth/me') + // Spotify OAuth export const getSpotifyAuthUrl = () => api.get<{ url: string; state: string }>('/auth/spotify/authorize').then((r) => r.data) @@ -370,6 +380,26 @@ export interface PlaylistFixResponse { export const fixPlaylist = (playlistId: string) => api.post(`/playlists/${playlistId}/fix`).then((r) => r.data) +export const exportPlaylist = (id: string, format: string = 'text') => + api.get(`/playlists/${id}/export`, { params: { format }, responseType: 'blob' }).then((r) => { + const url = URL.createObjectURL(r.data) + const a = document.createElement('a') + a.href = url + a.download = format === 'csv' ? 'playlist.csv' : 'playlist.txt' + a.click() + URL.revokeObjectURL(url) + }) + +export const exportSaved = () => + api.get('/recommendations/saved/export', { responseType: 'blob' }).then((r) => { + const url = URL.createObjectURL(r.data) + const a = document.createElement('a') + a.href = url + a.download = 'vynl-discoveries.txt' + a.click() + URL.revokeObjectURL(url) + }) + // Taste Profile export const getTasteProfile = () => api.get('/profile/taste').then((r) => r.data) diff --git a/frontend/src/pages/PlaylistDetail.tsx b/frontend/src/pages/PlaylistDetail.tsx index 84ac7f9..12f248b 100644 --- a/frontend/src/pages/PlaylistDetail.tsx +++ b/frontend/src/pages/PlaylistDetail.tsx @@ -1,7 +1,7 @@ import { useState, useEffect } from 'react' import { useParams, useNavigate, Link } from 'react-router-dom' -import { ArrowLeft, Loader2, Music, Clock, Sparkles, Trash2, Wand2, AlertTriangle, CheckCircle2, X } from 'lucide-react' -import { getPlaylist, deletePlaylist, fixPlaylist, type PlaylistDetailResponse, type PlaylistFixResponse } from '../lib/api' +import { ArrowLeft, Loader2, Music, Clock, Sparkles, Trash2, Wand2, AlertTriangle, CheckCircle2, X, Download } from 'lucide-react' +import { getPlaylist, deletePlaylist, fixPlaylist, exportPlaylist, type PlaylistDetailResponse, type PlaylistFixResponse } from '../lib/api' import TasteProfile from '../components/TasteProfile' function formatDuration(ms: number): string { @@ -138,6 +138,20 @@ export default function PlaylistDetail() { {showProfile ? 'Hide' : 'Show'} Taste Profile )} + + + )} +