diff --git a/app/api/spotify/auth/route.ts b/app/api/spotify/auth/route.ts new file mode 100644 index 0000000..9c3918f --- /dev/null +++ b/app/api/spotify/auth/route.ts @@ -0,0 +1,20 @@ +export const dynamic = 'force-dynamic' +import { NextResponse } from 'next/server' + +export async function GET() { + const clientId = process.env.SPOTIFY_CLIENT_ID! + const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL}/api/spotify/callback` + const scopes = [ + 'user-read-playback-state', + 'user-modify-playback-state', + 'user-read-currently-playing', + ].join(' ') + + const url = new URL('https://accounts.spotify.com/authorize') + url.searchParams.set('response_type', 'code') + url.searchParams.set('client_id', clientId) + url.searchParams.set('scope', scopes) + url.searchParams.set('redirect_uri', redirectUri) + + return NextResponse.redirect(url.toString()) +} diff --git a/app/api/spotify/callback/route.ts b/app/api/spotify/callback/route.ts new file mode 100644 index 0000000..51b105e --- /dev/null +++ b/app/api/spotify/callback/route.ts @@ -0,0 +1,44 @@ +export const dynamic = 'force-dynamic' +import { NextRequest, NextResponse } from 'next/server' +import { writeFileSync } from 'fs' + +export async function GET(req: NextRequest) { + const code = req.nextUrl.searchParams.get('code') + if (!code) return NextResponse.json({ error: 'no code' }, { status: 400 }) + + const clientId = process.env.SPOTIFY_CLIENT_ID! + const clientSecret = process.env.SPOTIFY_CLIENT_SECRET! + const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL}/api/spotify/callback` + + const res = await fetch('https://accounts.spotify.com/api/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`, + }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: redirectUri, + }), + }) + + const data = await res.json() + if (!data.refresh_token) { + return NextResponse.json({ error: 'no refresh_token', data }, { status: 400 }) + } + + // Save to file for extraction + try { + writeFileSync('/tmp/spotify_refresh_token.txt', data.refresh_token) + } catch {} + + return new NextResponse(` +
+Refresh Token:
+${data.refresh_token}
+ Скопируй токен и передай Cosmo для сохранения в .env
+ + `, { headers: { 'Content-Type': 'text/html' } }) +} diff --git a/app/api/voice/tools/spotify/route.ts b/app/api/voice/tools/spotify/route.ts new file mode 100644 index 0000000..3337e89 --- /dev/null +++ b/app/api/voice/tools/spotify/route.ts @@ -0,0 +1,78 @@ +export const dynamic = 'force-dynamic' +import { NextRequest, NextResponse } from 'next/server' +import { getAccessToken } from '@/lib/spotify-client' + +async function spotifyFetch(path: string, method = 'GET', body?: any) { + const token = await getAccessToken() + const res = await fetch(`https://api.spotify.com/v1${path}`, { + method, + headers: { + Authorization: `Bearer ${token}`, + ...(body ? { 'Content-Type': 'application/json' } : {}), + }, + ...(body ? { body: JSON.stringify(body) } : {}), + }) + if (res.status === 204) return { success: true } + if (!res.ok) { + const err = await res.text() + throw new Error(`Spotify API ${res.status}: ${err}`) + } + return res.json().catch(() => ({ success: true })) +} + +export async function GET() { + // Get current playback state + const data = await spotifyFetch('/me/player') + if (!data || !data.item) return NextResponse.json({ playing: false }) + return NextResponse.json({ + playing: data.is_playing, + track: data.item.name, + artist: data.item.artists?.map((a: any) => a.name).join(', '), + album: data.item.album?.name, + volume: data.volume_percent, + device: data.device?.name, + }) +} + +export async function POST(req: NextRequest) { + const body = await req.json() + const { action, query, volume } = body + + switch (action) { + case 'play': + if (query) { + // Search first + const search = await spotifyFetch(`/search?q=${encodeURIComponent(query)}&type=track,artist,playlist&limit=1`) + const track = search.tracks?.items?.[0] + const artist = search.artists?.items?.[0] + if (track) { + await spotifyFetch('/me/player/play', 'PUT', { uris: [track.uri] }) + return NextResponse.json({ success: true, playing: track.name, artist: track.artists?.[0]?.name }) + } else if (artist) { + // Play top tracks of artist + const top = await spotifyFetch(`/artists/${artist.id}/top-tracks?market=RU`) + const uris = top.tracks?.slice(0, 10).map((t: any) => t.uri) || [] + await spotifyFetch('/me/player/play', 'PUT', { uris }) + return NextResponse.json({ success: true, playing: `${artist.name} — топ треки` }) + } + return NextResponse.json({ error: 'not found' }, { status: 404 }) + } else { + await spotifyFetch('/me/player/play', 'PUT') + return NextResponse.json({ success: true }) + } + case 'pause': + await spotifyFetch('/me/player/pause', 'PUT') + return NextResponse.json({ success: true }) + case 'next': + await spotifyFetch('/me/player/next', 'POST') + return NextResponse.json({ success: true }) + case 'previous': + await spotifyFetch('/me/player/previous', 'POST') + return NextResponse.json({ success: true }) + case 'volume': + await spotifyFetch(`/me/player/volume?volume_percent=${Math.min(100, Math.max(0, volume || 50))}`, 'PUT') + return NextResponse.json({ success: true, volume }) + default: + return NextResponse.json({ error: 'unknown action' }, { status: 400 }) + } +} diff --git a/lib/spotify-client.ts b/lib/spotify-client.ts new file mode 100644 index 0000000..b8db08f --- /dev/null +++ b/lib/spotify-client.ts @@ -0,0 +1,34 @@ +let cachedToken: { token: string; expiresAt: number } | null = null + +export async function getAccessToken(): Promise