From 13fb29389ebdf93ea03c0bb592a121fda108bd52 Mon Sep 17 00:00:00 2001 From: synzr Date: Wed, 3 Dec 2025 17:25:57 +0500 Subject: [PATCH] feat(api/music): initial implementation --- .env.example | 2 + package.json | 4 ++ pnpm-lock.yaml | 26 +++++++++ src/lib/server/clients/last-fm.ts | 87 +++++++++++++++++++++++++++++++ src/routes/api/music/+server.ts | 11 ++++ 5 files changed, 130 insertions(+) create mode 100644 .env.example create mode 100644 src/lib/server/clients/last-fm.ts create mode 100644 src/routes/api/music/+server.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..95a3ce8 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +LASTFM_API_KEY= +LASTFM_USER= diff --git a/package.json b/package.json index 04fa0d8..d507528 100644 --- a/package.json +++ b/package.json @@ -33,5 +33,9 @@ "typescript-eslint": "^8.47.0", "vite": "^7.2.2", "vite-plugin-devtools-json": "^1.0.0" + }, + "dependencies": { + "lastfm-ts-api": "^2.2.0", + "node-cache": "^5.1.2" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a146fb..3546fe7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,13 @@ settings: importers: .: + dependencies: + lastfm-ts-api: + specifier: ^2.2.0 + version: 2.2.0 + node-cache: + specifier: ^5.1.2 + version: 5.1.2 devDependencies: '@eslint/compat': specifier: ^1.4.0 @@ -679,6 +686,10 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + clone@2.1.2: + resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==} + engines: {node: '>=0.8'} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -917,6 +928,9 @@ packages: known-css-properties@0.37.0: resolution: {integrity: sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==} + lastfm-ts-api@2.2.0: + resolution: {integrity: sha512-RGceA84ImKtPtFKZaAE9FbTI9hchjxAwi3ZJAr5nlrfVjvSQvpoEFtamrgJY+JYY9PdJusHil8eki00ll4ILZg==} + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -1034,6 +1048,10 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + node-cache@5.1.2: + resolution: {integrity: sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg==} + engines: {node: '>= 8.0.0'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -1801,6 +1819,8 @@ snapshots: dependencies: clsx: 2.1.1 + clone@2.1.2: {} + clsx@2.1.1: {} color-convert@2.0.1: @@ -2047,6 +2067,8 @@ snapshots: known-css-properties@0.37.0: {} + lastfm-ts-api@2.2.0: {} + levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -2133,6 +2155,10 @@ snapshots: natural-compare@1.4.0: {} + node-cache@5.1.2: + dependencies: + clone: 2.1.2 + optionator@0.9.4: dependencies: deep-is: 0.1.4 diff --git a/src/lib/server/clients/last-fm.ts b/src/lib/server/clients/last-fm.ts new file mode 100644 index 0000000..e9257a6 --- /dev/null +++ b/src/lib/server/clients/last-fm.ts @@ -0,0 +1,87 @@ +import { LASTFM_API_KEY, LASTFM_USER } from "$env/static/private"; + +import { LastFMUser } from "lastfm-ts-api"; +import NodeCache from "node-cache"; + +/** + * API cache. + */ +const cache = new NodeCache({ + // NOTE: TTL is 1 minute + stdTTL: 60, +}); + +/** + * Last.fm API client. + */ +const client = new LastFMUser(LASTFM_API_KEY); + +/** + * Recent track. + */ +export interface RecentTrack { + /** + * Song title. + */ + title: string; + + /** + * Song artist. + */ + artist: string; + + /** + * Album title. + */ + album: string; + + /** + * Cover URL. + */ + cover: string; + + /** + * Song URL. + */ + url: string; +} + +async function _getRecentTrack(): Promise { + // NOTE: fetch the recent tracks + const { + recenttracks: { + track: tracks, + }, + } = await client.getRecentTracks({ + user: LASTFM_USER, + }); + + // NOTE: get the first track and return it + const track = tracks.shift()!; + return { + title: track.name, + artist: track.artist["#text"], + album: track.album["#text"], + + // NOTE: the most HQ cover is always last in an array + cover: track.image.pop()!["#text"], + url: track.url, + }; +} + +/** + * Get the most recent track from a listening history. + * @returns Recent track. + */ +export async function getRecentTrack(): Promise { + let result = cache.get("recentTrack"); + + if (result !== undefined) { + return result; + } + + result = await _getRecentTrack(); + cache.set("recentTrack", result); + + return result; +} diff --git a/src/routes/api/music/+server.ts b/src/routes/api/music/+server.ts new file mode 100644 index 0000000..d8235c5 --- /dev/null +++ b/src/routes/api/music/+server.ts @@ -0,0 +1,11 @@ +import { getRecentTrack } from '$lib/server/clients/last-fm'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async () => { + return Response.json( + await getRecentTrack(), + { + headers: { 'cache-control': 'maxage=60' }, + }, + ); +};