diff --git a/constants.js b/constants.js index 98bbaf4..8ef94ff 100644 --- a/constants.js +++ b/constants.js @@ -13,9 +13,9 @@ const scopes = { ViewRecentlyPlayed: 'user-read-recently-played', ViewPlaybackPosition: 'user-read-playback-position', ViewTop: 'user-top-read', + ViewPrivatePlaylists: 'playlist-read-private', IncludeCollaborative: 'playlist-read-collaborative', ModifyPublicPlaylists: 'playlist-modify-public', - ViewPrivatePlaylists: 'playlist-read-private', ModifyPrivatePlaylists: 'playlist-modify-private', ControlRemotePlayback: 'app-remote-control', ModifyLibrary: 'user-library-modify', diff --git a/controllers/auth.js b/controllers/auth.js index 97285ec..3b2ffb7 100644 --- a/controllers/auth.js +++ b/controllers/auth.js @@ -48,8 +48,8 @@ const callback = async (req, res) => { logger.error('state mismatch'); return res.redirect(409, '/'); } else if (error) { - logger.error('callback error', { authError: error }); - return res.status(401).send({ message: `Auth callback error` }); + logger.error('callback error', { error }); + return res.status(401).send("Auth callback error"); } else { // get auth tokens res.clearCookie(stateKey); @@ -86,12 +86,10 @@ const callback = async (req, res) => { /** @type {typedefs.User} */ req.session.user = { username: userResponse.data.display_name, - id: userResponse.data.id, + uri: userResponse.data.uri, }; - return res.status(200).send({ - message: "Login successful", - }); + return res.sendStatus(200); } } catch (error) { logger.error('callback', { error }); @@ -121,9 +119,7 @@ const refresh = async (req, res) => { req.session.cookie.maxAge = 7 * 24 * 60 * 60 * 1000 // 1 week logger.info(`Access token refreshed${(response.data.refresh_token !== null) ? ' and refresh token updated' : ''}.`); - return res.status(200).send({ - message: "New access token obtained", - }); + return res.sendStatus(200); } else { logger.error('refresh failed', { statusCode: response.status }); return res.status(response.status).send('Error: Refresh token flow failed.'); diff --git a/controllers/playlists.js b/controllers/playlists.js index b210c63..34b3ec6 100644 --- a/controllers/playlists.js +++ b/controllers/playlists.js @@ -2,6 +2,7 @@ const logger = require("../utils/logger")(module); const typedefs = require("../typedefs"); const { axiosInstance } = require('../utils/axios'); +const { parseSpotifyUri, parseSpotifyLink } = require("../utils/spotifyUriTransformer"); /** * Retrieve list of all of user's playlists @@ -10,11 +11,11 @@ const { axiosInstance } = require('../utils/axios'); */ const getUserPlaylists = async (req, res) => { try { - let playlists = {}; + let userPlaylists = {}; // get first 50 const response = await axiosInstance.get( - `/users/${req.session.user.id}/playlists`, + `/users/${parseSpotifyUri(req.session.user.uri).id}/playlists`, { params: { offset: 0, @@ -26,49 +27,53 @@ const getUserPlaylists = async (req, res) => { } ); - if (response.status === 401) { - return res.status(401).send(response.data); - } + if (response.status >= 400 && response.status < 500) + return res.status(response.status).send(response.data); + + userPlaylists.total = response.data.total; /** @type {typedefs.SimplifiedPlaylist[]} */ - playlists.items = response.data.items.map((playlist) => { + userPlaylists.items = response.data.items.map((playlist) => { return { + uri: playlist.uri, + images: playlist.images, name: playlist.name, - id: playlist.id + total: playlist.tracks.total } }); - playlists.total = response.data.total; - playlists.next = response.data.next; + userPlaylists.next = response.data.next; // keep getting batches of 50 till exhausted - while (playlists.next) { + while (userPlaylists.next) { const nextResponse = await axiosInstance.get( - playlists.next, // absolute URL from previous response which has offset and limit params + userPlaylists.next, // absolute URL from previous response which has params { headers: { ...req.authHeader } } ); - if (response.status === 401) - return res.status(401).send(response.data); + if (response.status >= 400 && response.status < 500) + return res.status(response.status).send(response.data); - playlists.items.push( + userPlaylists.items.push( ...nextResponse.data.items.map((playlist) => { return { + uri: playlist.uri, + images: playlist.images, name: playlist.name, - id: playlist.id + total: playlist.tracks.total } }) ); - playlists.next = nextResponse.data.next; + userPlaylists.next = nextResponse.data.next; } - delete playlists.next; + delete userPlaylists.next; - return res.status(200).send(playlists); + return res.status(200).send(userPlaylists); } catch (error) { logger.error('getUserPlaylists', { error }); return res.sendStatus(500); @@ -76,7 +81,7 @@ const getUserPlaylists = async (req, res) => { } /** - * Retrieve single playlist + * Retrieve an entire playlist * @param {typedefs.Req} req * @param {typedefs.Res} res */ @@ -84,36 +89,57 @@ const getPlaylistDetails = async (req, res) => { try { /** @type {typedefs.Playlist} */ let playlist = {}; + /** @type {typedefs.UriObject} */ + let uri; + let initialFields = ["collaborative", "description", "images", "name", "owner(uri,display_name)", "public", + "snapshot_id", "tracks(next,total,items(is_local,track(name,uri)))"]; + let mainFields = ["next,items(is_local,track(name,uri))"]; + + try { + uri = parseSpotifyLink(req.query.playlist_link) + if (uri.type !== "playlist") { + return res.status(400).send("Not a playlist link"); + } + } catch (error) { + logger.error("parseSpotifyLink", { error }); + return res.sendStatus(400); + } const response = await axiosInstance.get( - "/playlists/" + req.query.playlist_id, + `/playlists/${uri.id}/`, { + params: { + fields: initialFields.join() + }, headers: { ...req.authHeader } } ); - if (response.status === 401) - return res.status(401).send(response.data); + if (response.status >= 400 && response.status < 500) + return res.status(response.status).send(response.data); // TODO: this whole section needs to be DRYer // look into serializr - playlist.uri = response.data.uri - playlist.name = response.data.name - playlist.description = response.data.description - let { display_name, uri, id, ...rest } = response.data.owner - playlist.owner = { display_name, uri, id } - playlist.followers = response.data.followers + playlist.name = response.data.name; + playlist.description = response.data.description; + playlist.collaborative = response.data.collaborative; + playlist.public = response.data.public; + playlist.images = { ...response.data.images }; + playlist.owner = { ...response.data.owner }; + playlist.snapshot_id = response.data.snapshot_id; playlist.total = response.data.tracks.total; - playlist.next = response.data.tracks.next; - playlist.tracks = response.data.tracks.items.map((playlist_track) => { + // previous fields get carried over to the next URL, but most of these fields are not present in the new endpoint + // API shouldn't be returning such URLs, the problem's in the API ig... + playlist.next = new URL(response.data.tracks.next); + playlist.next.searchParams.set("fields", mainFields.join()); + playlist.next = playlist.next.href; + playlist.tracks = response.data.tracks.items.map((playlist_item) => { return { - added_at: playlist_track.added_at, + is_local: playlist_item.is_local, track: { - uri: playlist_track.track.uri, - name: playlist_track.track.name, - artists: playlist_track.track.artists.map((artist) => { return { name: artist.name } }), - album: { name: playlist_track.track.album.name }, - is_local: playlist_track.track.is_local, + name: playlist_item.track.name, + type: playlist_item.track.type, + uri: playlist_item.track.uri } } }); @@ -122,26 +148,25 @@ const getPlaylistDetails = async (req, res) => { // keep getting batches of 50 till exhausted while (playlist.next) { const nextResponse = await axiosInstance.get( - playlist.next, // absolute URL from previous response which has offset and limit params + playlist.next, // absolute URL from previous response which has params { headers: { ...req.authHeader } } ); - if (nextResponse.status === 401) - return res.status(401).send(nextResponse.data); + + if (nextResponse.status >= 400 && nextResponse.status < 500) + return res.status(nextResponse.status).send(nextResponse.data); playlist.tracks.push( - ...nextResponse.data.items.map((playlist_track) => { + ...nextResponse.data.items.map((playlist_item) => { return { - added_at: playlist_track.added_at, + is_local: playlist_item.is_local, track: { - uri: playlist_track.track.uri, - name: playlist_track.track.name, - artists: playlist_track.track.artists.map((artist) => { return { name: artist.name } }), - album: { name: playlist_track.track.album.name }, - is_local: playlist_track.track.is_local, + name: playlist_item.track.name, + type: playlist_item.track.type, + uri: playlist_item.track.uri } } }) @@ -150,6 +175,8 @@ const getPlaylistDetails = async (req, res) => { playlist.next = nextResponse.data.next; } + delete playlist.next; + return res.status(200).send(playlist); } catch (error) { logger.error('getPlaylistDetails', { error }); diff --git a/middleware/authCheck.js b/middleware/authCheck.js index cb4c612..dddc797 100644 --- a/middleware/authCheck.js +++ b/middleware/authCheck.js @@ -10,7 +10,7 @@ const logger = require("../utils/logger")(module); const isAuthenticated = (req, res, next) => { if (req.session.accessToken) { req.authHeader = { 'Authorization': `Bearer ${req.session.accessToken}` }; - next() + next(); } else { const delSession = req.session.destroy((err) => { if (err) { diff --git a/routes/playlists.js b/routes/playlists.js index f0255e0..f7c7850 100644 --- a/routes/playlists.js +++ b/routes/playlists.js @@ -6,7 +6,7 @@ const { getPlaylistDetailsValidator } = require('../validators/playlists'); const validator = require("../validators"); router.get( - "/user", + "/me", isAuthenticated, validator.validate, getUserPlaylists diff --git a/typedefs.js b/typedefs.js index 35867b2..0b68895 100644 --- a/typedefs.js +++ b/typedefs.js @@ -8,13 +8,23 @@ * @typedef {import('winston').Logger} Logger * * @typedef {{ + * type: string, + * is_local: boolean, + * id: string, + * artist?: string, + * album?: string, + * title?: string, + * duration?: number + * }} UriObject + * + * @typedef {{ * display_name: string, - * id: string + * uri: string * }} User * * @typedef {{ * name: string, - * id: string, + * uri: string, * }} SimplifiedPlaylist * * @typedef {{ @@ -39,13 +49,19 @@ * }} PlaylistTrack * * @typedef {{ + * url: string, + * height: number, + * width: number + * }} ImageObject + * + * @typedef {{ * uri: string, * name: string, * description: string, + * collaborative: boolean, + * public: boolean, * owner: User, - * followers: { - * total: number - * }, + * images: ImageObject[], * tracks: PlaylistTrack[], * }} Playlist */ diff --git a/utils/axios.js b/utils/axios.js index 5132786..727ab92 100644 --- a/utils/axios.js +++ b/utils/axios.js @@ -32,7 +32,12 @@ axiosInstance.interceptors.request.use(request => { axiosInstance.interceptors.response.use( (response) => response, (error) => { - if (error.response) { + if (error.response && error.response.status === 429) { + // Rate limiting + logger.warn("Spotify API: Too many requests"); + return error.response; + } + else if (error.response) { // Server has responded logger.error( "Spotify API: Error", { diff --git a/utils/jsonTransformer.js b/utils/jsonTransformer.js index b7389ba..16e3110 100644 --- a/utils/jsonTransformer.js +++ b/utils/jsonTransformer.js @@ -2,7 +2,7 @@ * Returns a single string of the values of all keys in the given JSON object, even nested ones. * * @param {*} obj - * @returns + * @returns {string} */ const getNestedValuesString = (obj) => { let values = []; diff --git a/utils/logger.js b/utils/logger.js index daca4bf..6fa0c64 100644 --- a/utils/logger.js +++ b/utils/logger.js @@ -10,12 +10,14 @@ const getLabel = (callingModule) => { return path.join(parts[parts.length - 2], parts.pop()); }; +const allowedErrorKeys = ["name", "message", "stack"]; + const logMetaReplacer = (key, value) => { if (key === "error") { return { name: value.name, message: value.message, - stack: value.stack + stack: value.stack, }; } return value; @@ -28,11 +30,10 @@ const metaFormat = (meta) => { } const logFormat = printf(({ level, message, label, timestamp, ...meta }) => { - if (meta.error) { + if (meta.error) { // if the error was passed for (const key in meta.error) { - const allowedErrorKeys = ["name", "message", "stack"] - if (typeof key !== "symbol" && !allowedErrorKeys.includes(key)) { - delete meta.error[key] + if (!allowedErrorKeys.includes(key)) { + delete meta.error[key]; } } } diff --git a/utils/spotifyUriTransformer.js b/utils/spotifyUriTransformer.js new file mode 100644 index 0000000..670e0d2 --- /dev/null +++ b/utils/spotifyUriTransformer.js @@ -0,0 +1,108 @@ +const typedefs = require("../typedefs"); + +/** @type {RegExp} */ +const base62Pattern = /^[A-Za-z0-9]+$/; + +/** + * Returns type and ID from a Spotify URI + * @see {@link https://developer.spotify.com/documentation/web-api/concepts/spotify-uris-ids|Spotify URIs and IDs} + * @param {string} uri Spotify URI - can be of an album, track, playlist, user, episode, etc. + * @returns {typedefs.UriObject} + * @throws {TypeError} If the input is not a valid Spotify URI + */ +const parseSpotifyUri = (uri) => { + const parts = uri.split(":"); + + if (parts[0] !== "spotify") { + throw new TypeError(`${uri} is not a valid Spotify URI`); + } + + let type = parts[1]; + + if (type === "local") { + // Local file format: spotify:local::::<duration> + let idParts = parts.slice(2); + if (idParts.length < 4) { + throw new TypeError(`${uri} is not a valid local file URI`); + } + + // URL decode artist, album, and title + const artist = decodeURIComponent(idParts[0] || ''); + const album = decodeURIComponent(idParts[1] || ''); + const title = decodeURIComponent(idParts[2]); + const duration = parseInt(idParts[3], 10); + + if (isNaN(duration)) { + throw new TypeError(`${uri} has an invalid duration`); + } + + return { type: "track", is_local: true, artist, album, title, duration }; + } else { + // Not a local file + if (parts.length !== 3) { + throw new TypeError(`${uri} is not a valid Spotify URI`); + } + + const id = parts[2]; + + if (!base62Pattern.test(id)) { + throw new TypeError(`${uri} has an invalid ID`); + } + + return { type, is_local: false, id }; + } +} + +/** + * Returns type and ID from a Spotify link + * @see {@link https://developer.spotify.com/documentation/web-api/concepts/spotify-uris-ids|Spotify URIs and IDs} + * @param {string} link Spotify URL - can be of an album, track, playlist, user, episode, etc. + * @returns {typedefs.UriObject} + * @throws {TypeError} If the input is not a valid Spotify link + */ +const parseSpotifyLink = (link) => { + const localPattern = /^https:\/\/open\.spotify\.com\/local\/([^\/]*)\/([^\/]*)\/([^\/]+)\/(\d+)$/; + const standardPattern = /^https:\/\/open\.spotify\.com\/([^\/]+)\/([^\/?]+)/; + + if (localPattern.test(link)) { + // Local file format: https://open.spotify.com/local/artist/album/title/duration + const matches = link.match(localPattern); + if (!matches) { + throw new TypeError(`${link} is not a valid Spotify local file link`); + } + + // URL decode artist, album, and title + const artist = decodeURIComponent(matches[1] || ''); + const album = decodeURIComponent(matches[2] || ''); + const title = decodeURIComponent(matches[3]); + const duration = parseInt(matches[4], 10); + + if (isNaN(duration)) { + throw new TypeError(`${link} has an invalid duration`); + } + + return { type: "track", is_local: true, artist, album, title, duration }; + } else if (standardPattern.test(link)) { + // Not a local file + const matches = link.match(standardPattern); + if (!matches || matches.length < 3) { + throw new TypeError(`${link} is not a valid Spotify link`); + } + + const type = matches[1]; + const id = matches[2]; + + if (!base62Pattern.test(id)) { + throw new TypeError(`${link} has an invalid ID`); + } + + return { type, is_local: false, id }; + } else { + throw new TypeError(`${link} is not a valid Spotify link`); + } +} + +module.exports = { + parseSpotifyUri, + parseSpotifyLink +} diff --git a/validators/playlists.js b/validators/playlists.js index 8299d08..13b310e 100644 --- a/validators/playlists.js +++ b/validators/playlists.js @@ -8,11 +8,11 @@ const typedefs = require("../typedefs"); * @param {typedefs.Next} next */ const getPlaylistDetailsValidator = async (req, res, next) => { - await query("playlist_id") + await query("playlist_link") .notEmpty() - .withMessage("playlist_id not defined in query") - .isAlphanumeric() - .withMessage("playlist_id must be alphanumeric (base-62)") + .withMessage("playlist_link not defined in query") + .isURL() + .withMessage("playlist_link must be a valid link") .run(req); next(); }