Skip to content

Commit

Permalink
chore: initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
dirkluijk committed Jan 3, 2025
1 parent 8f92a71 commit a76a26c
Show file tree
Hide file tree
Showing 8 changed files with 343 additions and 0 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.idea
*.log
*.txt
20 changes: 20 additions & 0 deletions deno.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
83 changes: 83 additions & 0 deletions deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

89 changes: 89 additions & 0 deletions main.ts
Original file line number Diff line number Diff line change
@@ -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 <playlistUrl>",
"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("<input>")
.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()}`,
);
9 changes: 9 additions & 0 deletions src/utils/open.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/**
* Opens a URL in the users' browser.
*/
export async function open(uri: string): Promise<void> {
const platform = Deno.build.os;
const start = platform === "darwin" ? "open" : platform === "windows" ? "start" : "xdg-open";

await new Deno.Command(start, { args: [uri] }).spawn().output();
}
24 changes: 24 additions & 0 deletions src/utils/pkce.ts
Original file line number Diff line number Diff line change
@@ -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<ArrayBuffer> {
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];
}
70 changes: 70 additions & 0 deletions src/utils/spotify-authorization.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
// wait for user to have accepted
// opens web server to accept the callback
return new Promise<string>((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<string> {
// 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;
}
45 changes: 45 additions & 0 deletions src/utils/spotify.ts
Original file line number Diff line number Diff line change
@@ -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}`;
}
}

0 comments on commit a76a26c

Please sign in to comment.