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.
This commit is contained in:
root
2026-03-31 20:49:07 -05:00
parent 957a66bbd0
commit 5215e8c792
6 changed files with 138 additions and 6 deletions

View File

@@ -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 import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@@ -49,6 +50,39 @@ async def get_playlist(
return 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}") @router.delete("/{playlist_id}")
async def delete_playlist( async def delete_playlist(
playlist_id: int, playlist_id: int,

View File

@@ -1,9 +1,13 @@
import hashlib import hashlib
import json import json
import logging
from urllib.parse import quote_plus from urllib.parse import quote_plus
import anthropic import anthropic
api_logger = logging.getLogger("app")
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import PlainTextResponse
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -77,6 +81,24 @@ async def history(
return result.scalars().all() 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]) @router.get("/saved", response_model=list[RecommendationItem])
async def saved( async def saved(
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
@@ -132,6 +154,12 @@ Return ONLY the JSON object."""
messages=[{"role": "user", "content": prompt}], 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() response_text = message.content[0].text.strip()
if response_text.startswith("```"): if response_text.startswith("```"):
response_text = response_text.split("\n", 1)[1] response_text = response_text.split("\n", 1)[1]

View File

@@ -7,6 +7,21 @@
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" />
<meta name="description" content="Discover music you'll love with AI. Import your playlists, get personalized recommendations, and explore new artists." />
<meta name="keywords" content="music discovery, AI music, playlist analyzer, find new music, music recommendations" />
<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:title" content="Vynl - AI Music Discovery" />
<meta property="og:description" content="Dig deeper. Discover more. AI-powered music recommendations that actually understand your taste." />
<meta property="og:url" content="https://deepcutsai.com" />
<meta property="og:site_name" content="Vynl" />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Vynl - AI Music Discovery" />
<meta name="twitter:description" content="Dig deeper. Discover more. AI-powered music recommendations that actually understand your taste." />
<title>Vynl - AI Music Discovery</title> <title>Vynl - AI Music Discovery</title>
</head> </head>
<body> <body>

View File

@@ -144,6 +144,16 @@ export const login = (email: string, password: string) =>
export const getMe = () => export const getMe = () =>
api.get<UserResponse>('/auth/me').then((r) => r.data) api.get<UserResponse>('/auth/me').then((r) => r.data)
// Profile & Settings
export const updateProfile = (data: { name?: string; email?: string }) =>
api.put<UserResponse>('/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 // Spotify OAuth
export const getSpotifyAuthUrl = () => export const getSpotifyAuthUrl = () =>
api.get<{ url: string; state: string }>('/auth/spotify/authorize').then((r) => r.data) 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) => export const fixPlaylist = (playlistId: string) =>
api.post<PlaylistFixResponse>(`/playlists/${playlistId}/fix`).then((r) => r.data) api.post<PlaylistFixResponse>(`/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 // Taste Profile
export const getTasteProfile = () => export const getTasteProfile = () =>
api.get<TasteProfileResponse>('/profile/taste').then((r) => r.data) api.get<TasteProfileResponse>('/profile/taste').then((r) => r.data)

View File

@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom' import { useParams, useNavigate, Link } from 'react-router-dom'
import { ArrowLeft, Loader2, Music, Clock, Sparkles, Trash2, Wand2, AlertTriangle, CheckCircle2, X } from 'lucide-react' import { ArrowLeft, Loader2, Music, Clock, Sparkles, Trash2, Wand2, AlertTriangle, CheckCircle2, X, Download } from 'lucide-react'
import { getPlaylist, deletePlaylist, fixPlaylist, type PlaylistDetailResponse, type PlaylistFixResponse } from '../lib/api' import { getPlaylist, deletePlaylist, fixPlaylist, exportPlaylist, type PlaylistDetailResponse, type PlaylistFixResponse } from '../lib/api'
import TasteProfile from '../components/TasteProfile' import TasteProfile from '../components/TasteProfile'
function formatDuration(ms: number): string { function formatDuration(ms: number): string {
@@ -138,6 +138,20 @@ export default function PlaylistDetail() {
{showProfile ? 'Hide' : 'Show'} Taste Profile {showProfile ? 'Hide' : 'Show'} Taste Profile
</button> </button>
)} )}
<button
onClick={() => id && exportPlaylist(id, 'text')}
className="flex items-center gap-2 px-4 py-2.5 bg-purple-50 text-purple text-sm font-medium rounded-xl hover:bg-purple-100 transition-colors cursor-pointer border-none"
>
<Download className="w-4 h-4" />
Export TXT
</button>
<button
onClick={() => id && exportPlaylist(id, 'csv')}
className="flex items-center gap-2 px-4 py-2.5 bg-purple-50 text-purple text-sm font-medium rounded-xl hover:bg-purple-100 transition-colors cursor-pointer border-none"
>
<Download className="w-4 h-4" />
Export CSV
</button>
<button <button
onClick={handleDelete} onClick={handleDelete}
disabled={deleting} disabled={deleting}

View File

@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Loader2, Clock, Heart, Sparkles } from 'lucide-react' import { Loader2, Clock, Heart, Sparkles, Download } from 'lucide-react'
import { useAuth } from '../lib/auth' import { useAuth } from '../lib/auth'
import { getRecommendationHistory, getSavedRecommendations, toggleSaveRecommendation, type RecommendationItem } from '../lib/api' import { getRecommendationHistory, getSavedRecommendations, toggleSaveRecommendation, exportSaved, type RecommendationItem } from '../lib/api'
import RecommendationCard from '../components/RecommendationCard' import RecommendationCard from '../components/RecommendationCard'
type Tab = 'saved' | 'history' type Tab = 'saved' | 'history'
@@ -92,7 +92,18 @@ export default function Recommendations() {
</div> </div>
)} )}
{/* Tabs */} {/* Export + Tabs */}
<div className="flex items-center gap-4">
{tab === 'saved' && saved.length > 0 && (
<button
onClick={() => exportSaved()}
className="flex items-center gap-2 px-4 py-2.5 bg-purple-50 text-purple text-sm font-medium rounded-xl hover:bg-purple-100 transition-colors cursor-pointer border-none"
>
<Download className="w-4 h-4" />
Export Saved
</button>
)}
</div>
<div className="flex gap-1 bg-purple-50 p-1 rounded-xl w-fit"> <div className="flex gap-1 bg-purple-50 p-1 rounded-xl w-fit">
<button <button
onClick={() => setTab('saved')} onClick={() => setTab('saved')}