From a76a26c8cd618c289523d70347a14eb7cc44efa2 Mon Sep 17 00:00:00 2001 From: dirkluijk Date: Fri, 3 Jan 2025 14:55:12 +0100 Subject: [PATCH] chore: initial commit --- .gitignore | 3 + deno.json | 20 +++++++ deno.lock | 83 ++++++++++++++++++++++++++++ main.ts | 89 ++++++++++++++++++++++++++++++ src/utils/open.ts | 9 +++ src/utils/pkce.ts | 24 ++++++++ src/utils/spotify-authorization.ts | 70 +++++++++++++++++++++++ src/utils/spotify.ts | 45 +++++++++++++++ 8 files changed, 343 insertions(+) create mode 100644 .gitignore create mode 100644 deno.json create mode 100644 deno.lock create mode 100644 main.ts create mode 100644 src/utils/open.ts create mode 100644 src/utils/pkce.ts create mode 100644 src/utils/spotify-authorization.ts create mode 100644 src/utils/spotify.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..69a7ded --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.idea +*.log +*.txt diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..9777c35 --- /dev/null +++ b/deno.json @@ -0,0 +1,20 @@ +{ + "name": "@dirkluijk/text-to-playlist", + "version": "0.1.0", + "license": "MIT", + "exports": "./main.ts", + "tasks": { + "dev": "deno run --watch main.ts" + }, + "fmt": { + "lineWidth": 120 + }, + "imports": { + "@cliffy/command": "jsr:@cliffy/command@^1.0.0-rc.5", + "@soundify/web-api": "jsr:@soundify/web-api@^1.1.5", + "@std/collections": "jsr:@std/collections@^1.0.9", + "@std/encoding": "jsr:@std/encoding@^1.0.6", + "@std/fs": "jsr:@std/fs@^1.0.8", + "wretch": "npm:wretch@^2.11.0" + } +} diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..513a130 --- /dev/null +++ b/deno.lock @@ -0,0 +1,83 @@ +{ + "version": "4", + "specifiers": { + "jsr:@cliffy/command@^1.0.0-rc.5": "1.0.0-rc.7", + "jsr:@cliffy/flags@1.0.0-rc.7": "1.0.0-rc.7", + "jsr:@cliffy/internal@1.0.0-rc.7": "1.0.0-rc.7", + "jsr:@cliffy/table@1.0.0-rc.7": "1.0.0-rc.7", + "jsr:@soundify/web-api@^1.1.5": "1.1.5", + "jsr:@std/collections@^1.0.9": "1.0.9", + "jsr:@std/encoding@^1.0.6": "1.0.6", + "jsr:@std/fmt@~1.0.2": "1.0.3", + "jsr:@std/fs@^1.0.8": "1.0.8", + "jsr:@std/path@^1.0.8": "1.0.8", + "jsr:@std/text@~1.0.7": "1.0.9", + "npm:wretch@^2.11.0": "2.11.0" + }, + "jsr": { + "@cliffy/command@1.0.0-rc.7": { + "integrity": "1288808d7a3cd18b86c24c2f920e47a6d954b7e23cadc35c8cbd78f8be41f0cd", + "dependencies": [ + "jsr:@cliffy/flags", + "jsr:@cliffy/internal", + "jsr:@cliffy/table", + "jsr:@std/fmt", + "jsr:@std/text" + ] + }, + "@cliffy/flags@1.0.0-rc.7": { + "integrity": "318d9be98f6a6417b108e03dec427dea96cdd41a15beb21d2554ae6da450a781", + "dependencies": [ + "jsr:@std/text" + ] + }, + "@cliffy/internal@1.0.0-rc.7": { + "integrity": "10412636ab3e67517d448be9eaab1b70c88eba9be22617b5d146257a11cc9b17" + }, + "@cliffy/table@1.0.0-rc.7": { + "integrity": "9fdd9776eda28a0b397981c400eeb1aa36da2371b43eefe12e6ff555290e3180", + "dependencies": [ + "jsr:@std/fmt" + ] + }, + "@soundify/web-api@1.1.5": { + "integrity": "4916a2cdad9a6a25003524a9389230e157ff2c21c6954f3fa8b9f28436fd6835" + }, + "@std/collections@1.0.9": { + "integrity": "4f58104ead08a04a2199374247f07befe50ba01d9cca8cbb23ab9a0419921e71" + }, + "@std/encoding@1.0.6": { + "integrity": "ca87122c196e8831737d9547acf001766618e78cd8c33920776c7f5885546069" + }, + "@std/fmt@1.0.3": { + "integrity": "97765c16aa32245ff4e2204ecf7d8562496a3cb8592340a80e7e554e0bb9149f" + }, + "@std/fs@1.0.8": { + "integrity": "161c721b6f9400b8100a851b6f4061431c538b204bb76c501d02c508995cffe0", + "dependencies": [ + "jsr:@std/path" + ] + }, + "@std/path@1.0.8": { + "integrity": "548fa456bb6a04d3c1a1e7477986b6cffbce95102d0bb447c67c4ee70e0364be" + }, + "@std/text@1.0.9": { + "integrity": "b2db4abc3e6ab93eb0280a0d5e126fd3fca80955bcc297ccaaf555e995eab747" + } + }, + "npm": { + "wretch@2.11.0": { + "integrity": "sha512-zbFW4PnPSS5RFQabHRkCAcMQjmFGrEQLcJwYe4YGXL9KWXneu03Dtaz2JUeqEkOwNDMjdfHEHPL+AIZJwE+y1g==" + } + }, + "workspace": { + "dependencies": [ + "jsr:@cliffy/command@^1.0.0-rc.5", + "jsr:@soundify/web-api@^1.1.5", + "jsr:@std/collections@^1.0.9", + "jsr:@std/encoding@^1.0.6", + "jsr:@std/fs@^1.0.8", + "npm:wretch@^2.11.0" + ] + } +} diff --git a/main.ts b/main.ts new file mode 100644 index 0000000..77e6279 --- /dev/null +++ b/main.ts @@ -0,0 +1,89 @@ +import { exists } from "@std/fs/exists"; +import { chunk, withoutAll } from "@std/collections"; +import { PageIterator } from "@soundify/web-api/pagination"; +import { addItemsToPlaylist, getPlaylistTracks, removePlaylistItems, SpotifyClient } from "@soundify/web-api"; +import { Command } from "@cliffy/command"; +import { Playlist, Track } from "./src/utils/spotify.ts"; +import { retrieveAccessToken } from "./src/utils/spotify-authorization.ts"; + +const SPOTIFY_TRACK_PATTERN = /https:\/\/open\.spotify\.com\/track\/\w+/g; + +const { args, options } = await new Command() + .name("text-to-playlist") + .version("0.1.0") + .description( + "Adds all Spotify tracks in the given text to a Spotify Playlist", + ) + .option( + "-P, --playlist ", + "The Spotify Playlist URL to add to tracks to", + { required: true }, + ) + .option('-D, --debug', 'Outputs debugging logs') + .option( + "--remove-duplicates [flag:boolean]", + "Whether to filter out duplicates from input", + { default: true }, + ) + .option( + "--remove-other-tracks [flag:boolean]", + "Whether to remove tracks from playlist that do not exit in input", + { default: true }, + ) + .arguments("") + .parse(Deno.args); + +const input = await exists(args[0]) ? await Deno.readTextFile(args[0]) : args[0]; +const playlist = Playlist.fromUrl(options.playlist); +const { removeDuplicates, removeOtherTracks } = options; + +const accessToken = await retrieveAccessToken(); +const spotifyClient = new SpotifyClient(accessToken); + +// Read input and extract Spotify track links +const trackUrls = removeDuplicates + ? Array.from( + new Set(input.match(SPOTIFY_TRACK_PATTERN) ?? []), + ) + : input.match(SPOTIFY_TRACK_PATTERN) ?? []; + +const tracks = trackUrls.map(Track.fromUrl); + +if (options.debug) { + console.debug('Found tracks:'); + console.table(tracks.map((it) => it.toUrl())) +} + +const currentTracks = await new PageIterator( + (offset) => getPlaylistTracks(spotifyClient, playlist.id, { limit: 50, offset }), +).collect().then((tracks) => tracks.map(({ track }) => new Track(track.id))); + +// add everything that is in `tracks` but not in `currentTracks` +const trackUrisToAdd = withoutAll( + tracks.map((it) => it.toUri()), + currentTracks.map((it) => it.toUri()), +); + +for (const batch of chunk(trackUrisToAdd, 50)) { + await addItemsToPlaylist(spotifyClient, playlist.id, batch); +} + +console.log( + `Added ${trackUrisToAdd.length} tracks to playlist: ${playlist.toUrl()}`, +); + +// delete everything that is in `currentTrackURIs` but not in `trackURIs` +const trackURIsToRemove = removeOtherTracks + ? withoutAll( + currentTracks.map((it) => it.toUri()), + tracks.map((it) => it.toUri()), + ) + : []; + +for (const batch of chunk(trackURIsToRemove, 50)) { + await removePlaylistItems(spotifyClient, playlist.id, batch); +} + +console.log( + `Removed ${trackURIsToRemove.length} tracks from playlist: ${playlist.toUrl()}`, +); diff --git a/src/utils/open.ts b/src/utils/open.ts new file mode 100644 index 0000000..9165d3b --- /dev/null +++ b/src/utils/open.ts @@ -0,0 +1,9 @@ +/** + * Opens a URL in the users' browser. + */ +export async function open(uri: string): Promise { + const platform = Deno.build.os; + const start = platform === "darwin" ? "open" : platform === "windows" ? "start" : "xdg-open"; + + await new Deno.Command(start, { args: [uri] }).spawn().output(); +} diff --git a/src/utils/pkce.ts b/src/utils/pkce.ts new file mode 100644 index 0000000..3eeb475 --- /dev/null +++ b/src/utils/pkce.ts @@ -0,0 +1,24 @@ +import { encodeBase64Url } from "@std/encoding/base64url"; + +function randomString(options: { length: number }) { + const possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + const values = crypto.getRandomValues(new Uint8Array(options.length)); + return values.reduce((acc, x) => acc + possible[x % possible.length], ""); +} + +function sha256(plain: string): Promise { + return crypto.subtle.digest("SHA-256", new TextEncoder().encode(plain)); +} + +/** + * Creates a PKCE code challenge. + * + * @see https://blog.postman.com/what-is-pkce/ + */ +export async function createPkceChallenge(): Promise<[verifier: string, challenge: string]> { + const codeVerifier = randomString({ length: 64 }); + const hashed = await sha256(codeVerifier); + const codeChallenge = encodeBase64Url(hashed); + + return [codeVerifier, codeChallenge]; +} diff --git a/src/utils/spotify-authorization.ts b/src/utils/spotify-authorization.ts new file mode 100644 index 0000000..a50e123 --- /dev/null +++ b/src/utils/spotify-authorization.ts @@ -0,0 +1,70 @@ +import wretch from "wretch"; +import FormUrlAddon from "wretch/addons/formUrl"; +import { open } from "./open.ts"; +import { createPkceChallenge } from "./pkce.ts"; + +const CLIENT_ID = "e7ac817efe444b7bbdf70875114b1b0d"; +const REDIRECT_URI = "http://localhost:8888/callback"; + +const [codeVerifier, codeChallenge] = await createPkceChallenge(); + +function waitForAuthorization(options: { port: number }): Promise { + // wait for user to have accepted + // opens web server to accept the callback + return new Promise((resolve, reject) => { + const abortController = new AbortController(); + const server = Deno.serve({ + handler: (request: Request) => { + const requestUrl = new URL(request.url); + const queryParams = new URLSearchParams(requestUrl.search); + const code = queryParams.get("code"); + + if (code === null) { + reject(new Error("Missing code in Spotify callback")); + return new Response("Error: missing code in Spotify callback.", { + status: 400, + }); + } + + setTimeout(() => abortController.abort()); + resolve(server.finished.then(() => code)); + + return new Response("You can now close this browser window."); + }, + port: options.port, + signal: abortController.signal, + onListen: () => {}, + }); + }); +} + +export async function retrieveAccessToken(): Promise { + // ask user authorization + await open( + "https://accounts.spotify.com/authorize?" + + new URLSearchParams({ + response_type: "code", + client_id: CLIENT_ID, + scope: "playlist-modify-private", + code_challenge_method: "S256", + code_challenge: codeChallenge, + redirect_uri: REDIRECT_URI, + }).toString(), + ); + + const code = await waitForAuthorization({ port: 8888 }); + + const { access_token } = await wretch("https://accounts.spotify.com/api/token") + .addon(FormUrlAddon) + .formUrl({ + client_id: CLIENT_ID, + grant_type: "authorization_code", + code: code, + redirect_uri: REDIRECT_URI, + code_verifier: codeVerifier, + }) + .post() + .json<{ access_token: string }>(); + + return access_token; +} diff --git a/src/utils/spotify.ts b/src/utils/spotify.ts new file mode 100644 index 0000000..417ae4c --- /dev/null +++ b/src/utils/spotify.ts @@ -0,0 +1,45 @@ +export class Track { + constructor(public id: string) {} + + static fromUrl(trackUrl: string) { + const pattern = new URLPattern("https://open.spotify.com/track/:id"); + const result = pattern.exec(trackUrl); + + if (!result || !result.pathname.groups.id) { + throw new Error("Invalid Spotify track URL"); + } + + return new Track(result.pathname.groups.id); + } + + public toUri(): string { + return `spotify:track:${this.id}`; + } + + public toUrl(): string { + return `https://open.spotify.com/track/${this.id}`; + } +} + +export class Playlist { + constructor(public id: string) {} + + static fromUrl(playlistUrl: string) { + const pattern = new URLPattern("https://open.spotify.com/playlist/:id"); + const result = pattern.exec(playlistUrl); + + if (!result || !result.pathname.groups.id) { + throw new Error("Invalid Spotify playlist URL"); + } + + return new Playlist(result.pathname.groups.id); + } + + public toUri(): string { + return `spotify:playlist:${this.id}`; + } + + public toUrl(): string { + return `https://open.spotify.com/playlist/${this.id}`; + } +}