From 4dfec18a75e5cdc9dc2bff51f5702f06f29c50fe Mon Sep 17 00:00:00 2001 From: Raditya Harya Date: Wed, 1 May 2024 11:23:31 +0000 Subject: [PATCH] more modules & fixes --- biome.json | 3 +- package-lock.json | 20 +- package.json | 4 +- src/app/api/workflow/[id]/run/route.ts | 10 - src/app/workflow/Flow.tsx | 32 ++- .../nodes/Combiner/RandomStream.tsx | 56 +++++ src/components/nodes/Library/AlbumTracks.tsx | 59 +++++ .../nodes/Library/ArtistsTopTracks.tsx | 59 +++++ src/components/nodes/Library/Playlist.tsx | 5 - src/components/nodes/Order/Reverse.tsx | 18 ++ .../nodes/Order/SeparateArtists.tsx | 18 ++ .../nodes/Primitives/NodeBuilder.tsx | 19 +- src/lib/log.ts | 129 ++++++++++ src/lib/workflow/Base.ts | 68 +++--- src/lib/workflow/Combiner.ts | 44 ++++ src/lib/workflow/Filter.ts | 230 +++++++++--------- src/lib/workflow/Library.ts | 97 ++++++++ src/lib/workflow/Order.ts | 96 +++++--- src/lib/workflow/Workflow.ts | 30 ++- 19 files changed, 775 insertions(+), 222 deletions(-) create mode 100644 src/components/nodes/Combiner/RandomStream.tsx create mode 100644 src/components/nodes/Library/AlbumTracks.tsx create mode 100644 src/components/nodes/Library/ArtistsTopTracks.tsx create mode 100644 src/components/nodes/Order/Reverse.tsx create mode 100644 src/components/nodes/Order/SeparateArtists.tsx diff --git a/biome.json b/biome.json index 1b84b5c..194e5ea 100644 --- a/biome.json +++ b/biome.json @@ -47,7 +47,8 @@ "noAssignInExpressions": "off" }, "correctness": { - "noUndeclaredVariables": "off" + "noUndeclaredVariables": "off", + "useExhaustiveDependencies":"warn" } } }, diff --git a/package-lock.json b/package-lock.json index ceaad3b..087878f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "@sentry/nextjs": "^7.112.2", "@t3-oss/env-nextjs": "^0.10.0", "@tanstack/react-table": "^8.16.0", + "@types/lodash": "^4.17.0", "@types/spotify-api": "^0.0.25", "@xyflow/react": "^12.0.0-next.14", "bullmq": "^5.7.2", @@ -41,6 +42,7 @@ "date-fns": "^3.6.0", "drizzle-orm": "latest", "ioredis": "^5.4.1", + "lodash": "^4.17.21", "lucide-react": "^0.371.0", "million": "^3.0.6", "next": "^14.2.3", @@ -65,7 +67,7 @@ "tailwindcss-animate": "^1.0.7", "uuid": "^9.0.1", "validator": "^13.11.0", - "zod": "^3.22.4", + "zod": "^3.23.5", "zustand": "^4.5.2" }, "devDependencies": { @@ -4261,6 +4263,11 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true }, + "node_modules/@types/lodash": { + "version": "4.17.0", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.0.tgz", + "integrity": "sha512-t7dhREVv6dbNj0q17X12j7yDG4bD/DHYX7o5/DbDxobP0HnGPgpRz2Ej77aL7TZT3DSw13fqUTj8J4mMnqa7WA==" + }, "node_modules/@types/node": { "version": "20.12.7", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.7.tgz", @@ -7962,6 +7969,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, "node_modules/lodash.defaults": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", @@ -10827,9 +10839,9 @@ } }, "node_modules/zod": { - "version": "3.22.4", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz", - "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==", + "version": "3.23.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.5.tgz", + "integrity": "sha512-fkwiq0VIQTksNNA131rDOsVJcns0pfVUjHzLrNBiF/O/Xxb5lQyEXkhZWcJ7npWsYlvs+h0jFWXXy4X46Em1JA==", "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 40d7206..b8c9b30 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "@sentry/nextjs": "^7.112.2", "@t3-oss/env-nextjs": "^0.10.0", "@tanstack/react-table": "^8.16.0", + "@types/lodash": "^4.17.0", "@types/spotify-api": "^0.0.25", "@xyflow/react": "^12.0.0-next.14", "bullmq": "^5.7.2", @@ -55,6 +56,7 @@ "date-fns": "^3.6.0", "drizzle-orm": "latest", "ioredis": "^5.4.1", + "lodash": "^4.17.21", "lucide-react": "^0.371.0", "million": "^3.0.6", "next": "^14.2.3", @@ -79,7 +81,7 @@ "tailwindcss-animate": "^1.0.7", "uuid": "^9.0.1", "validator": "^13.11.0", - "zod": "^3.22.4", + "zod": "^3.23.5", "zustand": "^4.5.2" }, "devDependencies": { diff --git a/src/app/api/workflow/[id]/run/route.ts b/src/app/api/workflow/[id]/run/route.ts index c86b301..467132d 100644 --- a/src/app/api/workflow/[id]/run/route.ts +++ b/src/app/api/workflow/[id]/run/route.ts @@ -33,16 +33,6 @@ export async function POST( } const id = params.id; - const workers = await db.query.workerPool.findMany({}); - - if (workers.length === 0) { - log.error("No workers available"); - return NextResponse.json( - { error: "No workers available" }, - { status: 500 }, - ); - } - const workflow = await db.query.workflowJobs.findFirst({ where: (workflowJobs, { eq }) => eq(workflowJobs.id, id), }); diff --git a/src/app/workflow/Flow.tsx b/src/app/workflow/Flow.tsx index 5eac226..00a0ee6 100644 --- a/src/app/workflow/Flow.tsx +++ b/src/app/workflow/Flow.tsx @@ -19,12 +19,16 @@ import { useShallow } from "zustand/react/shallow"; import Alternate from "@nodes/Combiner/Alternate"; import Push from "@nodes/Combiner/Push"; +import RandomStream from "@nodes/Combiner/RandomStream"; import LikedTracks from "@nodes/Library/LikedTracks"; import Playlist from "@nodes/Library/Playlist"; import SaveAsAppend from "@nodes/Library/SaveAsAppend"; import SaveAsNew from "@nodes/Library/SaveAsNew"; import SaveAsReplace from "@nodes/Library/SaveAsReplace"; +import AlbumTracks from "@nodes/Library/AlbumTracks"; +import Last from "@nodes/Selectors/Last"; +import ArtistsTopTracks from "@nodes/Library/ArtistsTopTracks"; import DedupeArtists from "@nodes/Filter/DedupeArtists"; import DedupeTracks from "@nodes/Filter/DedupeTracks"; @@ -34,11 +38,12 @@ import RemoveMatch from "@nodes/Filter/RemoveMatch"; import Shuffle from "@nodes/Order/Shuffle"; import Sort from "@nodes/Order/Sort"; import SortPopularity from "@nodes/Order/SortPopularity"; +import Reverse from "@nodes/Order/Reverse"; +import SeparateArtists from "@nodes/Order/SeparateArtists"; import AllButFirst from "@nodes/Selectors/AllButFirst"; import AllButLast from "@nodes/Selectors/AllButLast"; import First from "@nodes/Selectors/First"; -import Last from "@nodes/Selectors/Last"; import Recommend from "@nodes/Selectors/Recommend"; import { Button } from "@/components/ui/button"; @@ -71,6 +76,11 @@ export const Nodes = { node: Push, description: "Append tracks of sources sequentially", }, + "Combiner.randomStream": { + title: "Random Stream", + node: RandomStream, + description: "Randomly select tracks from sources", + }, "Filter.dedupeTracks": { title: "Dedup Tracks", node: DedupeTracks, @@ -117,11 +127,31 @@ export const Nodes = { description: "Saves workflow output to an existing playlist by replacing all tracks", }, + "Library.albumTracks": { + title: "Album", + node: AlbumTracks, + description: "Album source", + }, + "Library.artistsTopTracks": { + title: "Artists Top Tracks", + node: ArtistsTopTracks, + description: "Top tracks of artists", + }, "Order.shuffle": { title: "Shuffle", node: Shuffle, description: "Randomly shuffle tracks", }, + "Order.reverse": { + title: "Reverse", + node: Reverse, + description: "Reverse the order of tracks", + }, + "Order.separateArtists": { + title: "Separate Artists", + node: SeparateArtists, + description: "Sort tracks based on artists", + }, "Order.sort": { title: "Sort", node: Sort, diff --git a/src/components/nodes/Combiner/RandomStream.tsx b/src/components/nodes/Combiner/RandomStream.tsx new file mode 100644 index 0000000..70fb93c --- /dev/null +++ b/src/components/nodes/Combiner/RandomStream.tsx @@ -0,0 +1,56 @@ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +"use client"; + +import { Position } from "@xyflow/react"; +import NodeHandle from "../Primitives/NodeHandle"; + +import React from "react"; +import { Separator } from "~/components/ui/separator"; +import useBasicNodeState from "~/hooks/useBasicNodeState"; +import { CardWithHeader } from "../Primitives/Card"; +import Debug from "../Primitives/Debug"; +import { SourceList } from "../Primitives/SourceList"; + +type PlaylistProps = { + id: string; + data: any; +}; + +const RandomStream: React.FC = ({ id, data }) => { + const { state, isValid, targetConnections, sourceConnections } = + useBasicNodeState(id); + + return ( + + + +
+ + + +
+
+ ); +}; + +export default RandomStream; diff --git a/src/components/nodes/Library/AlbumTracks.tsx b/src/components/nodes/Library/AlbumTracks.tsx new file mode 100644 index 0000000..bc52b44 --- /dev/null +++ b/src/components/nodes/Library/AlbumTracks.tsx @@ -0,0 +1,59 @@ +import React from "react"; +import * as z from "zod"; +import InputPrimitive from "../Primitives/Input"; +import NodeBuilder from "../Primitives/NodeBuilder"; + +const formSchema = z.object({ + albumId: z.string().min(1, { + message: "Album ID is required.", + }), + limit: z.number().default(10), + offset: z.number().default(0), +}); + +const AlbumTracks: React.FC = ({ id, data }: any) => { + return ( + ( + <> + + + + + )} + /> + ); +}; + +export default AlbumTracks; diff --git a/src/components/nodes/Library/ArtistsTopTracks.tsx b/src/components/nodes/Library/ArtistsTopTracks.tsx new file mode 100644 index 0000000..cf2c273 --- /dev/null +++ b/src/components/nodes/Library/ArtistsTopTracks.tsx @@ -0,0 +1,59 @@ +import React from "react"; +import * as z from "zod"; +import InputPrimitive from "../Primitives/Input"; +import NodeBuilder from "../Primitives/NodeBuilder"; + +const formSchema = z.object({ + artistsId: z.string().min(1, { + message: "Album ID is required.", + }), + limit: z.number().default(10), + offset: z.number().default(0), +}); + +const ArtistsTopTracks: React.FC = ({ id, data }: any) => { + return ( + ( + <> + + + + + )} + /> + ); +}; + +export default ArtistsTopTracks; diff --git a/src/components/nodes/Library/Playlist.tsx b/src/components/nodes/Library/Playlist.tsx index 919524a..cdf6904 100644 --- a/src/components/nodes/Library/Playlist.tsx +++ b/src/components/nodes/Library/Playlist.tsx @@ -217,11 +217,6 @@ const PlaylistComponent: React.FC = ({ id, data }) => { position={Position.Right} style={{ background: "#555" }} /> -
console.info(data))}>
diff --git a/src/components/nodes/Order/Reverse.tsx b/src/components/nodes/Order/Reverse.tsx new file mode 100644 index 0000000..cd82fb2 --- /dev/null +++ b/src/components/nodes/Order/Reverse.tsx @@ -0,0 +1,18 @@ +import React from "react"; +import NodeBuilder from "../Primitives/NodeBuilder"; + +const Reverse: React.FC = ({ id, data }: any) => { + + return ( + + ); +}; + +export default Reverse; diff --git a/src/components/nodes/Order/SeparateArtists.tsx b/src/components/nodes/Order/SeparateArtists.tsx new file mode 100644 index 0000000..b494bd4 --- /dev/null +++ b/src/components/nodes/Order/SeparateArtists.tsx @@ -0,0 +1,18 @@ +import React from "react"; +import NodeBuilder from "../Primitives/NodeBuilder"; + +const Reverse: React.FC = ({ id, data }: any) => { + + return ( + + ); +}; + +export default Reverse; diff --git a/src/components/nodes/Primitives/NodeBuilder.tsx b/src/components/nodes/Primitives/NodeBuilder.tsx index baf8773..5752cf8 100644 --- a/src/components/nodes/Primitives/NodeBuilder.tsx +++ b/src/components/nodes/Primitives/NodeBuilder.tsx @@ -12,8 +12,8 @@ interface NodeBuilderProps { title: string; type: string; info: string; - formSchema: ZodObject; - formFields: (args: { form: any; register: any }) => React.ReactNode; + formSchema?: ZodObject; + formFields?: (args: { form: any; register: any }) => React.ReactNode; extraContent?: React.ReactNode; handleConnectionType?: "source" | "target" | "both"; } @@ -44,24 +44,27 @@ const NodeBuilder: React.FC = ({ // Handle data updates from props or form React.useEffect(() => { if (data) { - form!.reset(data); + form?.reset(data); } }, [data, form]); - const watch = form!.watch(); + const watch = form?.watch(); const prevWatchRef = React.useRef(watch); React.useEffect(() => { if (JSON.stringify(prevWatchRef.current) !== JSON.stringify(watch)) { - updateNodeData(id, { + updateNodeData?.(id, { ...watch, }); } prevWatchRef.current = watch; }, [watch, id, updateNodeData]); - const formValid = formState!.isValid; + const formValid = formState?.isValid; const nodeValid = React.useMemo(() => { - return formValid && isValid; + if (formState) { + return formValid && isValid; + } + return isValid; }, [formValid, isValid]); return ( @@ -96,7 +99,7 @@ const NodeBuilder: React.FC = ({ {extraContent} diff --git a/src/lib/log.ts b/src/lib/log.ts index 5cc85da..5e0c950 100644 --- a/src/lib/log.ts +++ b/src/lib/log.ts @@ -1,3 +1,5 @@ +import _ from "lodash"; + enum LogLevel { DEBUG, INFO, @@ -58,6 +60,21 @@ class Logger { } debug(message: string, data?: any): void { + let parsed; + try { + parsed = this.getTracks(data); + data = parsed + } catch (error) { + this.error("Failed to parse data", error); + } + + data = this.deepUnset(data, "audio_features"); + data = this.deepUnset(data, "available_markets"); + data = this.deepUnset(data, "preview_url"); + data = this.deepUnset(data, "external_ids"); + data = this.deepUnset(data, "external_urls"); + data = this.deepUnset(data, "release_date_precision"); + data = this.deepUnset(data, "artists"); this.log(LogLevel.DEBUG, message, data); } @@ -80,6 +97,118 @@ class Logger { logTrackTitles(tracks: SpotifyApi.TrackObjectFull[]): void { tracks.forEach((track) => this.info(`Track: ${track.name}`)); } + + getTracks(sources: any[]) { + const tracks: SpotifyApi.TrackObjectFull[] = []; + + _.forEach(sources, (source) => { + let trackSource; + + if (_.has(source, "tracks")) { + trackSource = _.get(source, "tracks"); + } else if (_.has(source, "items")) { + trackSource = _.get(source, "items"); + } else if ( + _.has(source, "track") && + !_.isObject(_.get(source, "track")) + ) { + trackSource = _.get(source, "track") ? [_.get(source, "track")] : []; + } else if (_.isArray(source)) { + trackSource = source; + } + + if (!trackSource) return; + + if (_.has(trackSource, "tracks")) { + _.forEach(trackSource, (track) => { + if (_.get(track, "track.type") === "track") { + tracks.push(_.get(track, "track") as SpotifyApi.TrackObjectFull); + } + }); + } else if ( + _.isArray(trackSource) && + _.isObject(_.get(trackSource, [0])) + ) { + _.forEach(trackSource, (track) => { + if (_.get(track, "track.type") === "track") { + tracks.push(_.get(track, "track") as SpotifyApi.TrackObjectFull); + } else if (_.get(track, "type") === "track") { + tracks.push(track as SpotifyApi.TrackObjectFull); + } else { + tracks.push(track as SpotifyApi.TrackObjectFull); + } + }); + } else { + throw new Error("Invalid source type"); + } + }); + + return tracks; + } + + compressReturnValues(returnValues: any[]) { + const compressedValues: any[] = []; + + returnValues.forEach((playlist: any) => { + const compressedPlaylist: any = { + ...playlist, + tracks: { + items: playlist.tracks.map((item: any) => { + const compressedItem: any = { + ...item, + track: { + ...item.track, + audio_features: undefined, + available_markets: undefined, + preview_url: undefined, + external_ids: undefined, + external_urls: undefined, + }, + }; + + if (compressedItem.track?.album) { + compressedItem.track.album.release_date_precision = undefined; + compressedItem.track.album.artists = + compressedItem.track.album.artists.map( + (artist: SpotifyApi.ArtistObjectSimplified) => ({ + ...artist, + external_urls: undefined, + href: undefined, + uri: undefined, + }), + ); + } + + if (compressedItem.track?.artists) { + compressedItem.track.artists = compressedItem.track.artists.map( + (artist: SpotifyApi.ArtistObjectSimplified) => ({ + ...artist, + external_urls: undefined, + href: undefined, + uri: undefined, + }), + ); + } + + return compressedItem; + }), + }, + }; + + compressedValues.push(compressedPlaylist); + }); + + return compressedValues; + } + + deepUnset = (obj: any, prop: string) => { + if (_.isObject(obj)) { + _.unset(obj, prop); + _.forEach(obj, (value) => { + this.deepUnset(value, prop); + }); + } + }; } export { Logger, LogLevel }; diff --git a/src/lib/workflow/Base.ts b/src/lib/workflow/Base.ts index 45c3594..2027488 100644 --- a/src/lib/workflow/Base.ts +++ b/src/lib/workflow/Base.ts @@ -1,6 +1,8 @@ import SpotifyWebApi from "spotify-web-api-node"; import { env } from "~/env"; import { Logger } from "../log"; +import _ from "lodash"; + export interface AccessToken { slug: string; access_token: string; @@ -10,6 +12,7 @@ const log = new Logger("Base"); export class Base { public spClient: SpotifyWebApi; + public operationValues: Map = new Map(); constructor( public accessToken: AccessToken, @@ -56,6 +59,8 @@ export class Base { const trackChunks = chunk(trackUris, chunkSize); + log.debug("Adding tracks to playlist", trackChunks); + for (const trackChunk of trackChunks) { while (true) { try { @@ -63,7 +68,8 @@ export class Base { await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000), ); - await spClient.addTracksToPlaylist(playlistId, trackChunk); + const snapshot = await spClient.addTracksToPlaylist(playlistId, trackChunk); + log.info("Add Track Snapshot", snapshot.body.snapshot_id); break; } catch (error: any) { if (error.statusCode === 429) { @@ -102,6 +108,8 @@ export class Base { const trackChunks = chunk(trackUris, chunkSize); + log.debug("Removing tracks from playlist", trackChunks); + for (const trackChunk of trackChunks) { while (true) { try { @@ -110,7 +118,12 @@ export class Base { setTimeout(resolve, retryAfter * 1000), ); const trackObjects = trackChunk.map((uri) => ({ uri })); - await spClient.removeTracksFromPlaylist(playlistId, trackObjects); + log.info("Removing tracks", trackObjects); + const snapshot = await spClient.removeTracksFromPlaylist( + playlistId, + trackObjects, + ); + log.info("Remove Track Snapshot", snapshot.body.snapshot_id); break; } catch (error: any) { if (error.statusCode === 429) { @@ -169,53 +182,50 @@ export class Base { * @throws {Error} If the source type is invalid. */ static getTracks(sources: any[]) { - const tracks = [] as SpotifyApi.TrackObjectFull[]; + const tracks: SpotifyApi.TrackObjectFull[] = []; - for (const source of sources) { + _.forEach(sources, (source) => { let trackSource; - if (source.hasOwnProperty("tracks")) { - trackSource = source.tracks; - } else if (source.hasOwnProperty("items")) { - trackSource = source.items; + if (_.has(source, "tracks")) { + trackSource = _.get(source, "tracks"); + } else if (_.has(source, "items")) { + trackSource = _.get(source, "items"); } else if ( - source.hasOwnProperty("track") && - typeof source.track !== "object" + _.has(source, "track") && + !_.isObject(_.get(source, "track")) ) { - trackSource = source.track ? [source.track] : []; - } else if (Array.isArray(source)) { + trackSource = _.get(source, "track") ? [_.get(source, "track")] : []; + } else if (_.isArray(source)) { trackSource = source; } - if (!trackSource) continue; + if (!trackSource) return; - 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") { - tracks.push(track as SpotifyApi.TrackObjectFull); + if (_.has(trackSource, "tracks")) { + _.forEach(trackSource, (track) => { + if (_.get(track, "track.type") === "track") { + tracks.push(_.get(track, "track") as SpotifyApi.TrackObjectFull); } - } + }); } else if ( - Array.isArray(trackSource) && - typeof trackSource[0] === "object" + _.isArray(trackSource) && + _.isObject(_.get(trackSource, [0])) ) { - 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") { + _.forEach(trackSource, (track) => { + if (_.get(track, "track.type") === "track") { + tracks.push(_.get(track, "track") as SpotifyApi.TrackObjectFull); + } else if (_.get(track, "type") === "track") { tracks.push(track as SpotifyApi.TrackObjectFull); } else { - // log.error("ERROR1", track); tracks.push(track as SpotifyApi.TrackObjectFull); } - } + }); } else { log.error("ERROR2", trackSource); throw new Error("Invalid source type"); } - } + }); return tracks; } diff --git a/src/lib/workflow/Combiner.ts b/src/lib/workflow/Combiner.ts index 95359ec..7001ed7 100644 --- a/src/lib/workflow/Combiner.ts +++ b/src/lib/workflow/Combiner.ts @@ -7,6 +7,14 @@ import { Base } from "./Base"; const log = new Logger("Combiner"); export default class Combiner extends Base { + /** + * Pushes the tracks from the given sources into a single array. + * + * @param _spClient - The SpotifyWebApi client. + * @param sources - An array of sources containing tracks. + * @param _params - Additional parameters (currently unused). + * @returns An array of PlaylistTrackObject containing the combined tracks from all sources. + */ static push(_spClient: SpotifyWebApi, sources: any[], _params: {}) { log.debug("Push Sources:", sources); log.info("Pushing..."); @@ -29,6 +37,15 @@ export default class Combiner extends Base { return obj?.hasOwnProperty("track"); } + /** + * Combines the tracks from multiple sources in an alternating manner. + * + * @param _spClient - The SpotifyWebApi instance. + * @param sources - An array of sources containing tracks to be combined. + * @param _params - Additional parameters (currently unused). + * @returns An array of PlaylistTrackObject representing the combined tracks. + * @throws Error if the source type is invalid. + */ static alternate(_spClient: SpotifyWebApi, sources: any[], _params: {}) { log.debug("Alternate Sources:", sources); log.info("Alternating..."); @@ -64,4 +81,31 @@ export default class Combiner extends Base { } return result; } + + /** + * Selects a random stream from the given sources. + * + * @param _spClient - The SpotifyWebApi client. + * @param sources - An array of sources. + * @param _params - Additional parameters. + * @returns An array of SpotifyApi.PlaylistTrackObject representing the selected random stream. + */ + static randomStream(_spClient: SpotifyWebApi, sources: any[], _params: {}) { + log.debug("RandomStream Sources:", sources); + log.info("Selecting a random stream..."); + + const result = [] as SpotifyApi.PlaylistTrackObject[]; + + const randomSource = sources[Math.floor(Math.random() * sources.length)]; + + if (randomSource.tracks) { + result.push(...randomSource.tracks); + } else if (Array.isArray(randomSource)) { + result.push(...randomSource); + } else { + log.error("Invalid source type:", typeof randomSource); + } + + return result; + } } diff --git a/src/lib/workflow/Filter.ts b/src/lib/workflow/Filter.ts index 5a08529..e4fea22 100644 --- a/src/lib/workflow/Filter.ts +++ b/src/lib/workflow/Filter.ts @@ -1,4 +1,4 @@ -import * as _ from "radash"; +import _ from "lodash"; import type SpotifyWebApi from "spotify-web-api-node"; import { Logger } from "../log"; /* eslint-disable @typescript-eslint/ban-types */ @@ -17,74 +17,64 @@ export default class Filter extends Base { const tracks = Filter.getTracks(sources); - if (Array.isArray(tracks)) { - const res = tracks.filter((track: any) => { + if (_.isArray(tracks)) { + const res = _.filter(tracks, (track: any) => { if (params.filterKey && params.filterValue) { const [operator, value] = params.filterValue.split(" ") as [ string, string, ]; - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion - const trackValue = _.get(track, params.filterKey) as any; + const trackValue = _.get(track, params.filterKey); - let type = "string"; let filterValue: string | number | Date | boolean | object; - if (!Number.isNaN(Number(value))) { - filterValue = Number(value); - type = "number"; - } else if (!Number.isNaN(Date.parse(value))) { + if (!_.isNaN(_.toNumber(value))) { + filterValue = _.toNumber(value); + } else if (!_.isNaN(Date.parse(value))) { filterValue = new Date(value); - type = "date"; } else { filterValue = value; } - switch (type) { - case "number": - switch (operator) { - case ">": - return trackValue > filterValue; - case "<": - return trackValue < filterValue; - case ">=": - return trackValue >= filterValue; - case "<=": - return trackValue <= filterValue; - case "==": - return trackValue === filterValue; - default: - throw new Error(`Invalid operator: ${operator}`); - } - case "string": - return trackValue.includes(filterValue); - case "boolean": - return trackValue === Boolean(filterValue); - // biome-ignore lint/suspicious/noFallthroughSwitchClause: - case "object": - if (filterValue instanceof Date) { - const trackDateValue = new Date( - trackValue as number | string | Date, - ); - switch (operator) { - case ">": - return trackDateValue > filterValue; - case "<": - return trackDateValue < filterValue; - case ">=": - return trackDateValue >= filterValue; - case "<=": - return trackDateValue <= filterValue; - case "==": - return trackDateValue.getTime() === filterValue.getTime(); - default: - throw new Error(`Invalid operator: ${operator}`); - } - } - default: - throw new Error( - `Unsupported filterValue type: ${typeof filterValue}`, - ); + if (_.isNumber(filterValue)) { + switch (operator) { + case ">": + return trackValue > filterValue; + case "<": + return trackValue < filterValue; + case ">=": + return trackValue >= filterValue; + case "<=": + return trackValue <= filterValue; + case "==": + return trackValue === filterValue; + default: + throw new Error(`Invalid operator: ${operator}`); + } + } else if (_.isString(filterValue)) { + return _.includes(trackValue, filterValue); + } else if (_.isBoolean(filterValue)) { + return trackValue === Boolean(filterValue); + } else if (_.isObject(filterValue) && _.isDate(filterValue)) { + const trackDateValue = new Date(trackValue); + switch (operator) { + case ">": + return trackDateValue > filterValue; + case "<": + return trackDateValue < filterValue; + case ">=": + return trackDateValue >= filterValue; + case "<=": + return trackDateValue <= filterValue; + case "==": + return trackDateValue.getTime() === filterValue.getTime(); + default: + throw new Error(`Invalid operator: ${operator}`); + } + } else { + throw new Error( + `Unsupported filterValue type: ${typeof filterValue}`, + ); } } return true; @@ -113,9 +103,10 @@ export default class Filter extends Base { const tracks = Filter.getTracks(sources); if (_.isArray(tracks)) { - return _.unique(tracks, (track): string | number | symbol => - _.get(track, "track.artists[0].id"), + const uniqueTracks = _.uniqBy(tracks, (track): string | number | symbol => + _.get(track, "artists[0].id", ""), ); + return uniqueTracks; } return []; } @@ -130,74 +121,64 @@ export default class Filter extends Base { const tracks = Filter.getTracks(sources); - if (Array.isArray(tracks)) { - const res = tracks.filter((track: any) => { + if (_.isArray(tracks)) { + const res = _.filter(tracks, (track: any) => { if (params.matchKey && params.matchValue) { const [operator, value] = params.matchValue.split(" ") as [ string, string, ]; - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion - const trackValue = _.get(track, params.matchKey) as any; + const trackValue = _.get(track, params.matchKey); - let type = "string"; let matchValue: string | number | Date | boolean | object; - if (!Number.isNaN(Number(value))) { - matchValue = Number(value); - type = "number"; - } else if (!Number.isNaN(Date.parse(value))) { + if (!_.isNaN(_.toNumber(value))) { + matchValue = _.toNumber(value); + } else if (!_.isNaN(Date.parse(value))) { matchValue = new Date(value); - type = "date"; } else { matchValue = value; } - switch (type) { - case "number": - switch (operator) { - case ">": - return trackValue > matchValue; - case "<": - return trackValue < matchValue; - case ">=": - return trackValue >= matchValue; - case "<=": - return trackValue <= matchValue; - case "==": - return trackValue === matchValue; - default: - throw new Error(`Invalid operator: ${operator}`); - } - case "string": - return trackValue.includes(matchValue); - case "boolean": - return trackValue === Boolean(matchValue); - // biome-ignore lint/suspicious/noFallthroughSwitchClause: - case "object": - if (matchValue instanceof Date) { - const trackDateValue = new Date( - trackValue as number | string | Date, - ); - switch (operator) { - case ">": - return trackDateValue > matchValue; - case "<": - return trackDateValue < matchValue; - case ">=": - return trackDateValue >= matchValue; - case "<=": - return trackDateValue <= matchValue; - case "==": - return trackDateValue.getTime() === matchValue.getTime(); - default: - throw new Error(`Invalid operator: ${operator}`); - } - } - default: - throw new Error( - `Unsupported matchValue type: ${typeof matchValue}`, - ); + if (_.isNumber(matchValue)) { + switch (operator) { + case ">": + return trackValue > matchValue; + case "<": + return trackValue < matchValue; + case ">=": + return trackValue >= matchValue; + case "<=": + return trackValue <= matchValue; + case "==": + return trackValue === matchValue; + default: + throw new Error(`Invalid operator: ${operator}`); + } + } else if (_.isString(matchValue)) { + return _.includes(trackValue, matchValue); + } else if (_.isBoolean(matchValue)) { + return trackValue === Boolean(matchValue); + } else if (_.isObject(matchValue) && _.isDate(matchValue)) { + const trackDateValue = new Date(trackValue); + switch (operator) { + case ">": + return trackDateValue > matchValue; + case "<": + return trackDateValue < matchValue; + case ">=": + return trackDateValue >= matchValue; + case "<=": + return trackDateValue <= matchValue; + case "==": + return trackDateValue.getTime() === matchValue.getTime(); + default: + throw new Error(`Invalid operator: ${operator}`); + } + } else { + throw new Error( + `Unsupported matchValue type: ${typeof matchValue}`, + ); } } return false; @@ -223,4 +204,27 @@ export default class Filter extends Base { } return []; } + + trackFilter( + _spClient: SpotifyWebApi, + sources: any[], + params: { filterOperationId: string | undefined }, + ) { + log.info("Filtering tracks..."); + log.debug("Filter Sources:", sources); + + const tracks = Filter.getTracks(sources); + if (!params.filterOperationId) { + return tracks; + } + const filterTracks = Filter.getTracks( + this.operationValues.get(params.filterOperationId), + ); + + if (_.isArray(tracks) && _.isArray(filterTracks)) { + return _.differenceBy(tracks, filterTracks, "id"); + } + + return []; + } } diff --git a/src/lib/workflow/Library.ts b/src/lib/workflow/Library.ts index ba66816..ed0b6fa 100644 --- a/src/lib/workflow/Library.ts +++ b/src/lib/workflow/Library.ts @@ -233,4 +233,101 @@ export default class Library extends Base { return await Library._getPlaylistWithTracks(spClient, params.playlistId); } + + static async albumTracks( + spClient: SpotifyWebApi, + _sources: any[], + params: { + albumId: string; + limit?: number; + offset?: number; + }, + ) { + log.info("Getting album tracks..."); + log.info("Album ID:", params.albumId); + const tracks: SpotifyApi.TrackObjectFull[] = []; + let result; + let retryAfter = 0; + + if (!params.limit) { + params.limit = 50; + } + + if (!params.offset) { + params.offset = 0; + } + + while (true) { + try { + await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000)); + result = await spClient.getAlbumTracks(params.albumId, { + limit: Math.min(params.limit - tracks.length, params.limit), + offset: params.offset + tracks.length, + }); + tracks.push(...result.body.items); + if ( + tracks.length >= params.limit || + result.body.items.length < params.limit + ) { + break; + } + } catch (error: any) { + if (error.statusCode === 429) { + retryAfter = error.headers["retry-after"]; + log.warn(`Rate limited. Retrying after ${retryAfter} seconds.`); + } else { + throw error; + } + } + } + + return tracks; + } + + static async artistTopTracks( + spClient: SpotifyWebApi, + _sources: any[], + params: { + artistId: string; + limit?: number; + offset?: number; + }, + ) { + log.info("Getting artist top tracks..."); + log.info("Artist ID:", params.artistId); + const tracks: SpotifyApi.TrackObjectFull[] = []; + let result; + let retryAfter = 0; + + if (!params.limit) { + params.limit = 50; + } + + if (!params.offset) { + params.offset = 0; + } + + while (true) { + try { + await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000)); + result = await spClient.getArtistTopTracks(params.artistId, "US"); + tracks.push(...result.body.tracks); + if ( + tracks.length >= params.limit || + result.body.tracks.length < params.limit + ) { + break; + } + } catch (error: any) { + if (error.statusCode === 429) { + retryAfter = error.headers["retry-after"]; + log.warn(`Rate limited. Retrying after ${retryAfter} seconds.`); + } else { + throw error; + } + } + } + + return tracks; + } } diff --git a/src/lib/workflow/Order.ts b/src/lib/workflow/Order.ts index 784c7bb..0baa3fd 100644 --- a/src/lib/workflow/Order.ts +++ b/src/lib/workflow/Order.ts @@ -4,6 +4,7 @@ import { Logger } from "../log"; /* eslint-disable @typescript-eslint/ban-types */ /* eslint-disable @typescript-eslint/no-unused-vars */ import { Base } from "./Base"; +import _ from "lodash"; const log = new Logger("Order"); export default class Order extends Base { @@ -23,57 +24,78 @@ export default class Order extends Base { const tracks = Order.getTracks(sources); - function getOrderKey(obj, path) { - return path.split(".").reduce((o, i) => { - let indexMatch; - if ((indexMatch = i.match(/^(\w+)\[(\d+)\]$/))) { - 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); - throw new Error(`Failed to access property '${i}' on object`); - } - } else { - if (!o?.hasOwnProperty(i)) { - log.error(`Failed to access property '${i}' on object:`, o); - throw new Error(`Failed to access property '${i}' on object`); - } - return o[i]; - } - }, obj); - } - - if (Array.isArray(tracks)) { + if (_.isArray(tracks)) { log.info("Sorting by", [params.sortKey, params.sortOrder]); const sortKey = params.sortKey || "popularity"; const sortOrder = params.sortOrder === "asc" ? "asc" : "desc"; - 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; - }); + + const sortedTracks = _.orderBy( + tracks, + [(track) => _.get(track, sortKey)], + [sortOrder], + ); + return sortedTracks; } return []; } - static shuffle(_spClient: SpotifyWebApi, sources: any[], _params: {}) { + static shuffle( + _spClient: SpotifyWebApi, + sources: any[], + params: { weight: number }, + ) { log.info("Shuffling..."); log.debug("Shuffle Sources:", sources); const tracks = Order.getTracks(sources); - if (Array.isArray(tracks)) { - return tracks.sort(() => Math.random() - 0.5); + if (_.isArray(tracks)) { + const weight = params.weight || 0.5; // Default weight is 0.5 if not provided + return _.orderBy(tracks, () => Math.pow(Math.random(), weight)); } return []; } + + static reverse(_spClient: SpotifyWebApi, sources: any[], _params: {}) { + log.info("Reversing..."); + log.debug("Reverse Sources:", sources); + + const tracks = Order.getTracks(sources); + + if (_.isArray(tracks)) { + return tracks.reverse(); + } + return []; + } + + static separateArtists( + _spClient: SpotifyWebApi, + sources: any[], + _params: {}, + ) { + log.info("Separating Artists..."); + log.debug("Separate Artists Sources:", sources); + + const tracks = Order.getTracks(sources); + + const groupedTracks = _.groupBy(tracks, (track) => track.artists[0]!.id); + + const sortedGroups = _.orderBy(_.values(groupedTracks), "length", "desc"); + + const interleavedTracks: (SpotifyApi.TrackObjectFull | undefined)[] = []; + while ( + _.some( + sortedGroups, + (group: SpotifyApi.TrackObjectFull[]) => group.length > 0, + ) + ) { + _.forEach(sortedGroups, (group: SpotifyApi.TrackObjectFull[]) => { + if (group.length > 0) { + interleavedTracks.push(group.shift()); + } + }); + } + return interleavedTracks; + } } diff --git a/src/lib/workflow/Workflow.ts b/src/lib/workflow/Workflow.ts index 2d0a1ac..13dddef 100644 --- a/src/lib/workflow/Workflow.ts +++ b/src/lib/workflow/Workflow.ts @@ -28,8 +28,12 @@ export const operationParamsTypesMap = { "Filter.limit": { limit: { type: "number", required: true }, }, + "Filter.excludeTracks": { + operationId: { type: "string", required: true }, + }, "Combiner.push": {}, "Combiner.alternate": {}, + "Combiner.randomStream": {}, "Utility.save": {}, "Utility.removeKeys": { keys: { type: "string[]", required: true }, @@ -43,6 +47,8 @@ export const operationParamsTypesMap = { sortOrder: { type: "string", required: true }, }, "Order.shuffle": {}, + "Order.reverse": {}, + "Order.separateArtists": {}, "Library.saveAsNew": { name: { type: "string", required: true }, isPublic: { type: "boolean" }, @@ -60,23 +66,20 @@ export const operationParamsTypesMap = { "Library.saveAsReplace": { playlistId: { type: "string", required: true }, }, - "Playlist.getTracksRecomendation": { - limit: { type: "number", required: true }, - market: { type: "string" }, - seedTracks: { type: "string[]" }, - seedArtists: { type: "string[]" }, - seedGenres: { type: "string[]" }, - minAcousticness: { type: "number" }, - maxAcousticness: { type: "number" }, - targetAcousticness: { type: "number" }, - minDanceability: { type: "number" }, - maxDanceability: { type: "number" }, - targetDanceability: { type: "number" }, - }, "Library.likedTracks": { limit: { type: "number" }, offset: { type: "number" }, }, + "Library.albumTracks": { + albumId: { type: "string", required: true }, + limit: { type: "number" }, + offset: { type: "number" }, + }, + "Library.artistTopTracks": { + artistId: { type: "string", required: true }, + limit: { type: "number" }, + offset: { type: "number" }, + }, "Selector.first": { count: { type: "number" }, }, @@ -214,6 +217,7 @@ export class Runner extends Base { } log.info(`${operation.type} completed`); } + this.operationValues.set(operation.id, result); return result; }