diff --git a/.Dockerfile b/.Dockerfile deleted file mode 100644 index e69de29..0000000 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..72de5ce --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM node:23.6-bullseye-slim + +RUN apt-get update && apt-get install curl -y + +WORKDIR /opt/prod/app +COPY . . + +RUN npm install +EXPOSE 3000 +CMD [ "node", "dist/start.js" ] \ No newline at end of file diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..c396519 --- /dev/null +++ b/compose.yml @@ -0,0 +1,15 @@ +services: + api.jdf2.org: + build: + context: . + dockerfile: ./Dockerfile + volumes: + - storage:/opt/prod/storage/ + healthcheck: + test: + - CMD + - curl + - 'http://127.0.0.1:3000/getMostRecentSpotifyTrack' + interval: 2s + timeout: 10s + retries: 15 \ No newline at end of file diff --git a/consumer/ComicSans.ttf b/consumer/ComicSans.ttf new file mode 100644 index 0000000..831e3d8 Binary files /dev/null and b/consumer/ComicSans.ttf differ diff --git a/consumer/arrow.png b/consumer/arrow.png new file mode 100644 index 0000000..56c399c Binary files /dev/null and b/consumer/arrow.png differ diff --git a/consumer/background.png b/consumer/background.png new file mode 100644 index 0000000..3a9ea4d Binary files /dev/null and b/consumer/background.png differ diff --git a/dist/start.js b/dist/start.js new file mode 100644 index 0000000..776cbc4 --- /dev/null +++ b/dist/start.js @@ -0,0 +1,271 @@ +const express = require("express"); +const bodyParser = require("body-parser"); +const cors = require("cors"); +const https = require("https"); +const fs = require("fs/promises"); + +const agent = new https.Agent({ + rejectUnauthorized: false, +}); + +const app = express(); +app.use(cors()); +app.use(bodyParser.json()); + +const apiCache = { + _: {}, + get(key) { + if ( + this._[key] && + this._[key].timestamp + (this._[key].expire ?? 180000) > Date.now() + ) { + return this._[key].value; + } else { + return undefined; + } + }, + set(key, value, expire) { + this._[key] = { + timestamp: Date.now(), + expire: expire, + value: value, + }; + }, +}; + +app.get("/getMostRecentSpotifyTrack", async function (req, res) { + console.log(apiCache.get("getMostRecentSpotifyTrack")); + if (apiCache.get("getMostRecentSpotifyTrack")) { + res.send(JSON.stringify(apiCache.get("getMostRecentSpotifyTrack"))); + } else { + console.log("new") + const lastfmResponse = await fetch( + `http://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&user=jdf221&api_key=${process.env.LASTFM_API_KEY}&format=json` + ).then((res) => res.json()); + + const response = { + track: undefined, + artist: undefined, + }; + + const track = lastfmResponse?.["recenttracks"]?.["track"]?.[0]; + response.track = track?.name; + response.artist = track?.artist?.["#text"]; + console.log(response) + + apiCache.set("getMostRecentSpotifyTrack", response); + res.send(JSON.stringify(response)); + } +}); + +app.listen(3000); + +async function getSpotifyApi() { + return { + token: await fs + .readFile("/opt/prod/storage/spotifySeasons/tokenData.json") + .then((b) => JSON.parse(b.toString())), + application: { + id: process.env.SPOTIFY_API_ID, + secret: process.env.SPOTIFY_API_SECRET, + }, + + async sendRequest(url, method = "get", data, asJson = true) { + await new Promise((r) => setTimeout(r, 500)); + + if ( + url !== "https://accounts.spotify.com/api/token" && + Date.now() > this.token.expiresAfterTimestamp + ) { + await this.refreshToken(); + } + + return await fetch(url, { + method: method, + body: data + ? asJson + ? JSON.stringify(data) + : Object.keys(data) + .map( + (key) => + encodeURIComponent(key) + + "=" + + encodeURIComponent(data[key]) + ) + .join("&") + : undefined, + headers: { + "Content-Type": asJson + ? "application/json" + : "application/x-www-form-urlencoded;charset=UTF-8", + Authorization: + url == "https://accounts.spotify.com/api/token" + ? "Basic " + + new Buffer.from( + this.application.id + ":" + this.application.secret + ).toString("base64") + : "Bearer " + this.token.access_token, + }, + }).then((b) => b.json()); + }, + + async refreshToken() { + const response = await this.sendRequest( + "https://accounts.spotify.com/api/token", + "post", + { + grant_type: "refresh_token", + refresh_token: this.token.refresh_token, + }, + false + ); + + this.token = response; + + this.token.expiresAfterTimestamp = + Date.now() + this.token.expires_in * 1000; + this.token.refresh_token = process.env.SPOTIFY_API_REFRESH_TOKEN + + await fs.writeFile( + "/opt/prod/storage/spotifySeasons/tokenData.json", + JSON.stringify(this.token) + ); + }, + + async createPlaylist(name) { + return ( + await this.sendRequest( + "https://api.spotify.com/v1/users/jdf221/playlists", + "post", + { + name: name, + public: false, + } + ) + ).id; + }, + + async search(query) { + const queryObject = { q: query, type: ["track"] }; + const queryString = Object.keys(queryObject) + .map( + (key) => + encodeURIComponent(key) + "=" + encodeURIComponent(queryObject[key]) + ) + .join("&"); + return this.sendRequest( + "https://api.spotify.com/v1/search?" + queryString + ); + }, + + async addTracksToPlaylist(playlistId, tracksArray) { + return this.sendRequest( + `https://api.spotify.com/v1/playlists/${playlistId}/tracks`, + "post", + { uris: tracksArray }, + true + ); + }, + }; +} + +async function weeklySpotifyHandler() { + const spotifyApi = await getSpotifyApi(); + + const seasons = [ + "winter", // 0 Jan + "winter", // 1 February + "spring", // 2 March + "spring", // 3 April + "spring", // 4 May + "summer", // 5 June + "summer", // 6 July + "summer", // 7 August + "fall", // 8 September + "fall", // 9 October + "fall", // 10 November + "winter", // 11 December + ]; + const currentDate = new Date(); + currentDate.setDate(currentDate.getDate() - 7); + + const currentSeason = seasons[currentDate.getUTCMonth()]; + const currentPlaylistInternalId = `${currentSeason}-${currentDate.getUTCFullYear()}`; + + const createdPlaylists = await fs + .readFile("/opt/prod/storage/spotifySeasons/playlists.json") + .then((b) => JSON.parse(b.toString())); + + if (!createdPlaylists[currentPlaylistInternalId]) { + const newPlaylistId = await spotifyApi.createPlaylist( + currentDate.getUTCFullYear() + + " " + + (currentSeason[0].toUpperCase() + currentSeason.slice(1)) + ); + createdPlaylists[currentPlaylistInternalId] = { + spotifyPlaylistId: newPlaylistId, + tracks: [], + }; + } + + const playlistId = + createdPlaylists[currentPlaylistInternalId].spotifyPlaylistId; + + const topTracks = await fetch( + `https://ws.audioscrobbler.com/2.0/?method=user.getTopTracks&user=jdf221&period=7day&api_key=${process.env.LASTFM_API_KEY}&format=json` + ).then((b) => b.json()); + const filteredTopTracks = topTracks.toptracks.track.filter( + (t) => parseInt(t.playcount) > 2 + ); + + const newTracksToAdd = []; + for (const lastfmTrack of filteredTopTracks) { + const spotifySearch = await spotifyApi.search( + `track:${lastfmTrack.name} artist:${lastfmTrack.artist.name}` + ); + const tracksButExplicitFirst = spotifySearch.tracks.items.sort((a, b) => { + if (a.explicit && b.explicit) return 0; + if (a.explicit) return -1; + if (b.explicit) return 1; + }); + + for (const track of tracksButExplicitFirst) { + if ( + lastfmTrack.artist.name === track.artists[0].name && + lastfmTrack.name === track.name + ) { + if ( + !createdPlaylists[currentPlaylistInternalId].tracks.includes( + track.uri + ) + ) { + createdPlaylists[currentPlaylistInternalId].tracks.push(track.uri); + console.log(`Adding ${lastfmTrack.name}} to ${currentPlaylistInternalId}`) + newTracksToAdd.push(track.uri); + } + else { + console.log(`Skipping ${lastfmTrack.name}}, already added to ${currentPlaylistInternalId}`) + } + break; + } + } + } + + spotifyApi.addTracksToPlaylist(playlistId, newTracksToAdd); + + await fs.writeFile( + "/opt/prod/storage/spotifySeasons/playlists.json", + JSON.stringify(createdPlaylists) + ); +} + +setInterval(() => { + const currentDate = new Date(); + if ( + currentDate.getDay() === 0 && + currentDate.getUTCHours() === 0 && + currentDate.getUTCMinutes() < 29 + ) { + weeklySpotifyHandler(); + } +}, 1000 * 60 * 30); // Every 30 minutes \ No newline at end of file diff --git a/package.json b/package.json index abdc2a8..d408b0f 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,11 @@ { + "name": "api.jdf2.org", + "version": "1.0.0", + "main": "index.js", "dependencies": { - "express": "^4.21.2" - } + "body-parser": "^1.19.0", + "cors": "^2.8.5", + "express": "^4.17.1", + "form-data": "^4.0.0" + } }