diff --git a/.github/workflows/dev-build.yml b/.github/workflows/dev-build.yml index dcf7805..e7016d3 100644 --- a/.github/workflows/dev-build.yml +++ b/.github/workflows/dev-build.yml @@ -62,4 +62,4 @@ jobs: ghcr.io/radityaharya/${{ matrix.image_name }}:latest ${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:${{ github.sha }} ${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:latest - platforms: linux/amd64 \ No newline at end of file + platforms: linux/amd64 diff --git a/next.config.js b/next.config.js index 2b492e6..d4a954a 100644 --- a/next.config.js +++ b/next.config.js @@ -23,8 +23,8 @@ const config = { }, }; -let sentryConfig -let millionConfig +let sentryConfig; +let millionConfig; if (process.env.SENTRY) { sentryConfig = withSentryConfig( @@ -68,4 +68,4 @@ const prodConfig = { }, }; -export default process.env.NODE_ENV === "development" ? config : prodConfig; \ No newline at end of file +export default process.env.NODE_ENV === "development" ? config : prodConfig; diff --git a/src/components/main-nav.tsx b/src/components/main-nav.tsx index 83f59ab..0519537 100644 --- a/src/components/main-nav.tsx +++ b/src/components/main-nav.tsx @@ -44,7 +44,9 @@ export function MainNav() { onClick={() => resetReactFlow()} className={cn( "transition-colors hover:text-foreground/80", - /\/workflow(?!s)/.test(pathname) ? "text-foreground" : "text-foreground/60", + /\/workflow(?!s)/.test(pathname) + ? "text-foreground" + : "text-foreground/60", )} > Builder @@ -97,7 +99,7 @@ export function SiteNav({ className }: { className?: string }) { : "", pathname === "/" ? "absolute" : "", pathname.startsWith("/auth") - ? "bg-transparent absolute backdrop-blur-none" + ? "absolute bg-transparent backdrop-blur-none" : "", )} > diff --git a/src/lib/workflow/Base.ts b/src/lib/workflow/Base.ts index 8b69df8..192e3aa 100644 --- a/src/lib/workflow/Base.ts +++ b/src/lib/workflow/Base.ts @@ -130,4 +130,77 @@ export class Base { ); } } + + static isPlaylistTrackObject( + obj: any, + ): obj is SpotifyApi.PlaylistTrackObject { + return obj?.hasOwnProperty("track"); + } + + static isPlaylistTrackObjectArray( + obj: any, + ): obj is SpotifyApi.PlaylistTrackObject[] { + return ( + Array.isArray(obj) && + obj.every((item: any) => this.isPlaylistTrackObject(item)) + ); + } + + /** + * Retrieves the tracks from the given sources. + * + * @param sources - An array of sources from which to retrieve the tracks. + * @returns An array of tracks. + * @throws {Error} If the source type is invalid. + */ + static getTracks(sources: any[]) { + const tracks = [] as SpotifyApi.TrackObjectFull[]; + + for (const source of sources) { + let trackSource; + + if (source.hasOwnProperty("tracks")) { + trackSource = source.tracks; + } else if (source.hasOwnProperty("items")) { + trackSource = source.items; + } else if ( + source.hasOwnProperty("track") && + typeof source.track != "object" + ) { + trackSource = source.track ? [source.track] : []; + } else if (Array.isArray(source)) { + trackSource = source; + } + + if (!trackSource) continue; + + if (trackSource.hasOwnProperty("tracks")) { + for (const track of trackSource) { + if (track.track && track.track.type === "track") { + tracks.push(track.track as SpotifyApi.TrackObjectFull); + } else if (track.track && track.type === "track") { + throw new Error("Invalid source type"); + } + } + } else if ( + Array.isArray(trackSource) && + typeof trackSource[0] == "object" + ) { + for (const track of trackSource) { + if (track.track && track.track.type === "track") { + tracks.push(track.track as SpotifyApi.TrackObjectFull); + } else if (track.track && track.type === "track") { + tracks.push(track as SpotifyApi.TrackObjectFull); + } else { + throw new Error("Invalid source type"); + } + } + } else { + console.error("ERROR", trackSource); + throw new Error("Invalid source type"); + } + } + + return tracks; + } } diff --git a/src/lib/workflow/Combiner.ts b/src/lib/workflow/Combiner.ts index 480dfe6..87dd046 100644 --- a/src/lib/workflow/Combiner.ts +++ b/src/lib/workflow/Combiner.ts @@ -5,14 +5,11 @@ import { Base } from "./Base"; import _ from "radash"; import type { AccessToken } from "./Base"; import { Logger } from "../log"; +import type SpotifyWebApi from "spotify-web-api-node"; const log = new Logger("Combiner"); export default class Combiner extends Base { - constructor(accessToken: AccessToken) { - super(accessToken); - } - - static push(sources: any[], params: {}) { + static push(spClient: SpotifyWebApi, sources: any[], params: {}) { log.debug("Push Sources:", sources); log.info("Pushing..."); const result = [] as SpotifyApi.PlaylistTrackObject[]; @@ -34,7 +31,7 @@ export default class Combiner extends Base { return obj?.hasOwnProperty("track"); } - static alternate(sources: any[], params: {}) { + static alternate(spClient: SpotifyWebApi, sources: any[], params: {}) { log.debug("Alternate Sources:", sources); log.info("Alternating..."); const result = [] as SpotifyApi.PlaylistTrackObject[]; diff --git a/src/lib/workflow/Filter.ts b/src/lib/workflow/Filter.ts index 71865a6..eec6c98 100644 --- a/src/lib/workflow/Filter.ts +++ b/src/lib/workflow/Filter.ts @@ -4,57 +4,19 @@ import { Base } from "./Base"; import * as _ from "radash"; import type { AccessToken } from "./Base"; import { Logger } from "../log"; +import type SpotifyWebApi from "spotify-web-api-node"; const log = new Logger("Workflow"); export default class Filter extends Base { - constructor(accessToken: AccessToken) { - super(accessToken); - } - static isPlaylistTrackObject( - obj: any, - ): obj is SpotifyApi.PlaylistTrackObject { - return obj?.hasOwnProperty("track"); - } - - static isPlaylistTrackObjectArray( - obj: any, - ): obj is SpotifyApi.PlaylistTrackObject[] { - return ( - Array.isArray(obj) && - obj.every((item: any) => this.isPlaylistTrackObject(item)) - ); - } - static filter( + spClient: SpotifyWebApi, sources: any[], params: { filterKey: string; filterValue: string }, ) { log.info("Filtering..."); log.debug("Filter Sources:", sources); - let tracks = [] as any; - - if ( - Array.isArray(sources) && - Array.isArray(sources[0]) && - Filter.isPlaylistTrackObjectArray(sources[0]) - ) { - // If the first source is an array of PlaylistTrackObjects, assume all sources are - tracks = sources.flat(); - } else if (Array.isArray(sources) && sources[0]!.hasOwnProperty("tracks")) { - // If the first source has a 'tracks' property that is an array, assume all sources do - for (const source of sources) { - tracks.push(...source.tracks); - } - } else if (Filter.isPlaylistTrackObjectArray(sources)) { - tracks = sources; - } else { - throw new Error( - `Invalid source type: ${typeof sources[0]} in ${ - sources[0] - } located in sources: ${JSON.stringify(sources)}`, - ); - } + const tracks = this.getTracks(sources); if (Array.isArray(tracks)) { const res = tracks.filter((track: any) => { @@ -133,105 +95,40 @@ export default class Filter extends Base { } } - static dedupeTracks(sources: any[], params: {}) { + static dedupeTracks(spClient: SpotifyWebApi, sources: any[], params: {}) { log.info("Deduping tracks..."); log.debug("DedupeTracks Sources:", sources); - let tracks = [] as any; - - if ( - Array.isArray(sources) && - Array.isArray(sources[0]) && - Filter.isPlaylistTrackObjectArray(sources[0]) - ) { - // If the first source is an array of PlaylistTrackObjects, assume all sources are - tracks = sources.flat(); - } else if (Array.isArray(sources) && sources[0]!.hasOwnProperty("tracks")) { - // If the first source has a 'tracks' property that is an array, assume all sources do - for (const source of sources) { - tracks.push(...source.tracks); - } - } else if (Filter.isPlaylistTrackObjectArray(sources)) { - tracks = sources; - } else { - throw new Error( - `Invalid source type: ${typeof sources[0]} in ${ - sources[0] - } located in sources: ${JSON.stringify(sources)}`, - ); - } + const tracks = this.getTracks(sources); if (Array.isArray(tracks)) { - return [ - ...new Map(tracks.map((item) => [item.id, item])).values(), - ] as SpotifyApi.PlaylistTrackObject[]; + return [...new Map(tracks.map((item) => [item.id, item])).values()]; } return []; } - static dedupeArtists(sources: any[], params: {}) { + static dedupeArtists(spClient: SpotifyWebApi, sources: any[], params: {}) { log.info("Deduping artists..."); log.debug("DedupeArtists Sources:", sources); - let tracks = [] as any; - - if ( - Array.isArray(sources) && - Array.isArray(sources[0]) && - Filter.isPlaylistTrackObjectArray(sources[0]) - ) { - // If the first source is an array of PlaylistTrackObjects, assume all sources are - tracks = sources.flat(); - } else if (Array.isArray(sources) && sources[0]!.hasOwnProperty("tracks")) { - // If the first source has a 'tracks' property that is an array, assume all sources do - for (const source of sources) { - tracks.push(...source.tracks); - } - } else if (Filter.isPlaylistTrackObjectArray(sources)) { - tracks = sources; - } else { - throw new Error( - `Invalid source type: ${typeof sources[0]} in ${ - sources[0] - } located in sources: ${JSON.stringify(sources)}`, - ); - } + const tracks = this.getTracks(sources); if (_.isArray(tracks)) { return _.unique(tracks, (track): string | number | symbol => _.get(track, "track.artists[0].id"), - ) as SpotifyApi.PlaylistTrackObject[]; + ); } return []; } static match( + spClient: SpotifyWebApi, sources: any[], params: { matchKey: string; matchValue: string }, ) { log.info("Matching..."); log.debug("Match Sources:", sources); - let tracks = [] as any; - - if ( - Array.isArray(sources) && - Array.isArray(sources[0]) && - Filter.isPlaylistTrackObjectArray(sources[0]) - ) { - tracks = sources.flat(); - } else if (Array.isArray(sources) && sources[0]!.hasOwnProperty("tracks")) { - for (const source of sources) { - tracks.push(...source.tracks); - } - } else if (Filter.isPlaylistTrackObjectArray(sources)) { - tracks = sources; - } else { - throw new Error( - `Invalid source type: ${typeof sources[0]} in ${ - sources[0] - } located in sources: ${JSON.stringify(sources)}`, - ); - } + const tracks = this.getTracks(sources); if (Array.isArray(tracks)) { const res = tracks.filter((track: any) => { @@ -310,33 +207,15 @@ export default class Filter extends Base { } } - static limit(sources: any[], params: { limit?: number }) { + static limit( + spClient: SpotifyWebApi, + sources: any[], + params: { limit?: number }, + ) { log.info("Limiting..."); log.debug("Limit Sources:", sources); - let tracks = [] as any; - - if ( - Array.isArray(sources) && - Array.isArray(sources[0]) && - Filter.isPlaylistTrackObjectArray(sources[0]) - ) { - // If the first source is an array of PlaylistTrackObjects, assume all sources are - tracks = sources.flat(); - } else if (Array.isArray(sources) && sources[0]!.hasOwnProperty("tracks")) { - // If the first source has a 'tracks' property that is an array, assume all sources do - for (const source of sources) { - tracks.push(...source.tracks); - } - } else if (Filter.isPlaylistTrackObjectArray(sources)) { - tracks = sources; - } else { - throw new Error( - `Invalid source type: ${typeof sources[0]} in ${ - sources[0] - } located in sources: ${JSON.stringify(sources)}`, - ); - } + const tracks = this.getTracks(sources); if (Array.isArray(tracks)) { return tracks.slice(0, params.limit); diff --git a/src/lib/workflow/Library.ts b/src/lib/workflow/Library.ts index aa70b45..a4313fe 100644 --- a/src/lib/workflow/Library.ts +++ b/src/lib/workflow/Library.ts @@ -13,56 +13,19 @@ export default class Library extends Base { super(accessToken, spClient); } - static async likedTracks( - spClient: SpotifyWebApi, - { limit = 50, offset = 0 }: { limit?: number; offset?: number }, - ) { - const tracks: SpotifyApi.PlaylistTrackObject[] = []; - let result; - let retryAfter = 0; - - while (true) { - try { - await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000)); - result = await spClient.getMySavedTracks({ - limit: Math.min(limit - tracks.length, limit), - offset: offset + tracks.length, - }); - tracks.push(...result.body.items); - if (tracks.length >= limit || result.body.items.length < limit) { - break; - } - } catch (error: any) { - if (error.statusCode === 429) { - retryAfter = error.headers["retry-after"]; - log.warn(`Rate limited. Retrying after ${retryAfter} seconds.`); - continue; - } else { - throw error; - } - } - } - - return tracks; - } - - static isPlaylistTrackObject( - obj: any, - ): obj is SpotifyApi.PlaylistTrackObject { + static isTrackObjectFull(obj: any): obj is SpotifyApi.TrackObjectFull { return obj?.hasOwnProperty("track"); } - static isPlaylistTrackObjectArray( - obj: any, - ): obj is SpotifyApi.PlaylistTrackObject[] { + static isTrackObjectFullArray(obj: any): obj is SpotifyApi.TrackObjectFull[] { return ( Array.isArray(obj) && - obj.every((item: any) => this.isPlaylistTrackObject(item)) + obj.every((item: any) => this.isTrackObjectFull(item)) ); } static async _getPlaylistWithTracks(spClient: SpotifyWebApi, id: string) { - let tracks: SpotifyApi.PlaylistTrackObject[] = []; + let tracks: SpotifyApi.TrackObjectFull[] = []; let offset = 0; let result; let retryAfter = 0; @@ -121,31 +84,9 @@ export default class Library extends Base { const id = params.id; - let tracks = [] as any; + const tracks = this.getTracks(sources); - if ( - Array.isArray(sources) && - Array.isArray(sources[0]) && - Library.isPlaylistTrackObjectArray(sources[0]) - ) { - // If the first source is an array of PlaylistTrackObjects, assume all sources are - tracks = sources.flat(); - } else if (Array.isArray(sources) && sources[0]!.hasOwnProperty("tracks")) { - // If the first source has a 'tracks' property that is an array, assume all sources do - for (const source of sources) { - tracks.push(...source.tracks); - } - } else if (Library.isPlaylistTrackObjectArray(sources)) { - tracks = sources; - } else { - throw new Error( - `Invalid source type: ${typeof sources[0]} in ${ - sources[0] - } located in sources: ${JSON.stringify(sources)}`, - ); - } - - const trackUris = tracks.map((track: any) => track.track.uri) as string[]; + const trackUris = tracks.map((track: any) => `spotify:track:${track.id}`); await Library.addTracksBatch(spClient, id, trackUris); @@ -167,31 +108,9 @@ export default class Library extends Base { const playlistName = params.name; - let tracks = [] as any; + const tracks = this.getTracks(sources); - if ( - Array.isArray(sources) && - Array.isArray(sources[0]) && - Library.isPlaylistTrackObjectArray(sources[0]) - ) { - // If the first source is an array of PlaylistTrackObjects, assume all sources are - tracks = sources.flat(); - } else if (Array.isArray(sources) && sources[0]!.hasOwnProperty("tracks")) { - // If the first source has a 'tracks' property that is an array, assume all sources do - for (const source of sources) { - tracks.push(...source.tracks); - } - } else if (Library.isPlaylistTrackObjectArray(sources)) { - tracks = sources; - } else { - throw new Error( - `Invalid source type: ${typeof sources[0]} in ${ - sources[0] - } located in sources: ${JSON.stringify(sources)}`, - ); - } - - const trackUris = tracks.map((track: any) => track.track.uri) as string[]; + const trackUris = tracks.map((track: any) => `spotify:track:${track.id}`); const response = await spClient.createPlaylist(playlistName, { public: params.isPublic ?? false, @@ -225,35 +144,50 @@ export default class Library extends Base { const id = params.id; - let tracks = [] as any; + const tracks = this.getTracks(sources); - if ( - Array.isArray(sources) && - Array.isArray(sources[0]) && - Library.isPlaylistTrackObjectArray(sources[0]) - ) { - // If the first source is an array of PlaylistTrackObjects, assume all sources are - tracks = sources.flat(); - } else if (Array.isArray(sources) && sources[0]!.hasOwnProperty("tracks")) { - // If the first source has a 'tracks' property that is an array, assume all sources do - for (const source of sources) { - tracks.push(...source.tracks); - } - } else if (Library.isPlaylistTrackObjectArray(sources)) { - tracks = sources; - } else { - throw new Error( - `Invalid source type: ${typeof sources[0]} in ${ - sources[0] - } located in sources: ${JSON.stringify(sources)}`, - ); - } + // console.log("trackSaveAsReplace", tracks[0]); - const trackUris = tracks.map((track: any) => track.track.uri) as string[]; + const trackUris = tracks.map((track: any) => `spotify:track:${track.id}`); + + console.log("trackUris", trackUris); // await spClient.replaceTracksInPlaylist(id, trackUris); await Library.replaceTracksBatch(spClient, id, trackUris); return Library._getPlaylistWithTracks(spClient, id); } + + static async likedTracks( + spClient: SpotifyWebApi, + { limit = 50, offset = 0 }: { limit?: number; offset?: number }, + ) { + const tracks: SpotifyApi.TrackObjectFull[] = []; + let result; + let retryAfter = 0; + + while (true) { + try { + await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000)); + result = await spClient.getMySavedTracks({ + limit: Math.min(limit - tracks.length, limit), + offset: offset + tracks.length, + }); + tracks.push(...result.body.items); + if (tracks.length >= limit || result.body.items.length < limit) { + break; + } + } catch (error: any) { + if (error.statusCode === 429) { + retryAfter = error.headers["retry-after"]; + log.warn(`Rate limited. Retrying after ${retryAfter} seconds.`); + continue; + } else { + throw error; + } + } + } + + return tracks; + } } diff --git a/src/lib/workflow/Order.ts b/src/lib/workflow/Order.ts index d83eb44..d40406f 100644 --- a/src/lib/workflow/Order.ts +++ b/src/lib/workflow/Order.ts @@ -5,113 +5,75 @@ import { Base } from "./Base"; import _ from "radash"; import type { AccessToken } from "./Base"; import { Logger } from "../log"; +import type SpotifyWebApi from "spotify-web-api-node"; const log = new Logger("Order"); export default class Order extends Base { - constructor(accessToken: AccessToken) { - super(accessToken); - } - - static isPlaylistTrackObject( - obj: any, - ): obj is SpotifyApi.PlaylistTrackObject { - return obj?.hasOwnProperty("track"); - } - - static isPlaylistTrackObjectArray( - obj: any, - ): obj is SpotifyApi.PlaylistTrackObject[] { - return ( - Array.isArray(obj) && - obj.every((item: any) => this.isPlaylistTrackObject(item)) - ); - } - /** * The function sorts an array of objects based on a specified sort key and sort order. * @param {Operation[]} sources - An array of Operation objects. * @param params - The `params` parameter is an object that contains two properties: * @returns an array of sorted Operation objects. */ - static sort(sources: any[], params: { sortKey: string; sortOrder: string }) { + static sort( + spClient: SpotifyWebApi, + sources: any[], + params: { sortKey: string; sortOrder: string }, + ) { log.info("Sorting..."); log.debug("Sort Sources:", sources); - let tracks = [] as any; - - if ( - Array.isArray(sources) && - Array.isArray(sources[0]) && - Order.isPlaylistTrackObjectArray(sources[0]) - ) { - // If the first source is an array of PlaylistTrackObjects, assume all sources are - tracks = sources.flat(); - } else if (Array.isArray(sources) && sources[0]!.hasOwnProperty("tracks")) { - // If the first source has a 'tracks' property that is an array, assume all sources do - for (const source of sources) { - tracks.push(...source.tracks); - } - } else if (Order.isPlaylistTrackObjectArray(sources)) { - tracks = sources; - } else { - throw new Error( - `Invalid source type: ${typeof sources[0]} in ${ - sources[0] - } located in sources: ${JSON.stringify(sources)}`, - ); - } + const tracks = this.getTracks(sources); function getOrderKey(obj, path) { return path.split(".").reduce((o, i) => { - if (/^\d+$/.test(i)) { - return o[parseInt(i, 10)]; + let indexMatch; + if ((indexMatch = i.match(/^(\w+)\[(\d+)\]$/))) { + // Handle array access + const propName = indexMatch[1]; + const index = parseInt(indexMatch[2], 10); + if ( + o?.hasOwnProperty(propName) && + Array.isArray(o[propName]) && + o[propName].length > index + ) { + return o[propName][index]; + } else { + log.error(`Failed to access property '${i}' on object:`, o); + return undefined; // Or handle the error differently + } } else { + // Handle object property access + if (!o?.hasOwnProperty(i)) { + log.error(`Failed to access property '${i}' on object:`, o); + return undefined; // Or handle the error differently + } return o[i]; } }, obj); } - + if (Array.isArray(tracks)) { log.info("Sorting by", [params.sortKey, params.sortOrder]); - const sortKey = params.sortKey || "track.popularity"; + const sortKey = params.sortKey || "popularity"; const sortOrder = params.sortOrder === "asc" ? "asc" : "desc"; - return tracks.sort((a, b) => { + const sortedTracks = tracks.sort((a, b) => { const keyA = getOrderKey(a, sortKey); const keyB = getOrderKey(b, sortKey); if (keyA < keyB) return sortOrder === "asc" ? -1 : 1; if (keyA > keyB) return sortOrder === "asc" ? 1 : -1; return 0; }); + return sortedTracks; } return []; } - static shuffle(sources: any[], params: {}) { + + static shuffle(spClient: SpotifyWebApi, sources: any[], params: {}) { log.info("Shuffling..."); log.debug("Shuffle Sources:", sources); - let tracks = [] as any; - - if ( - Array.isArray(sources) && - Array.isArray(sources[0]) && - Order.isPlaylistTrackObjectArray(sources[0]) - ) { - // If the first source is an array of PlaylistTrackObjects, assume all sources are - tracks = sources.flat(); - } else if (Array.isArray(sources) && sources[0]!.hasOwnProperty("tracks")) { - // If the first source has a 'tracks' property that is an array, assume all sources do - for (const source of sources) { - tracks.push(...source.tracks); - } - } else if (Order.isPlaylistTrackObjectArray(sources)) { - tracks = sources; - } else { - throw new Error( - `Invalid source type: ${typeof sources[0]} in ${ - sources[0] - } located in sources: ${JSON.stringify(sources)}`, - ); - } + const tracks = this.getTracks(sources); if (Array.isArray(tracks)) { return tracks.sort(() => Math.random() - 0.5); diff --git a/src/lib/workflow/Playlist.ts b/src/lib/workflow/Playlist.ts index 0781e7a..a689598 100644 --- a/src/lib/workflow/Playlist.ts +++ b/src/lib/workflow/Playlist.ts @@ -44,195 +44,6 @@ export default class Playlist extends Base { super(accessToken, spClient); } - static isPlaylistTrackObject( - obj: any, - ): obj is SpotifyApi.PlaylistTrackObject { - return obj?.hasOwnProperty("track"); - } - - static isPlaylistTrackObjectArray( - obj: any, - ): obj is SpotifyApi.PlaylistTrackObject[] { - return ( - Array.isArray(obj) && - obj.every((item: any) => this.isPlaylistTrackObject(item)) - ); - } - - static async _getPlaylistWithTracks( - spClient: SpotifyWebApi, - playlistId: string, - ) { - return spClient - .getPlaylist(playlistId) - .then((response) => ({ - playlistId: response.body.id, - tracks: response.body.tracks.items, - })) - .catch((error) => { - log.error("Error getting playlist tracks", error); - throw new Error( - "Error getting playlist tracks " + (error as Error).message, - ); - }); - } - - /** - * The `saveAsAppend` saves a list of tracks to a Spotify playlist by - * appending them to the existing tracks in the playlist. - * @param {SpotifyWebApi} spClient - The `spClient` parameter is an instance of the SpotifyWebApi - * class, which is used to make API requests to the Spotify API. - * @param {any[]} sources - The `sources` parameter is an array that contains the sources of tracks - * to be added to the playlist. It can have different formats: - * @param params - { playlistId: string } - * @returns the playlist with the added tracks. - */ - static async saveAsAppend( - spClient: SpotifyWebApi, - sources: any[], - params: { playlistId: string }, - ) { - log.info("Saving as append playlist..."); - log.debug("SaveAsAppend Sources:", sources); - - const playlistId = params.playlistId; - - let tracks = [] as any; - - if ( - Array.isArray(sources) && - Array.isArray(sources[0]) && - Playlist.isPlaylistTrackObjectArray(sources[0]) - ) { - // If the first source is an array of PlaylistTrackObjects, assume all sources are - tracks = sources.flat(); - } else if (Array.isArray(sources) && sources[0]!.hasOwnProperty("tracks")) { - // If the first source has a 'tracks' property that is an array, assume all sources do - for (const source of sources) { - tracks.push(...source.tracks); - } - } else if (Playlist.isPlaylistTrackObjectArray(sources)) { - tracks = sources; - } else { - throw new Error( - `Invalid source type: ${typeof sources[0]} in ${ - sources[0] - } located in sources: ${JSON.stringify(sources)}`, - ); - } - - const trackUris = tracks.map((track: any) => track.track.uri) as string[]; - - await Playlist.addTracksBatch(spClient, playlistId, trackUris); - - return Playlist._getPlaylistWithTracks(spClient, playlistId); - } - - static async saveAsNew( - spClient: SpotifyWebApi, - sources: any[], - params: { - name: string; - isPublic?: boolean; - collaborative?: boolean; - description?: string; - }, - ) { - log.info("Saving as new playlist..."); - log.debug("SaveAsNew Sources:", sources); - - const playlistName = params.name; - - let tracks = [] as any; - - if ( - Array.isArray(sources) && - Array.isArray(sources[0]) && - Playlist.isPlaylistTrackObjectArray(sources[0]) - ) { - // If the first source is an array of PlaylistTrackObjects, assume all sources are - tracks = sources.flat(); - } else if (Array.isArray(sources) && sources[0]!.hasOwnProperty("tracks")) { - // If the first source has a 'tracks' property that is an array, assume all sources do - for (const source of sources) { - tracks.push(...source.tracks); - } - } else if (Playlist.isPlaylistTrackObjectArray(sources)) { - tracks = sources; - } else { - throw new Error( - `Invalid source type: ${typeof sources[0]} in ${ - sources[0] - } located in sources: ${JSON.stringify(sources)}`, - ); - } - - const trackUris = tracks.map((track: any) => track.track.uri) as string[]; - - const response = await spClient.createPlaylist(playlistName, { - public: params.isPublic ?? false, - collaborative: params.collaborative ?? false, - description: params.description ?? "", - }); - - await Playlist.addTracksBatch(spClient, response.body.id, trackUris); - - return Playlist._getPlaylistWithTracks(spClient, response.body.id); - } - - /** - * The function `saveAsReplace` takes in a Spotify client, an array of sources, and a playlist ID, - * and replaces the tracks in the playlist with the tracks from the sources. - * @param {SpotifyWebApi} spClient - The `spClient` parameter is an instance of the `SpotifyWebApi` - * class, which is used to make API requests to the Spotify Web API. - * @param {any[]} sources - The `sources` parameter is an array that contains the sources of tracks - * to be saved. It can have different formats: - * @param params - { playlistId: string } - * @returns the result of calling the `_getPlaylistWithTracks` method with the `spClient` and - * `playlistId` as arguments. - */ - static async saveAsReplace( - spClient: SpotifyWebApi, - sources: any[], - params: { playlistId: string }, - ) { - log.info("Saving as replace playlist..."); - log.debug("SaveAsReplace Sources:", sources); - - const playlistId = params.playlistId; - - let tracks = [] as any; - - if ( - Array.isArray(sources) && - Array.isArray(sources[0]) && - Playlist.isPlaylistTrackObjectArray(sources[0]) - ) { - // If the first source is an array of PlaylistTrackObjects, assume all sources are - tracks = sources.flat(); - } else if (Array.isArray(sources) && sources[0]!.hasOwnProperty("tracks")) { - // If the first source has a 'tracks' property that is an array, assume all sources do - for (const source of sources) { - tracks.push(...source.tracks); - } - } else if (Playlist.isPlaylistTrackObjectArray(sources)) { - tracks = sources; - } else { - throw new Error( - `Invalid source type: ${typeof sources[0]} in ${ - sources[0] - } located in sources: ${JSON.stringify(sources)}`, - ); - } - - const trackUris = tracks.map((track: any) => track.track.uri) as string[]; - - // await spClient.replaceTracksInPlaylist(playlistId, trackUris); - await Playlist.replaceTracksBatch(spClient, playlistId, trackUris); - - return Playlist._getPlaylistWithTracks(spClient, playlistId); - } - /** * The function `getPlaylistTracks` retrieves the tracks of a playlist using the Spotify Web API. * @param {SpotifyWebApi} spClient - The `spClient` parameter is an instance of the `SpotifyWebApi` @@ -244,19 +55,6 @@ export default class Playlist extends Base { * @returns the result of calling the `_getPlaylistWithTracks` method of the `Playlist` class with * the `spClient` and `playlistId` parameters. */ - static async getPlaylistTracks( - spClient: SpotifyWebApi, - sources: any[], - params: { playlistId: string }, - ) { - log.info("Getting playlist tracks..."); - log.debug("GetPlaylistTracks Sources:", sources); - - const playlistId = params.playlistId; - - return Playlist._getPlaylistWithTracks(spClient, playlistId); - } - /** * The function `getRecommendedTracks` retrieves recommended tracks from Spotify based on given * sources and parameters. @@ -287,29 +85,7 @@ export default class Playlist extends Base { throw new Error(`Limit cannot be greater than ${MAX_LIMIT}`); } - let tracks = [] as any; - - if ( - Array.isArray(sources) && - Array.isArray(sources[0]) && - Playlist.isPlaylistTrackObjectArray(sources[0]) - ) { - // If the first source is an array of PlaylistTrackObjects, assume all sources are - tracks = sources.flat(); - } else if (Array.isArray(sources) && sources[0]!.hasOwnProperty("tracks")) { - // If the first source has a 'tracks' property that is an array, assume all sources do - for (const source of sources) { - tracks.push(...source.tracks); - } - } else if (Playlist.isPlaylistTrackObjectArray(sources)) { - tracks = sources; - } else { - throw new Error( - `Invalid source type: ${typeof sources[0]} in ${ - sources[0] - } located in sources: ${JSON.stringify(sources)}`, - ); - } + const tracks = this.getTracks(sources); const options = { ...params } as getTracksRecomendationParams; @@ -330,4 +106,19 @@ export default class Playlist extends Base { return response.body.tracks; } + + static async albumTracks( + spClient: SpotifyWebApi, + sources: any[], + params: { + albumId: string; + }, + ): Promise { + const tracks: SpotifyApi.TrackObjectFull[] = []; + const tracksResponse = await spClient.getAlbumTracks(params.albumId); + const trackIds = tracksResponse.body.items.map((track) => track.id); + const trackObjects = await spClient.getTracks(trackIds); + tracks.push(...trackObjects.body.tracks); + return tracks; + } } diff --git a/src/lib/workflow/Selector.ts b/src/lib/workflow/Selector.ts new file mode 100644 index 0000000..b0fa6b5 --- /dev/null +++ b/src/lib/workflow/Selector.ts @@ -0,0 +1,192 @@ +/* eslint-disable @typescript-eslint/ban-types */ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { Base } from "./Base"; +import type { AccessToken } from "./Base"; +import { Logger } from "../log"; +import type SpotifyWebApi from "spotify-web-api-node"; + +const log = new Logger("Workflow"); +export default class Selector extends Base { + /** + * Returns the first `count` tracks from the given sources. + * + * @param spClient - The SpotifyWebApi client. + * @param sources - An array of sources. + * @param params - An object containing the `count` parameter. + * @returns An array of tracks. + * @throws Error if the source type is invalid. + */ + static first( + spClient: SpotifyWebApi, + sources: any[], + params: { count: number }, + ) { + log.info("First Selection..."); + log.debug("First Sources:", sources); + + const tracks = this.getTracks(sources); + + if (Array.isArray(tracks)) { + return tracks.slice(0, params.count); + } else { + throw new Error(`Invalid source type: ${typeof tracks}`); + } + } + + /** + * Returns the last `count` number of tracks from the given sources. + * + * @param spClient - The SpotifyWebApi client. + * @param sources - An array of sources. + * @param params - An object containing the `count` parameter. + * @returns An array of tracks. + * @throws Error if the source type is invalid. + */ + static last( + spClient: SpotifyWebApi, + sources: any[], + params: { count: number }, + ) { + log.info("Last Selection..."); + log.debug("Last Sources:", sources); + + const tracks = this.getTracks(sources); + + if (Array.isArray(tracks)) { + return tracks.slice(tracks.length - params.count); + } else { + throw new Error(`Invalid source type: ${typeof tracks}`); + } + } + + /** + * Returns all elements from the given sources array except the first element. + * Throws an error if the source type is invalid. + * + * @param spClient - The SpotifyWebApi client. + * @param sources - An array of sources. + * @param params - The parameters object containing the count property. + * @returns An array of tracks excluding the first track. + * @throws Error if the source type is invalid. + */ + static allButFirst( + spClient: SpotifyWebApi, + sources: any[], + params: { count: number }, + ) { + log.info("All But First Selection..."); + log.debug("All But First Sources:", sources); + + const tracks = this.getTracks(sources); + + if (Array.isArray(tracks)) { + return tracks.slice(1); + } else { + throw new Error(`Invalid source type: ${typeof tracks}`); + } + } + + /** + * Returns all but the last element from the given sources array. + * + * @param spClient - The SpotifyWebApi client. + * @param sources - An array of sources. + * @param params - Additional parameters. + * @param params.count - The number of elements to retrieve. + * @returns An array containing all but the last element from the sources array. + * @throws An error if the source type is invalid. + */ + static allButLast( + spClient: SpotifyWebApi, + sources: any[], + params: { count: number }, + ) { + log.info("All But Last Selection..."); + log.debug("All But Last Sources:", sources); + + const tracks = this.getTracks(sources); + + if (Array.isArray(tracks)) { + return tracks.slice(0, tracks.length - 1); + } else { + throw new Error(`Invalid source type: ${typeof tracks}`); + } + } + + /** + * Selects a random number of items from sources. + * + * @param sources - The array of elements to select from. + * @param params - The parameters for the selection. + * @param params.count - The number of elements to select. + */ + static random( + spClient: SpotifyWebApi, + sources: any[], + params: { count: number }, + ) { + log.info("Random Selection..."); + log.debug("Random Sources:", sources); + + const tracks = this.getTracks(sources); + + if (Array.isArray(tracks)) { + const res = new Set(); + while (res.size < params.count) { + const randomIndex = Math.floor(Math.random() * tracks.length); + res.add(tracks[randomIndex]); + } + return Array.from(res); + } else { + throw new Error(`Invalid source type: ${typeof tracks}`); + } + } + + /** + * Uses Spotify's recommendation API to recommend items based on the sources. + * Seeds can be: tracks, artists pulled randomly from the sources. + * + * + * @param sources - An array of sources to select items from. + * @param params - The parameters for the recommendation. + * @param params.count - The number of items to recommend. + */ + static recommend( + spClient: SpotifyWebApi, + sources: any[], + params: { count: number; seedType: "tracks" | "artists" }, + ) { + log.info("Recommendation..."); + log.debug("Recommendation Sources:", sources); + + const tracks = this.getTracks(sources); + + const seedTracks = new Array(); + + if (Array.isArray(tracks)) { + const res = new Set(); + while (res.size < 5) { + // 5 is the max number of seed shared between tracks, artists, and genres + const randomIndex = Math.floor(Math.random() * tracks.length); + res.add(tracks[randomIndex]!); + } + seedTracks.push(...Array.from(res)); + } else { + throw new Error(`Invalid source type: ${typeof tracks}`); + } + + const seedTrackIds = Array.from(seedTracks).map((track) => track.id); + const seedArtists = Array.from(seedTracks).map( + (track) => track.artists[0]!.id, + ); + if (Array.isArray(seedTracks)) { + return spClient.getRecommendations({ + seed_tracks: params.seedType === "tracks" ? seedTrackIds : undefined, + seed_artists: params.seedType === "artists" ? seedArtists : undefined, + limit: params.count, + }); + } else { + throw new Error(`Invalid source type: ${typeof seedTracks}`); + } + } +} diff --git a/src/lib/workflow/Utility.ts b/src/lib/workflow/Utility.ts index 785ceb1..5830830 100644 --- a/src/lib/workflow/Utility.ts +++ b/src/lib/workflow/Utility.ts @@ -5,86 +5,33 @@ import { Base } from "./Base"; import * as _ from "radash"; import type { AccessToken } from "./Base"; import { Logger } from "../log"; +import type SpotifyWebApi from "spotify-web-api-node"; const log = new Logger("Utility"); export default class Utility extends Base { - constructor(accessToken: AccessToken) { - super(accessToken); - } - - static isPlaylistTrackObject( - obj: any, - ): obj is SpotifyApi.PlaylistTrackObject { - return obj?.hasOwnProperty("track"); - } - - static isPlaylistTrackObjectArray( - obj: any, - ): obj is SpotifyApi.PlaylistTrackObject[] { - return ( - Array.isArray(obj) && - obj.every((item: any) => this.isPlaylistTrackObject(item)) - ); - } - static removeKeys(sources: any[], params: { keys: string[] }) { + static removeKeys( + spClient: SpotifyWebApi, + sources: any[], + params: { keys: string[] }, + ) { log.debug("RemoveKeys Sources:", sources); log.info("Removing keys..."); - let tracks = [] as any; - - if ( - Array.isArray(sources) && - Array.isArray(sources[0]) && - Utility.isPlaylistTrackObjectArray(sources[0]) - ) { - // If the first source is an array of PlaylistTrackObjects, assume all sources are - tracks = sources.flat(); - } else if (Array.isArray(sources) && sources[0]!.hasOwnProperty("tracks")) { - // If the first source has a 'tracks' property that is an array, assume all sources do - for (const source of sources) { - tracks.push(...source.tracks); - } - } else if (Utility.isPlaylistTrackObjectArray(sources)) { - tracks = sources; - } else { - throw new Error( - `Invalid source type: ${typeof sources[0]} in ${ - sources[0] - } located in sources: ${JSON.stringify(sources)}`, - ); - } + const tracks = this.getTracks(sources); const result = tracks.map((track: any) => _.omit(track, params.keys || [])); return result; } - static includeOnlyKeys(sources: any[], params: { keys: string[] }) { + static includeOnlyKeys( + spClient: SpotifyWebApi, + sources: any[], + params: { keys: string[] }, + ) { log.debug("IncludeOnlyKeys Sources:", sources); log.info("Including only keys..."); - let tracks = [] as any; - - if ( - Array.isArray(sources) && - Array.isArray(sources[0]) && - Utility.isPlaylistTrackObjectArray(sources[0]) - ) { - // If the first source is an array of PlaylistTrackObjects, assume all sources are - tracks = sources.flat(); - } else if (Array.isArray(sources) && sources[0]!.hasOwnProperty("tracks")) { - // If the first source has a 'tracks' property that is an array, assume all sources do - for (const source of sources) { - tracks.push(...source.tracks); - } - } else if (Utility.isPlaylistTrackObjectArray(sources)) { - tracks = sources; - } else { - throw new Error( - `Invalid source type: ${typeof sources[0]} in ${ - sources[0] - } located in sources: ${JSON.stringify(sources)}`, - ); - } + const tracks = this.getTracks(sources); const result = tracks.map((track: any) => _.pick(track, params.keys || [])); return result; diff --git a/src/lib/workflow/Workflow.ts b/src/lib/workflow/Workflow.ts index 817a157..734c60b 100644 --- a/src/lib/workflow/Workflow.ts +++ b/src/lib/workflow/Workflow.ts @@ -74,18 +74,9 @@ export const operationParamsTypesMap = { }, } as Record>; -// import * as _ from "radash"; -interface Operations { - Filter: typeof Filter; - Combiner: typeof Combiner; - Utility: typeof Utility; - Order: typeof Order; - Playlist: typeof Playlist; - Library: typeof Library; - [key: string]: any; -} +import type { Workflow } from "./types/base"; -export const operations: Operations = { +export const operations: Workflow.Operations = { Filter, Combiner, Utility, @@ -131,7 +122,7 @@ export class Runner extends Base { return sourceValues; } - async fetchSourceTracks(source) { + async fetchSourceTracks(source: Source) { let tracks: SpotifyApi.PlaylistTrackObject[] = []; log.info( `Loading source ${source.id} of type ${ @@ -140,16 +131,17 @@ export class Runner extends Base { ); if (source.type === "Source.playlist") { - log.info(`Loading playlist ${source.params.playlistId}`); + log.info(`Loading playlist ${source.params?.playlistId ?? ""}`); const limit = 25; let offset = 0; let result; let retryAfter = 0; + let maxRetries = 5; - while (true) { + while (true && maxRetries > 0) { try { log.debug("Getting playlist tracks", { - id: source.params.playlistId, + id: source.params?.playlistId ?? "", limit, offset, }); @@ -157,7 +149,7 @@ export class Runner extends Base { setTimeout(resolve, retryAfter * 1000), ); result = await this.spClient.getPlaylistTracks( - source.params.playlistId as string, + source.params?.playlistId as string, { limit, offset, @@ -170,6 +162,7 @@ export class Runner extends Base { if (error.statusCode === 429) { retryAfter = error.headers["retry-after"]; log.warn(`Rate limited. Retrying after ${retryAfter} seconds.`); + maxRetries--; continue; } else { throw error; @@ -181,7 +174,7 @@ export class Runner extends Base { } } } else if (source.type === "Library.likedTracks") { - const limit = source.params.limit ?? 50; + const limit = source.params?.limit ?? 50; tracks = await operations.Library.likedTracks(this.spClient, { limit, offset: 0, @@ -262,21 +255,18 @@ export class Runner extends Base { // Split the operation type into class name and method name const [className, methodName] = operation.type.split(".") as [ - keyof Operations, - keyof Operations[keyof Operations], + keyof Workflow.Operations, + keyof Workflow.Operations[keyof Workflow.Operations], ]; // Get the class from the operations object const operationClass = operations[className]; - const result = - className === "Playlist" || className === "Library" - ? await operationClass[methodName]( - this.spClient, - sources, - operation.params, - ) - : await operationClass[methodName](sources, operation.params); + const result = await operationClass[methodName]( + this.spClient, + sources, + operation.params, + ); sourceValues.set(operationId, result); return result; @@ -445,8 +435,8 @@ export class Runner extends Base { ); } else { const [className, methodName] = operationType.split(".") as [ - keyof Operations, - keyof Operations[keyof Operations], + keyof Workflow.Operations, + keyof Workflow.Operations[keyof Workflow.Operations], ]; const operationClass = operations[className]; if ( diff --git a/src/lib/workflow/types/base.d.ts b/src/lib/workflow/types/base.d.ts new file mode 100644 index 0000000..cfc6d89 --- /dev/null +++ b/src/lib/workflow/types/base.d.ts @@ -0,0 +1,19 @@ +type Operations = { + Filter: typeof Filter; + Combiner: typeof Combiner; + Utility: typeof Utility; + Order: typeof Order; + Playlist: typeof Playlist; + Library: typeof Library; +}; + +type OperationArgs = { + spClient: SpotifyWebApi; + sources: any[]; + params: any; +}; + +export namespace Workflow { + export type OperationArgs = OperationArgs; + export type Operations = Operations; +} diff --git a/src/middlewares/handlers/userApi.ts b/src/middlewares/handlers/userApi.ts index ba14133..61714de 100644 --- a/src/middlewares/handlers/userApi.ts +++ b/src/middlewares/handlers/userApi.ts @@ -11,16 +11,13 @@ const logger = new Logger("middleware:userApi"); const matchPaths = ["/api/user"]; async function getSession(req: NextRequest) { - const response = await fetch( - process.env.NEXTAUTH_URL + "/api/auth/session", - { - headers: { - "Content-Type": "application/json", - Cookie: req.headers.get("cookie") ?? "", - }, - method: "GET", + const response = await fetch(process.env.NEXTAUTH_URL + "/api/auth/session", { + headers: { + "Content-Type": "application/json", + Cookie: req.headers.get("cookie") ?? "", }, - ); + method: "GET", + }); if (!response.ok) { return null; } diff --git a/src/server/db/index.ts b/src/server/db/index.ts index 6140011..bd92e1e 100644 --- a/src/server/db/index.ts +++ b/src/server/db/index.ts @@ -1,11 +1,7 @@ -import { drizzle } from 'drizzle-orm/postgres-js'; -import postgres from 'postgres'; - +import { drizzle } from "drizzle-orm/postgres-js"; +import postgres from "postgres"; import { env } from "~/env"; import * as schema from "./schema"; -export const db = drizzle( - postgres(env.DATABASE_URL), - { schema }, -); \ No newline at end of file +export const db = drizzle(postgres(env.DATABASE_URL), { schema }); diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index 3681f6e..b669f95 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -10,7 +10,7 @@ import { varchar, json, integer, - pgTable + pgTable, } from "drizzle-orm/pg-core"; import { type AdapterAccount } from "next-auth/adapters"; @@ -22,12 +22,14 @@ import { type AdapterAccount } from "next-auth/adapters"; */ export const users = pgTable("user", { - id: text("id").primaryKey().$defaultFn(() => randomUUID()), + id: text("id") + .primaryKey() + .$defaultFn(() => randomUUID()), name: text("name"), email: text("email").notNull().unique(), emailVerified: timestamp("emailVerified", { mode: "date" }), image: text("image"), -}) +}); export const usersRelations = relations(users, ({ many }) => ({ accounts: many(accounts), @@ -56,22 +58,28 @@ export const accounts = pgTable( compoundKey: primaryKey({ columns: [account.provider, account.providerAccountId], }), - }) -) + }), +); export const accountsRelations = relations(accounts, ({ one }) => ({ user: one(users, { fields: [accounts.userId], references: [users.id] }), })); -export const sessions = pgTable("session", { - id: text("id").primaryKey().$defaultFn(() => randomUUID()), - sessionToken: text("sessionToken").notNull().unique(), - userId: text("userId") - .notNull() - .references(() => users.id, { onDelete: "cascade" }), - expires: timestamp("expires", { mode: "date" }).notNull(), - }, (session) => ({ - userIdIdx: index().on(session.userId) - })) +export const sessions = pgTable( + "session", + { + id: text("id") + .primaryKey() + .$defaultFn(() => randomUUID()), + sessionToken: text("sessionToken").notNull().unique(), + userId: text("userId") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + expires: timestamp("expires", { mode: "date" }).notNull(), + }, + (session) => ({ + userIdIdx: index().on(session.userId), + }), +); export const sessionsRelations = relations(sessions, ({ one }) => ({ user: one(users, { fields: [sessions.userId], references: [users.id] }),