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:
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -7,6 +7,21 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<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" />
|
||||
<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>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -144,6 +144,16 @@ export const login = (email: string, password: string) =>
|
||||
export const getMe = () =>
|
||||
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
|
||||
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<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
|
||||
export const getTasteProfile = () =>
|
||||
api.get<TasteProfileResponse>('/profile/taste').then((r) => r.data)
|
||||
|
||||
@@ -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
|
||||
</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
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { getRecommendationHistory, getSavedRecommendations, toggleSaveRecommendation, type RecommendationItem } from '../lib/api'
|
||||
import { getRecommendationHistory, getSavedRecommendations, toggleSaveRecommendation, exportSaved, type RecommendationItem } from '../lib/api'
|
||||
import RecommendationCard from '../components/RecommendationCard'
|
||||
|
||||
type Tab = 'saved' | 'history'
|
||||
@@ -92,7 +92,18 @@ export default function Recommendations() {
|
||||
</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">
|
||||
<button
|
||||
onClick={() => setTab('saved')}
|
||||
|
||||
Reference in New Issue
Block a user