-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
8 changed files
with
343 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
.idea | ||
*.log | ||
*.txt |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
} | ||
} |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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()}`, | ||
); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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]; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}`; | ||
} | ||
} |