beatprints.js
Version:
A Node.js version of the original Python BeatPrints project (https://github.com/TrueMyst/BeatPrints/) by TrueMyst. Create eye-catching, Pinterest-style music posters effortlessly. BeatPrints integrates with Spotify and LRClib API to help you design custom
186 lines (185 loc) • 7.36 kB
JavaScript
import { Client, Track, Album } from 'spotify-api.js';
import { InvalidSearchLimit, NoMatchingAlbumFound, NoMathcingTrackFound } from './errors.js';
export const SpotifyURI = /^spotify:(track|album):([0-9A-Za-z]{22})$/;
export const SpotifyURL = /^https:\/\/(open|play)\.spotify\.com(\/intl-\w{2})?\/(track|album)\/([0-9A-Za-z]{22})$/;
export const SpotifyID = /^([0-9A-Za-z]{22})$/;
/**
* A class for interacting with the Spotify API to search and retrieve track and alnum metadata.
*/
export class Spotify {
/**
* Initializes the Spotify client with credentials and obtains an access token.
* @param {string} CLIENT_ID Spotify web API client ID.
* @param {string} CLIENT_SECRET Spotify web API client secret.
*/
constructor(CLIENT_ID, CLIENT_SECRET) {
this.CLIENT_ID = CLIENT_ID;
this.CLIENT_SECRET = CLIENT_SECRET;
this.client = new Client({
refreshToken: true,
token: {
clientID: CLIENT_ID,
clientSecret: CLIENT_SECRET
}
});
}
/**
* Checks if the provided string is a valid Spotify ID, URI, or URL.
*
* A valid Spotify identifier can be one of the following formats:
* - Spotify URI: "spotify:{type}:{id}"
* - Spotify URL: "https://open.spotify.com/{type}/{id}"
* - Spotify ID: A 22-character Base62 string
*
* @param {string} id The string to be checked.
* @returns {boolean} True if the string is a valid Spotify identifier, false otherwise.
*/
isSpotifyID(id) {
// Remove query parameters (e.g. ?si=...)
const cleanId = id.split('?')[0];
// Spotify URI: spotify:album:id or spotify:track:id
if (SpotifyURI.test(cleanId)) {
return true;
}
if (SpotifyURL.test(cleanId)) {
return true;
}
// Spotify ID: 22-character Base62 string
if (SpotifyID.test(cleanId)) {
return true;
}
return false;
}
extractSpotifyID(input) {
// Remove query parameters
const cleanInput = input.split("?")[0];
// URI (spotify:track:id or spotify:album:id)
const uriMatch = cleanInput.match(SpotifyURI);
if (uriMatch) {
return { type: uriMatch[1], id: uriMatch[2] };
}
// URL (https://open.spotify.com/track/... or https://play.spotify.com/track/...)
const urlMatch = cleanInput.match(SpotifyURL);
if (urlMatch) {
return { type: urlMatch[3], id: urlMatch[4] };
}
// ID (22-char base62)
const rawIdMatch = cleanInput.match(SpotifyID);
if (rawIdMatch) {
return { type: null, id: rawIdMatch[1] };
}
return null;
}
async getTrack(query, limit = 8) {
try {
if (this.isSpotifyID(query)) {
const trackId = this.extractSpotifyID(query).id;
const track = await this.client.tracks.get(trackId);
return this.getTrackMetadata(track);
}
else {
if (limit < 1)
throw new InvalidSearchLimit();
const search = await this.client.tracks.search(query, { limit });
const metadata = await Promise.all(search.map(track => this.getTrackMetadata(track)));
if (!metadata.length)
throw new NoMathcingTrackFound();
return limit === 1 ? metadata[0] : metadata;
}
}
catch (err) {
if (err instanceof Error)
throw err;
throw new NoMathcingTrackFound();
}
}
async getAlbum(query, limit = 8, shuffle = false) {
try {
if (this.isSpotifyID(query)) {
const albumId = this.extractSpotifyID(query).id;
const album = await this.client.albums.get(albumId);
return this.getAlbumMetadata(album, shuffle);
}
else {
if (limit < 1)
throw new InvalidSearchLimit();
const search = await this.client.albums.search(query, { limit });
const metadata = await Promise.all(search.map(album => this.getAlbumMetadata(album, shuffle)));
if (!metadata.length)
throw new NoMatchingAlbumFound();
return limit === 1 ? metadata[0] : metadata;
}
}
catch (err) {
if (err instanceof Error)
throw err;
throw new NoMatchingAlbumFound();
}
}
/**
* Returns TrackMetadata from a Spotify track object.
* @param {Track} track Spotify track object.
*/
async getTrackMetadata(track) {
const album = (await this.client.albums.get(track.album.id));
const metadata = {
name: track.name,
artist: track.artists[0]?.name,
album: album.name,
released: this.formatReleased(track.album.releaseDate, track.album.releaseDatePrecision),
duration: this.formatDuration(track.duration),
image: track.album?.images[0]?.url,
label: album.label && album.label.length < 35 ? album.label : track.artists[0]?.name,
id: track.id
};
return metadata;
}
/**
* Returns AlbumMetadata from a Spotify album object.
* @param {Album} album Spotify album object
* @param {boolean} shuffle Whether to shuffle the tracks in the album
*/
async getAlbumMetadata(album, shuffle) {
const fetchedTracks = await this.client.albums.getTracks(album.id);
const tracks = fetchedTracks.map(t => t.name);
if (shuffle) {
tracks.sort(() => Math.random() - 0.5);
}
const metadata = {
name: album.name,
artist: album.artists[0]?.name,
released: this.formatReleased(album.releaseDate, album.releaseDatePrecision),
image: album.images[0]?.url,
label: album.label && album.label.length < 35 ? album.label : album.artists[0]?.name,
id: album.id,
tracks
};
return metadata;
}
formatReleased(releaseDate, precision) {
const date = new Date(releaseDate);
const year = date.getFullYear();
const month = date.getMonth() + 1;
const day = date.getDate();
switch (precision) {
case "day":
return `${year}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}`;
case "month":
return `${year}-${month.toString().padStart(2, '0')}`;
case "year":
return `${year}`;
default:
return `${year}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}`;
}
}
/**
* Formats the duration of a track from milliseconds to `mm:ss` format.
* @param {number} duration Duration of the track in milliseconds.
* @returns {string} Formatted duration in `mm:ss` format.
*/
formatDuration(duration) {
const minutes = Math.floor(duration / 60000);
const seconds = Math.floor((duration % 60000) / 1000);
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
}