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(` + +

✅ Spotify авторизован!

+

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 { + if (cachedToken && Date.now() < cachedToken.expiresAt - 30_000) { + return cachedToken.token + } + + const refreshToken = process.env.SPOTIFY_REFRESH_TOKEN + if (!refreshToken) throw new Error('SPOTIFY_REFRESH_TOKEN not set') + + const clientId = process.env.SPOTIFY_CLIENT_ID! + const clientSecret = process.env.SPOTIFY_CLIENT_SECRET! + + 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: 'refresh_token', + refresh_token: refreshToken, + }), + }) + + const data = await res.json() + if (!data.access_token) throw new Error(`Spotify token refresh failed: ${JSON.stringify(data)}`) + + cachedToken = { + token: data.access_token, + expiresAt: Date.now() + (data.expires_in || 3600) * 1000, + } + return cachedToken.token +} diff --git a/lib/tools/_registry.ts b/lib/tools/_registry.ts index 26ca1d2..f7aeb3d 100644 --- a/lib/tools/_registry.ts +++ b/lib/tools/_registry.ts @@ -13,6 +13,7 @@ import { tools as calendarTools } from './calendar' import { tools as timerTools } from './timers' import { tool as notes } from './notes' import { tools as smartHomeTools } from './smart-home' +import { tools as spotifyTools } from './spotify' const ALL_TOOLS: VoiceTool[] = [ weather, @@ -21,6 +22,7 @@ const ALL_TOOLS: VoiceTool[] = [ ...timerTools, notes, ...smartHomeTools, + ...spotifyTools, ] export const TOOL_SCHEMAS = ALL_TOOLS.map((t) => t.schema) diff --git a/lib/tools/spotify.ts b/lib/tools/spotify.ts new file mode 100644 index 0000000..066d158 --- /dev/null +++ b/lib/tools/spotify.ts @@ -0,0 +1,59 @@ +import type { VoiceTool } from './_types' +import { tabletGet, tabletJson } from './_http' + +const getNowPlaying: VoiceTool = { + schema: { + type: 'function', + function: { + name: 'get_now_playing', + description: + 'Узнать что сейчас играет в Spotify: название трека, исполнитель, статус воспроизведения. ' + + 'Используй для вопросов «что сейчас играет», «какая музыка», «кто поёт».', + parameters: { type: 'object', properties: {} }, + }, + }, + async execute() { + return tabletGet('/api/voice/tools/spotify') + }, +} + +const controlSpotify: VoiceTool = { + schema: { + type: 'function', + function: { + name: 'control_spotify', + description: + 'Управление Spotify: включить музыку (можно с поиском), поставить на паузу, следующий/предыдущий трек, громкость. ' + + 'Используй для команд «включи музыку», «поставь паузу», «следующий трек», «громкость 50%», ' + + '«включи The Weeknd», «поставь джаз».', + parameters: { + type: 'object', + properties: { + action: { + type: 'string', + enum: ['play', 'pause', 'next', 'previous', 'volume'], + description: 'Действие: play, pause, next, previous, volume', + }, + query: { + type: 'string', + description: 'Поисковый запрос для action=play: исполнитель, трек или жанр', + }, + volume: { + type: 'number', + description: 'Громкость 0-100 для action=volume', + }, + }, + required: ['action'], + }, + }, + }, + async execute(args) { + return tabletJson('POST', '/api/voice/tools/spotify', { + action: args.action, + query: args.query, + volume: args.volume, + }) + }, +} + +export const tools: VoiceTool[] = [getNowPlaying, controlSpotify]