diff --git a/.github/workflows/functions-build.yml b/.github/workflows/functions-build.yml index a6ab841..58fbfeb 100644 --- a/.github/workflows/functions-build.yml +++ b/.github/workflows/functions-build.yml @@ -12,8 +12,10 @@ jobs: build: runs-on: ubuntu-latest steps: - - run: echo ${{ github.event_name }} - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version-file: 'functions/.nvmrc' - name: Install dependencies run: yarn --cwd ./functions/ - name: Build diff --git a/.github/workflows/functions-deploy.yml b/.github/workflows/functions-deploy.yml index e7b6790..4778324 100644 --- a/.github/workflows/functions-deploy.yml +++ b/.github/workflows/functions-deploy.yml @@ -17,6 +17,9 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version-file: 'functions/.nvmrc' - name: Install dependencies run: yarn --cwd ./functions/ - name: Download Artifact diff --git a/.vscode/launch.json b/.vscode/launch.json index 26721ed..dd93b44 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -23,6 +23,16 @@ "skipFiles": ["/**", "**/node_modules/**"], "console": "integratedTerminal", "preLaunchTask": "npm: build - functions" + }, + { + "name": "Attach Firebase Functions Emulator", + "type": "node", + "request": "attach", + "sourceMaps": true, + "outFiles": ["${workspaceFolder}/functions/lib/**/*.js"], + "skipFiles": ["/**", "**/node_modules/**"], + "cwd": "${workspaceFolder}/functions", + "port": 9229 } ] } diff --git a/functions/package.json b/functions/package.json index ee18d86..86f2dbc 100644 --- a/functions/package.json +++ b/functions/package.json @@ -5,7 +5,8 @@ "build:watch": "tsc --watch", "serve": "yarn build && firebase emulators:start --only functions,firestore,pubsub -P dev", "dev": "yarn build && firebase emulators:start --only functions,firestore,pubsub --inspect-functions -P dev", - "shell": "yarn build && firebase functions:shell -P dev", + "shell-prep": "yarn build && firebase emulators:start --only firestore,pubsub -P dev", + "shell": "yarn build && firebase functions:shell -P dev --inspect-functions", "start": "yarn shell", "deploy": "firebase deploy --only functions -P prod", "logs": "firebase functions:log" @@ -17,10 +18,11 @@ "dependencies": { "firebase-admin": "^10.2.0", "firebase-functions": "^4.1.1", - "spotify-web-api-node": "^5.0.2" + "node-fetch": "^2.6.7" }, "devDependencies": { - "@types/spotify-web-api-node": "^5.0.7", + "@types/node-fetch": "^2.6.4", + "@types/spotify-api": "^0.0.22", "typescript": "^4.9.4" }, "private": true diff --git a/functions/src/authorize.ts b/functions/src/authorize.ts deleted file mode 100644 index 51cec09..0000000 --- a/functions/src/authorize.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { handleResponse } from './utils' -import SpotifyWebApi from 'spotify-web-api-node' - -export async function getRefreshToken(code: string, origin: string) { - const spotify = new SpotifyWebApi({ - clientId: process.env.SPOTIFY_CLIENT_ID, - clientSecret: process.env.SPOTIFY_CLIENT_SECRET - }) - spotify.setRedirectURI(origin + '/authorize') - const { refresh_token } = await handleResponse(() => spotify.authorizationCodeGrant(code)) - return refresh_token -} diff --git a/functions/src/spotify/error.ts b/functions/src/spotify/error.ts new file mode 100644 index 0000000..c60bc07 --- /dev/null +++ b/functions/src/spotify/error.ts @@ -0,0 +1,17 @@ +import type { Response } from 'node-fetch' + +export const handleRepsonse = async (method: () => Promise): Promise => { + const res = await method() + if (res.status < 300) { + if (res.headers.get('content-type')?.startsWith('application/json')) return await res.json() + else return true as T + } else { + let error + try { + error = (await res.json()).error + } catch { + error = { status: res.status, statusText: res.statusText } + } + throw error + } +} diff --git a/functions/src/spotify/index.ts b/functions/src/spotify/index.ts new file mode 100644 index 0000000..06a5f96 --- /dev/null +++ b/functions/src/spotify/index.ts @@ -0,0 +1,182 @@ +import { handleRepsonse } from './error' +import fetch, { BodyInit } from 'node-fetch' + +export class Spotify { + private credentials: Credentials + setRefreshToken(value: string) { + this.credentials.refreshToken = value + } + + constructor(credentials: Credentials) { + this.credentials = credentials + } + + private authRequest(params: Record): Promise { + const { clientId, clientSecret } = this.credentials + if (!clientId || !clientSecret) throw new Error('Missing credentials') + return handleRepsonse(() => + fetch('https://accounts.spotify.com/api/token', { + headers: { + Authorization: + 'Basic ' + Buffer.from(clientId + ':' + clientSecret).toString('base64'), + 'Content-Type': 'application/x-www-form-urlencoded' + }, + method: 'POST', + body: new URLSearchParams(params).toString() + }) + ) + } + + private async request( + endpoint: string, + method: 'GET', + params?: Record + ): Promise + private async request( + endpoint: string, + method: 'POST' | 'PUT' | 'DELETE', + params?: Record + ): Promise + private async request( + endpoint: string, + method: 'GET' | 'POST' | 'PUT' | 'DELETE', + params?: Record + ): Promise { + if (!this.credentials.accessToken) + throw new Error('Missing access token. Please authenticate first.') + let url = 'https://api.spotify.com/v1/' + endpoint + let body: BodyInit + if (method == 'GET' && params) { + for (const key in params) if (params[key]) params[key] = params[key]?.toString() + url += '?' + new URLSearchParams(params as Record).toString() + } else if (params) { + body = JSON.stringify(params) + } + return await handleRepsonse(() => + fetch(url, { + headers: { Authorization: 'Bearer ' + this.credentials.accessToken }, + method, + body + }) + ) + } + + private async requestAll(endpoint: string): Promise { + const limit = 50 + const { items, total } = await this.request>(endpoint, 'GET', { + limit + }) + const reqN = [...Array(Math.ceil(total / limit)).keys()] // number of request that need to be run + reqN.shift() // first request has already been run + const responses = await Promise.all( + reqN.map(i => + this.request>(endpoint, 'GET', { + limit, + offset: limit * i + }) + ) + ) + items.push(...responses.flatMap(res => res.items)) + return items + } + + async authorizationCodeGrant(code: string) { + if (!this.credentials.redirectUri) + throw new Error('Missing redirect uri. Please authenticate first.') + const token = await this.authRequest({ + code: code, + redirect_uri: this.credentials.redirectUri, + grant_type: 'authorization_code' + }) + this.credentials.refreshToken = token.refresh_token + this.credentials.accessToken = token.access_token + return token + } + + async refreshAccessToken() { + if (!this.credentials.refreshToken) + throw new Error('Missing refresh token. Please authenticate first.') + const token = await this.authRequest({ + refresh_token: this.credentials.refreshToken, + grant_type: 'refresh_token' + }) + this.credentials.accessToken = token.access_token + } + + getMe() { + return this.request('me', 'GET') + } + + getMySavedTracks() { + return this.requestAll('me/tracks') + } + + getMyPlaylists() { + return this.requestAll('me/playlists') + } + + createPlaylist(userId: string, details: PlaylistDetails) { + return this.request( + `users/${userId}/playlists`, + 'POST', + details + ) + } + + changePlaylistDetails(playlistId: string, details: Partial) { + return this.request('playlists/' + playlistId, 'PUT', details) + } + + getPlaylistTracks(playlistId: string) { + return this.requestAll(`playlists/${playlistId}/tracks`) + } + + addTracksToPlaylist(playlistId: string, uris: string[]) { + return this.request( + `playlists/${playlistId}/tracks`, + 'POST', + { uris } + ) + } + + removeTracksToPlaylist(playlistId: string, uris: string[]) { + return this.request( + `playlists/${playlistId}/tracks`, + 'DELETE', + { uris } + ) + } + + usersFollowPlaylist(playlistId: string, userIds: string[]) { + return this.request(`playlists/${playlistId}/followers/contains`, 'GET', { + ids: userIds.join() + }) + } +} + +type Primative = string | number | boolean | undefined + +interface Credentials { + accessToken?: string | undefined + clientId?: string | undefined + clientSecret?: string | undefined + redirectUri?: string | undefined + refreshToken?: string | undefined +} + +interface AccessToken { + access_token: string + expires_in: number + scope: string + token_type: string +} + +interface RefreshToken extends AccessToken { + refresh_token: string +} + +type PlaylistDetails = { + name: string + description?: string + public?: boolean +} diff --git a/functions/src/tools/public-liked-songs.ts b/functions/src/tools/public-liked-songs.ts index 63da075..ed60a3c 100644 --- a/functions/src/tools/public-liked-songs.ts +++ b/functions/src/tools/public-liked-songs.ts @@ -1,8 +1,7 @@ import * as functions from 'firebase-functions' -import { getRefreshToken } from '../authorize' import { db } from '../firestore' -import { handleResponse, forEvery } from '../utils' -import SpotifyWebApi from 'spotify-web-api-node' +import { Spotify } from '../spotify' +import { forEvery } from '../utils' import { secrets } from '../env' interface Document { @@ -15,24 +14,67 @@ export const createPublicLikedSongs = functions .https.onCall(async (data: Data, context) => { if (!context.auth) throw new functions.https.HttpsError('unauthenticated', 'User must be authenticated.') - const refresh_token = await getRefreshToken(data.code, data.origin) + + const spotify = new Spotify({ + clientId: process.env.SPOTIFY_CLIENT_ID, + clientSecret: process.env.SPOTIFY_CLIENT_SECRET, + redirectUri: data.origin + '/authorize' + }) + const ref = db.collection('public-liked-songs').doc(context.auth.uid) let doc = await ref.get() - if (doc.exists) await ref.update({ refresh_token }) - else await ref.create({ refresh_token }) - doc = await ref.get() - const docData = doc.data() as Document + let docData: Document + if (doc.exists) { + docData = doc.data() as Document + spotify.setRefreshToken(docData.refresh_token) + await spotify.refreshAccessToken() + } else { + const { refresh_token } = await spotify.authorizationCodeGrant(data.code) + await ref.create({ refresh_token }) + doc = await ref.get() // Do I need this line? + docData = doc.data() as Document + } + + const user = await spotify.getMe() + + // Don't have playlist id if (!docData.playlist_id) { - docData.playlist_id = await create(docData.refresh_token) + // check to see if playlist already exists + const playlists = await spotify.getMyPlaylists() + const playlistName = name(user.display_name) + for (const playlist of playlists) { + if (playlist.name == playlistName) docData.playlist_id = playlist.id + } + // if playlist doesn't exist + if (!docData.playlist_id) { + const playlist = await spotify.createPlaylist(user.id, { + name: playlistName, + description: description(), + public: true + }) + docData.playlist_id = playlist.id + } await ref.update({ playlist_id: docData.playlist_id }) } + + if (!(await spotify.usersFollowPlaylist(docData.playlist_id, [user.id]))[0]) { + await ref.delete() + throw new functions.https.HttpsError( + 'not-found', + 'You may have deleted the synced playlist. Refresh to restore it.' + ) + } + try { - return await update(docData.refresh_token, docData.playlist_id) + return await update(spotify, docData.playlist_id) } catch (error) { - if (typeof error == 'object' && error && 'statusCode' in error) - if ((error as { statusCode: unknown }).statusCode == 404) - return await ref.update({ playlist_id: undefined }) + let msg = 'Spotify Error' + functions.logger.warn(error) + if (typeof error == 'object' && error && 'statusCode' in error) { + if ('message' in error && typeof error.message == 'string') msg = error.message + throw new functions.https.HttpsError('unknown', msg, error) + } throw error } }) @@ -50,63 +92,43 @@ export const syncPublicLikedSongs = functions docRefs.map(async ref => { const doc = await ref.get() const data = doc.data() as Document - if (data.playlist_id) return await update(data.refresh_token, data.playlist_id) - else return + const spotify = new Spotify({ + clientId: process.env.SPOTIFY_CLIENT_ID, + clientSecret: process.env.SPOTIFY_CLIENT_SECRET, + refreshToken: data.refresh_token + }) + await spotify.refreshAccessToken() + if (data.playlist_id) { + const user = await spotify.getMe() + if (!(await spotify.usersFollowPlaylist(data.playlist_id, [user.id]))[0]) + return await ref.delete() + else return await update(spotify, data.playlist_id) + } else return await ref.delete() }) ) }) -async function create(refresh_token: string) { - const spotify = await authorize(refresh_token) - const [playlists, user] = await Promise.all([ - getAllPlaylist(spotify), - handleResponse(() => spotify.getMe()) - ]) - const playlistName = name(user.display_name) - for (const playlist of playlists) { - if (playlist.name == playlistName) return playlist.id - } - // if playlist doesn't exist - const playlist = await handleResponse(() => - spotify.createPlaylist(playlistName, { - description: description(), - public: true - }) - ) - return playlist.id -} - -async function update(refresh_token: string, playlist_id: string) { - const spotify = await authorize(refresh_token) - +async function update(spotify: Spotify, playlistId: string) { const [playlistTracks, savedTracks] = await Promise.all([ - getPlaylistTracks(spotify, playlist_id), - getSavedTracks(spotify) + spotify.getPlaylistTracks(playlistId), + spotify.getMySavedTracks() ]) + const playlistTrackIds = playlistTracks + .map(item => item.track?.id) + .filter((v): v is string => v !== undefined) + const savedTrackIds = savedTracks.map(item => item.track.id) - const removedTracks = playlistTracks.filter( - track => !savedTracks.some(savedTrack => savedTrack.id === track.id) - ) - const addedTracks = savedTracks - .filter(track => !playlistTracks.some(playlistTrack => playlistTrack.id === track.id)) - .reverse() + const removedTrackIds = playlistTrackIds.filter(id => !savedTrackIds.includes(id)) + const addedTrackIds = savedTrackIds.filter(id => !playlistTrackIds.includes(id)).reverse() - if (removedTracks.length > 0) - await forEvery(removedTracks, 100, tracks => - spotify.removeTracksFromPlaylist(playlist_id, tracks) - ) - if (addedTracks.length > 0) - await forEvery(addedTracks, 100, tracks => - spotify.addTracksToPlaylist( - playlist_id, - tracks.map(track => track.uri) - ) - ) + if (removedTrackIds.length > 0) + await forEvery(removedTrackIds, 100, ids => spotify.removeTracksToPlaylist(playlistId, ids)) + if (addedTrackIds.length > 0) + await forEvery(addedTrackIds, 100, ids => spotify.addTracksToPlaylist(playlistId, ids)) + + await spotify.changePlaylistDetails(playlistId, { description: description() }) - await handleResponse(() => - spotify.changePlaylistDetails(playlist_id, { description: description() }) - ) - return playlist_id + return playlistId } function name(userName = 'User') { @@ -116,59 +138,3 @@ function description() { const date = new Date().toLocaleDateString('en-US') return `Created at "https://spotify-tools/benkeys.com".\nLast updated on ${date}.` } - -/** This function must be run somewhere with access to `SPOTIFY_CLIENT_ID` & `SPOTIFY_CLIENT_SECRET`. */ -async function authorize(refresh_token: string) { - const spotify = new SpotifyWebApi({ - clientId: process.env.SPOTIFY_CLIENT_ID, - clientSecret: process.env.SPOTIFY_CLIENT_SECRET - }) - spotify.setRefreshToken(refresh_token) - const { access_token } = await handleResponse(() => spotify.refreshAccessToken()) - spotify.setAccessToken(access_token) - return spotify -} - -async function getPlaylistTracks(spotify: SpotifyWebApi, playlistId: string) { - const limit = 50 - const { items, total } = await handleResponse(() => - spotify.getPlaylistTracks(playlistId, { limit }) - ) - const reqN = [...Array(Math.ceil(total / limit)).keys()] // number of request that need to be run - reqN.shift() // first request has already been run - const responses = await Promise.all( - reqN.map(i => - handleResponse(() => - spotify.getPlaylistTracks(playlistId, { limit, offset: limit * i }) - ) - ) - ) - items.push(...responses.flatMap(res => res.items)) - return items - .map(item => item.track) - .filter((item): item is SpotifyApi.TrackObjectFull => Boolean(item)) -} - -async function getSavedTracks(spotify: SpotifyWebApi) { - const limit = 50 - const { items, total } = await handleResponse(() => spotify.getMySavedTracks({ limit })) - const reqN = [...Array(Math.ceil(total / limit)).keys()] // number of request that need to be run - reqN.shift() // first request has already been run - const responses = await Promise.all( - reqN.map(i => handleResponse(() => spotify.getMySavedTracks({ limit, offset: limit * i }))) - ) - items.push(...responses.flatMap(res => res.items)) - return items.map(item => item.track) -} - -async function getAllPlaylist(spotify: SpotifyWebApi) { - const limit = 50 - const { items, total } = await handleResponse(() => spotify.getUserPlaylists({ limit })) - const reqN = [...Array(Math.ceil(total / limit)).keys()] // number of request that need to be run - reqN.shift() // first request has already been run - const responses = await Promise.all( - reqN.map(i => handleResponse(() => spotify.getUserPlaylists({ limit, offset: limit * i }))) - ) - items.push(...responses.flatMap(res => res.items)) - return items -} diff --git a/functions/src/utils.ts b/functions/src/utils.ts index 1c97a5a..b6bac0b 100644 --- a/functions/src/utils.ts +++ b/functions/src/utils.ts @@ -1,23 +1,3 @@ -export function handleResponse(call: () => Promise>): Promise { - return call() - .catch(error => { - throw `Spotify Error: ${error}` - }) - .then(res => { - if (res.statusCode < 300) { - return res.body - } else { - // handle error - throw `Spotify Error: ${res.statusCode}` - } - }) -} -interface Response { - body: T - headers: Record - statusCode: number -} - export async function forEvery( items: T[], limit: number, diff --git a/functions/tsconfig.json b/functions/tsconfig.json index 8c09f91..9a819d8 100644 --- a/functions/tsconfig.json +++ b/functions/tsconfig.json @@ -7,7 +7,9 @@ "sourceMap": true, "strict": true, "target": "es2017", - "esModuleInterop": true + "esModuleInterop": true, + "types": ["spotify-api"], + "lib": ["esnext"] }, "compileOnSave": true, "include": ["src"] diff --git a/functions/yarn.lock b/functions/yarn.lock index 9fefb1e..491bedb 100644 --- a/functions/yarn.lock +++ b/functions/yarn.lock @@ -288,6 +288,14 @@ resolved "https://registry.npmjs.org/@types/mime/-/mime-3.0.1.tgz" integrity sha512-Y4XFY5VJAuw0FgAqPNd6NNoV44jbq9Bz2L7Rh/J6jLTiHBSBJa9fxqQIvkIld4GsoDOcCbvzOUAbLPsSKKg+uA== +"@types/node-fetch@^2.6.4": + version "2.6.4" + resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.4.tgz#1bc3a26de814f6bf466b25aeb1473fa1afe6a660" + integrity sha512-1ZX9fcN4Rvkvgv4E6PAY5WXUFWFcRWxZa3EW83UjycOB9ljJCedb2CupIP4RZMEwF/M3eTcCihbBRgwtGbg5Rg== + dependencies: + "@types/node" "*" + form-data "^3.0.0" + "@types/node@*": version "18.11.15" resolved "https://registry.npmjs.org/@types/node/-/node-18.11.15.tgz" @@ -316,17 +324,10 @@ "@types/mime" "*" "@types/node" "*" -"@types/spotify-api@*": - version "0.0.16" - resolved "https://registry.npmjs.org/@types/spotify-api/-/spotify-api-0.0.16.tgz" - integrity sha512-5tzZ8cJe2Wv5zjhBLXi7gJObp/kzuG10FwMKPqNtmG9HFWlXKvqBhd0Z+T5Oa0yhkSX1u8Y+bBUhUEPmZpGpDQ== - -"@types/spotify-web-api-node@^5.0.7": - version "5.0.7" - resolved "https://registry.npmjs.org/@types/spotify-web-api-node/-/spotify-web-api-node-5.0.7.tgz" - integrity sha512-8ajd4xS3+l4Zau1OyggPv7DjeSFEIGYvG5Q8PbbBMKiaRFD53IkcvU4Bx4Ijyzw+l+Kc09L5L+MXRj0wyVLx9Q== - dependencies: - "@types/spotify-api" "*" +"@types/spotify-api@^0.0.22": + version "0.0.22" + resolved "https://registry.yarnpkg.com/@types/spotify-api/-/spotify-api-0.0.22.tgz#762707c4ad323e9d1c4e75e85742ad50e1666942" + integrity sha512-oZLKiI6Fck/BGd0QdmIdJCY351xQfRkfpx2Q23jIntL3YQx85vScYuFJMK81/NoSK9m7HYOl2DW4A9WomDuJOA== abort-controller@^3.0.0: version "3.0.0" @@ -458,11 +459,6 @@ combined-stream@^1.0.8: dependencies: delayed-stream "~1.0.0" -component-emitter@^1.3.0: - version "1.3.0" - resolved "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz" - integrity sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg== - compressible@^2.0.12: version "2.0.18" resolved "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz" @@ -504,11 +500,6 @@ cookie@0.5.0: resolved "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz" integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw== -cookiejar@^2.1.2: - version "2.1.3" - resolved "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.3.tgz" - integrity sha512-JxbCBUdrfr6AQjOXrxoTvAMJO4HBTUIlBzslcJPAz+/KT8yk53fXun51u+RenNYvad/+Vc2DIz5o9UxlCDymFQ== - cors@^2.8.5: version "2.8.5" resolved "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz" @@ -669,11 +660,6 @@ fast-deep-equal@^3.1.1: resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz" integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== -fast-safe-stringify@^2.0.7: - version "2.1.1" - resolved "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz" - integrity sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA== - fast-text-encoding@^1.0.0, fast-text-encoding@^1.0.3: version "1.0.4" resolved "https://registry.npmjs.org/fast-text-encoding/-/fast-text-encoding-1.0.4.tgz" @@ -736,11 +722,6 @@ form-data@^3.0.0: combined-stream "^1.0.8" mime-types "^2.1.12" -formidable@^1.2.2: - version "1.2.6" - resolved "https://registry.npmjs.org/formidable/-/formidable-1.2.6.tgz" - integrity sha512-KcpbcpuLNOwrEjnbpMC0gS+X8ciDoZE1kkqzat4a8vrprf+s9pKNQ/QIwWfbfs4ltgmFl3MD177SNTkve3BwGQ== - forwarded@0.2.0: version "0.2.0" resolved "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz" @@ -1117,7 +1098,7 @@ merge-descriptors@1.0.1: resolved "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz" integrity sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w== -methods@^1.1.2, methods@~1.1.2: +methods@~1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz" integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== @@ -1139,11 +1120,6 @@ mime@1.6.0: resolved "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz" integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== -mime@^2.4.6: - version "2.6.0" - resolved "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz" - integrity sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg== - mime@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz" @@ -1283,7 +1259,7 @@ pumpify@^2.0.0: inherits "^2.0.3" pump "^3.0.0" -qs@6.11.0, qs@^6.9.4: +qs@6.11.0: version "6.11.0" resolved "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz" integrity sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q== @@ -1305,7 +1281,7 @@ raw-body@2.5.1: iconv-lite "0.4.24" unpipe "1.0.0" -readable-stream@^3.1.1, readable-stream@^3.6.0: +readable-stream@^3.1.1: version "3.6.0" resolved "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz" integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA== @@ -1352,13 +1328,6 @@ semver@^6.0.0: resolved "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz" integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== -semver@^7.3.2: - version "7.3.7" - resolved "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz" - integrity sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g== - dependencies: - lru-cache "^6.0.0" - send@0.18.0: version "0.18.0" resolved "https://registry.npmjs.org/send/-/send-0.18.0.tgz" @@ -1407,13 +1376,6 @@ signal-exit@^3.0.2: resolved "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz" integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== -spotify-web-api-node@^5.0.2: - version "5.0.2" - resolved "https://registry.npmjs.org/spotify-web-api-node/-/spotify-web-api-node-5.0.2.tgz" - integrity sha512-r82dRWU9PMimHvHEzL0DwEJrzFk+SMCVfq249SLt3I7EFez7R+jeoKQd+M1//QcnjqlXPs2am4DFsGk8/GCsrA== - dependencies: - superagent "^6.1.0" - statuses@2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz" @@ -1459,23 +1421,6 @@ stubs@^3.0.0: resolved "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz" integrity sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw== -superagent@^6.1.0: - version "6.1.0" - resolved "https://registry.npmjs.org/superagent/-/superagent-6.1.0.tgz" - integrity sha512-OUDHEssirmplo3F+1HWKUrUjvnQuA+nZI6i/JJBdXb5eq9IyEQwPyPpqND+SSsxf6TygpBEkUjISVRN4/VOpeg== - dependencies: - component-emitter "^1.3.0" - cookiejar "^2.1.2" - debug "^4.1.1" - fast-safe-stringify "^2.0.7" - form-data "^3.0.0" - formidable "^1.2.2" - methods "^1.1.2" - mime "^2.4.6" - qs "^6.9.4" - readable-stream "^3.6.0" - semver "^7.3.2" - teeny-request@^7.1.3: version "7.2.0" resolved "https://registry.npmjs.org/teeny-request/-/teeny-request-7.2.0.tgz" diff --git a/src/lib/components/ErrorMsg.svelte b/src/lib/components/ErrorMsg.svelte new file mode 100644 index 0000000..e880d94 --- /dev/null +++ b/src/lib/components/ErrorMsg.svelte @@ -0,0 +1,27 @@ + + +
+

Oh No! Something went wrong

+ {#if typeof error === 'object' && error !== null && hasKeys(error, 'code')} + {#if typeof error.code == 'string' && error.code.includes('internal')} +

Internal Error

+ {:else} + {#if hasKeys(error, 'message')} +

{error.message}

+ {/if} +
Code: {error.code}
+ {/if} + {:else} +

Uknown Error

+ {@debug error} + {/if} +
diff --git a/src/routes/(tools)/public-liked-songs/+page.svelte b/src/routes/(tools)/public-liked-songs/+page.svelte index 99644a7..18a9b70 100644 --- a/src/routes/(tools)/public-liked-songs/+page.svelte +++ b/src/routes/(tools)/public-liked-songs/+page.svelte @@ -6,6 +6,7 @@ import LoginButton from '$lib/components/LoginButton.svelte' import Spinner from '$lib/components/spinner.svelte' import SpotifyEmbed from '$lib/components/spotify-embed.svelte' + import ErrorMsg from '$lib/components/ErrorMsg.svelte' let backendResponse: ReturnType | undefined @@ -33,8 +34,7 @@ /> {/if} {:catch error} -

Oh No! Something went wrong

-

{error}

+ {/await} {:else if $user}