Distinguish Spotify playlist restrictions from missing OAuth scopes

This commit is contained in:
2026-11-11 13:41:49 +03:00
parent 0e921617dd
commit a318099add
2 changed files with 20 additions and 2 deletions
+11 -1
View File
@@ -106,7 +106,17 @@ export async function spotifyApi(path, { method = 'GET', body, signal } = {}) {
headers: { Authorization: `Bearer ${token}`, ...(body ? { 'Content-Type': 'application/json' } : {}) },
...(body ? { body: JSON.stringify(body) } : {}),
});
if (response.status === 403) return { error: 'spotify_permission_required', hint: 'Переподключи Spotify в пульте, чтобы разрешить работу с плейлистами.' };
if (response.status === 403) {
const failure = await response.json().catch(() => null);
const reason = `${response.headers.get('www-authenticate') || ''} ${failure?.error?.message || ''}`;
if (/insufficient[_ ](?:client )?scope/i.test(reason)) {
return { error: 'spotify_permission_required', hint: 'Переподключи Spotify в пульте, чтобы разрешить работу с плейлистами.' };
}
if (path.startsWith('playlists/')) {
return { error: 'spotify_playlist_access_denied', hint: 'Spotify разрешает читать и менять состав плейлиста его владельцу и соавторам.' };
}
return { error: 'spotify_access_denied' };
}
if (!response.ok) return { error: 'spotify_request_failed' };
return response.status === 204 ? { ok: true } : response.json();
}
+9 -1
View File
@@ -4,7 +4,7 @@ import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createHash } from 'node:crypto';
import { startSpotify, finishSpotify, spotifyStatus, searchSpotify } from '../server/spotify.mjs';
import { startSpotify, finishSpotify, spotifyStatus, searchSpotify, playlistTool } from '../server/spotify.mjs';
test('PKCE state is single use and token persists privately; exact artist ranks first', async () => {
const dir = mkdtempSync(join(tmpdir(), 'spotify-test-'));
@@ -16,6 +16,12 @@ test('PKCE state is single use and token persists privately; exact artist ranks
verifier = options.body.get('code_verifier');
return Response.json({ access_token: 'access', refresh_token: 'refresh', expires_in: 3600 });
}
if (String(url).includes('/me/playlists')) {
return Response.json({ error: { message: 'Insufficient client scope' } }, { status: 403 });
}
if (String(url).includes('/items')) {
return Response.json({ error: { message: 'Forbidden' } }, { status: 403 });
}
return Response.json({ tracks: { items: [{ uri: 'spotify:track:1', name: 'Song', type: 'track' }] }, artists: { items: [{ uri: 'spotify:artist:1', name: 'Aphex Twin', type: 'artist' }] } });
};
try {
@@ -28,5 +34,7 @@ test('PKCE state is single use and token persists privately; exact artist ranks
assert.equal(JSON.parse(readFileSync(join(dir, 'spotify.json'))).refresh_token, 'refresh');
assert.deepEqual(spotifyStatus(), { configured: true, connected: true });
assert.equal((await searchSpotify('aphex twin'))[0].type, 'artist');
assert.equal((await playlistTool({ action: 'list' })).error, 'spotify_permission_required');
assert.equal((await playlistTool({ action: 'read', playlistUri: 'spotify:playlist:test' })).error, 'spotify_playlist_access_denied');
} finally { globalThis.fetch = original; rmSync(dir, { recursive: true, force: true }); }
});