Last active
          August 20, 2025 20:58 
        
      - 
      
- 
        Save onegentig/8c4289da9f9708b3341010a5b0a7ff2d to your computer and use it in GitHub Desktop. 
  
    
      This file contains hidden or 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
    
  
  
    
  | #!/usr/bin/env -S deno run --allow-env --allow-net | |
| /** | |
| * Spotify Album Popularity Fetcher | |
| * @file spotify-album-pop.ts | |
| * @author onegen (https://github.com/onegentig) | |
| * | |
| * A very simple script that searches for an album on Spotify and shows | |
| * the popularity of all its tracks. I wanted something like this and most | |
| * 3rd party tools provided it on the track level, which was annoying to me. | |
| * I am also not a Spotify user so it takes the search term not an ID. | |
| * | |
| * You need a Spotify API key to use this script. The script fetches | |
| * the stuff from env vars SPOTIFY_APP_ID and SPOTIFY_APP_SECRET. | |
| * @see https://developer.spotify.com/dashboard | |
| * | |
| * @note Script searches for the album and uses the first result only. | |
| * | |
| * Usage: ./spotify-album-pop.ts "Album Name" | |
| */ | |
| import { MultiProgressBar } from "jsr:@deno-library/progress"; // Bar display | |
| /* === Interfaces === */ | |
| interface ResultListing<T> { | |
| href: string; // endpoint link | |
| limit: number; | |
| next?: string; // next page link | |
| offset: number; | |
| previous?: string; // previous page link | |
| total: number; | |
| items: Array<T>; | |
| } | |
| interface SimplifiedArtist { | |
| external_urls: Record<string, string>; | |
| href: string; | |
| id: string; | |
| name: string; | |
| type: "artist"; | |
| uri: string; | |
| } | |
| interface Track { | |
| album: SimplifiedAlbum; | |
| artists: Array<SimplifiedArtist>; | |
| available_markets: Array<string>; | |
| disc_number: number; | |
| duration_ms: number; | |
| explicit: boolean; | |
| external_ids: Record<string, string>; | |
| external_urls: Record<string, string>; | |
| href: string; // endpoint link | |
| id: string; // Spotify ID | |
| is_playable: boolean; | |
| linked_from: unknown; // not important | |
| restrictions: { reason: "market" | "product" | "explicit"; }; | |
| name: string; | |
| popularity: number; // THE THING WE WANT | |
| track_number: number; | |
| type: "track"; | |
| uri: string; // Spotify URI | |
| is_local: boolean; | |
| } | |
| interface Album { | |
| album_type: "album" | "single" | "compilation"; | |
| total_tracks: number; | |
| available_markets: Array<string>; | |
| external_urls: Record<string, string>; | |
| href: string; // Spotify ID | |
| images: Array<unknown>; // not important | |
| name: string; | |
| release_date: string; | |
| release_date_precision: "year" | "month" | "day"; | |
| restrictions: { reason: "market" | "product" | "explicit"; }; | |
| type: "album"; | |
| artists: Array<SimplifiedArtist>; | |
| tracks: ResultListing<SimplifiedTrack>; | |
| copyrights: Array<unknown>; // Don’t care | |
| external_ids: Record<string, string>; | |
| genres: Array<string>; | |
| popularity: number; | |
| } | |
| interface SearchResult { | |
| tracks?: ResultListing<SimplifiedTrack>; | |
| artists?: ResultListing<SimplifiedArtist>; | |
| albums?: ResultListing<SimplifiedAlbum>; | |
| playlists?: ResultListing<unknown>; // not important | |
| shows?: ResultListing<unknown>; // not important | |
| episodes?: ResultListing<unknown>; // not important | |
| audiobooks?: ResultListing<unknown>; // not important | |
| } | |
| type SimplifiedTrack = Omit<Track, "album" | "external_ids" | "popularity" | "linked_from">; | |
| type SimplifiedAlbum = Omit<Album, "id" | "tracks" | "external_ids" | "genres" | "popularity"> & { | |
| id: string; | |
| }; | |
| type BarChartItem = { name: string; value: number; }; | |
| /* === Globals === */ | |
| let token: string | null = null; | |
| let tokenExpires: number = 0; | |
| /* === Helper Functions === */ | |
| async function getAccessToken(): Promise<string> { | |
| if (token && Date.now() < tokenExpires - 5000) | |
| return token; | |
| const id: string | undefined = Deno.env.get("SPOTIFY_APP_ID"); | |
| const secret: string | undefined = Deno.env.get("SPOTIFY_APP_SECRET"); | |
| if (!id || !secret) | |
| throw new Error("Spotify API credentials not found in environment variables."); | |
| const req = new URLSearchParams({ grant_type: "client_credentials" }); | |
| const res = await fetch("https://accounts.spotify.com/api/token", { | |
| method: "POST", | |
| headers: { | |
| "Authorization": `Basic ${btoa(`${id}:${secret}`)}`, | |
| "Content-Type": "application/x-www-form-urlencoded", | |
| }, | |
| body: req.toString(), | |
| }); | |
| if (!res.ok) | |
| throw new Error(`Failed to fetch access token: ${res.status} ${res.statusText}`); | |
| const data = await res.json(); | |
| token = data.access_token; | |
| tokenExpires = Date.now() + (data.expires_in * 1000); | |
| return token!; | |
| } | |
| async function searchAlbum(name: string): Promise<SimplifiedAlbum> { | |
| const bearer = await getAccessToken(); | |
| const req = new URLSearchParams({ | |
| q: name, | |
| type: "album", | |
| limit: "1" | |
| }); | |
| const res = await fetch(`https://api.spotify.com/v1/search?${req.toString()}`, { | |
| method: "GET", | |
| headers: { | |
| "Authorization": `Bearer ${bearer}`, | |
| } | |
| }); | |
| if (!res.ok) | |
| throw new Error(`Failed to search for album: ${res.status} ${res.statusText}`); | |
| const data: SearchResult = await res.json(); | |
| if (!data.albums || data.albums.items.length === 0) | |
| throw new Error("No albums found for the given name."); | |
| const album: SimplifiedAlbum = data.albums.items[0]; | |
| return album; | |
| } | |
| async function fetchAlbum(id: string): Promise<Album> { | |
| const bearer = await getAccessToken(); | |
| const res = await fetch(`https://api.spotify.com/v1/albums/${id}`, { | |
| method: "GET", | |
| headers: { | |
| "Authorization": `Bearer ${bearer}`, | |
| } | |
| }); | |
| if (!res.ok) | |
| throw new Error(`Failed to fetch album: ${res.status} ${res.statusText}`); | |
| const data: Album = await res.json(); | |
| return data; | |
| } | |
| async function fetchTracks(ids: Array<string>): Promise<Array<Track>> { | |
| if (ids.length === 0) | |
| return []; | |
| if (ids.length > 50) | |
| throw new Error("Too many tracks requested at once. Maximum is 50."); | |
| const bearer = await getAccessToken(); | |
| const req = new URLSearchParams({ | |
| ids: ids.join(","), | |
| }); | |
| const res = await fetch(`https://api.spotify.com/v1/tracks?${req.toString()}`, { | |
| method: "GET", | |
| headers: { | |
| "Authorization": `Bearer ${bearer}`, | |
| } | |
| }); | |
| if (!res.ok) | |
| throw new Error(`Failed to fetch tracks: ${res.status} ${res.statusText}`); | |
| const data: { tracks: Array<Track>; } = await res.json(); | |
| return data.tracks; | |
| } | |
| async function fetchAlbumTracks(album: Album): Promise<Array<Track>> { | |
| if (album.tracks.total === 0) | |
| return []; | |
| const ids: Array<string> = []; | |
| album.tracks.items.forEach(track => ids.push(track.id)); | |
| let next: string | null = album.tracks.next ?? null; | |
| while (next) { | |
| const res = await fetch(next, { | |
| method: "GET", | |
| headers: { | |
| "Authorization": `Bearer ${await getAccessToken()}` | |
| } | |
| }); | |
| if (!res.ok) | |
| throw new Error(`Failed to fetch more tracks: ${res.status} ${res.statusText}`); | |
| const data: ResultListing<SimplifiedTrack> = await res.json(); | |
| data.items.forEach(track => ids.push(track.id)); | |
| next = data.next ?? null; | |
| } | |
| const tracks: Array<Track> = []; | |
| do { | |
| const batch = ids.splice(0, 50); | |
| const fetchedTracks = await fetchTracks(batch); | |
| tracks.push(...fetchedTracks); | |
| } while (ids.length > 0); | |
| return tracks; | |
| } | |
| async function displayPopularityBars(trachs: Array<Track>): Promise<void> { | |
| const items: Array<BarChartItem> = trachs.map(track => ({ | |
| name: " " + track.name, | |
| value: track.popularity | |
| })); | |
| const cols = (Deno.consoleSize?.().columns ?? 80) - 5; | |
| const nameW = Math.min(32, Math.floor(cols * 0.40)); | |
| const barW = Math.max(10, cols - nameW - 6 - 3); | |
| const pad = (s: string) => s.padEnd(nameW).slice(0, nameW); | |
| const bars = new MultiProgressBar({ | |
| width: barW, | |
| display: ":text | :bar | :percent", | |
| complete: "█", incomplete: " " | |
| }); | |
| await bars.render(items.map(({ name, value }) => ({ | |
| completed: Math.max(0, Math.min(100, Math.round(value))), | |
| total: 100, | |
| text: `${pad(name)}` | |
| }))); | |
| await bars.end(); | |
| } | |
| /* === Main FN === */ | |
| async function main(albumName: string): Promise<void> { | |
| try { | |
| const album = await searchAlbum(albumName); | |
| const fullAlbum = await fetchAlbum(album.id); | |
| const tracks = await fetchAlbumTracks(fullAlbum); | |
| console.info(`Album: ${fullAlbum.name} by ${fullAlbum.artists.map(a => a.name).join(", ")}`); | |
| console.info(`Release Date: ${fullAlbum.release_date}`); | |
| console.info(`Total Tracks: ${fullAlbum.total_tracks}`); | |
| console.info(`Popularity: ${fullAlbum.popularity}`); | |
| /*console.info("Tracks:"); | |
| tracks.forEach(track => { | |
| console.info(` - ${track.name} (Popularity: ${track.popularity})`); | |
| });*/ | |
| await displayPopularityBars(tracks); | |
| } catch (error) { | |
| console.error(error); | |
| } | |
| } | |
| if (import.meta.main) { | |
| const args = Deno.args; | |
| if (args.length === 0) { | |
| console.error("Usage: ./spotify-album-pop.ts \"Album Name\""); | |
| Deno.exit(1); | |
| } | |
| const albumName = args.join(" "); | |
| await main(albumName); | |
| } | 
  
    Sign up for free
    to join this conversation on GitHub.
    Already have an account?
    Sign in to comment