diff --git a/app/api/auth/lastfm/callback/route.ts b/app/api/auth/lastfm/callback/route.ts new file mode 100644 index 0000000..ca8a6e3 --- /dev/null +++ b/app/api/auth/lastfm/callback/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { lastfmAdapter } from "@/lib/adapters/lastfm"; +import { upsertAccount } from "@/lib/db/repositories/accounts"; + +export async function GET(request: NextRequest) { + const url = new URL(request.url); + const token = url.searchParams.get("token"); + + if (!token) { + return NextResponse.redirect(new URL("/accounts?error=missing_token", request.url)); + } + + const tokens = await lastfmAdapter.exchangeCode(token, "", ""); + const profile = await lastfmAdapter.getAccountProfile(tokens); + upsertAccount("lastfm", profile, tokens); + + return NextResponse.redirect(new URL("/accounts?connected=lastfm", request.url)); +} diff --git a/app/api/auth/lastfm/start/route.ts b/app/api/auth/lastfm/start/route.ts new file mode 100644 index 0000000..9c410b9 --- /dev/null +++ b/app/api/auth/lastfm/start/route.ts @@ -0,0 +1,9 @@ +import { NextRequest, NextResponse } from "next/server"; +import { buildLastfmAuthUrl } from "@/lib/auth/lastfm-auth"; + +export async function GET(request: NextRequest) { + const callbackUrl = new URL("/api/auth/lastfm/callback", request.url); + callbackUrl.hostname = "127.0.0.1"; + + return NextResponse.redirect(buildLastfmAuthUrl(callbackUrl.toString())); +} diff --git a/lib/adapters/lastfm.ts b/lib/adapters/lastfm.ts new file mode 100644 index 0000000..0663ed2 --- /dev/null +++ b/lib/adapters/lastfm.ts @@ -0,0 +1,173 @@ +import { buildLastfmAuthUrl, exchangeLastfmToken } from "@/lib/auth/lastfm-auth"; +import { NotSupportedError, type MusicPlatformAdapter } from "@/lib/adapters/types"; +import { fetchWithRetry } from "@/lib/adapters/http-retry"; +import { computeCanonicalTrackId } from "@/lib/matching/identity"; +import type { AccountProfile, CanonicalTrack, TokenSet, TrackSearchQuery } from "@/lib/types/model"; + +const API_BASE = "https://ws.audioscrobbler.com/2.0/"; +const LOVED_TRACKS_PAGE_SIZE = 200; + +function apiKey(): string { + const key = process.env.LASTFM_API_KEY; + if (!key) throw new Error("LASTFM_API_KEY is not set"); + return key; +} + +async function lastfmGet(params: Record): Promise { + const url = new URL(API_BASE); + url.searchParams.set("api_key", apiKey()); + url.searchParams.set("format", "json"); + for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v); + + const res = await fetchWithRetry(() => fetch(url.toString())); + const text = await res.text(); + if (!res.ok) { + throw new Error(`Last.fm API error ${res.status} on ${params.method}: ${text}`); + } + + const data = JSON.parse(text); + if (data.error) { + throw new Error(`Last.fm API error ${data.error} on ${params.method}: ${data.message}`); + } + return data as T; +} + +interface LastfmTrack { + name: string; + artist: { name: string }; + mbid?: string; + url?: string; +} + +interface LovedTracksResponse { + lovedtracks: { + track: LastfmTrack[]; + "@attr": { page: string; totalPages: string }; + }; +} + +interface TrackSearchResponse { + results: { + trackmatches: { track: LastfmTrack[] }; + }; +} + +interface TrackInfoResponse { + track: LastfmTrack; +} + +function toCanonicalTrack(track: LastfmTrack): CanonicalTrack { + const artists = [track.artist.name]; + return { + canonicalId: computeCanonicalTrackId({ artists, title: track.name }), + title: track.name, + artists, + platformIds: { lastfm: track.mbid || track.url || `${track.artist.name}|${track.name}` }, + }; +} + +/** Used by the matching engine's Last.fm disambiguation hook — not part of the shared adapter interface. */ +export async function getLastfmTrackInfo( + artist: string, + title: string +): Promise<{ artists: string[]; title: string; mbid?: string } | null> { + try { + const data = await lastfmGet({ + method: "track.getInfo", + artist, + track: title, + autocorrect: "1", + }); + return { artists: [data.track.artist.name], title: data.track.name, mbid: data.track.mbid }; + } catch { + return null; + } +} + +export const lastfmAdapter: MusicPlatformAdapter = { + platform: "lastfm", + + getAuthUrl(redirectUri) { + return { url: buildLastfmAuthUrl(redirectUri), codeVerifier: "", state: "" }; + }, + + async exchangeCode(code) { + // Last.fm's flow has no PKCE/state — `code` here is the `token` query param + // the callback route reads directly from the redirect. + return exchangeLastfmToken(code); + }, + + async refreshToken(tokens) { + // Last.fm session keys don't expire; nothing to refresh. + return tokens; + }, + + async getAccountProfile(tokens: TokenSet): Promise { + const username = tokens.scope ?? "unknown"; + return { id: username, displayName: username }; + }, + + async *fetchLikedSongs(tokens: TokenSet): AsyncGenerator { + const username = tokens.scope ?? ""; + let page = 1; + let totalPages = 1; + + do { + const data = await lastfmGet({ + method: "user.getlovedtracks", + user: username, + limit: String(LOVED_TRACKS_PAGE_SIZE), + page: String(page), + }); + yield data.lovedtracks.track.map(toCanonicalTrack); + totalPages = Number(data.lovedtracks["@attr"].totalPages) || 1; + page++; + } while (page <= totalPages); + }, + + async *fetchPlaylists(): AsyncGenerator { + throw new NotSupportedError("lastfm", "fetchPlaylists (read-only source, no playlists)"); + }, + + async *fetchPlaylistTracks(): AsyncGenerator { + throw new NotSupportedError("lastfm", "fetchPlaylistTracks (read-only source, no playlists)"); + }, + + async *fetchFollowedArtists(): AsyncGenerator { + throw new NotSupportedError("lastfm", "fetchFollowedArtists (not applicable)"); + }, + + async *fetchSavedAlbums(): AsyncGenerator { + throw new NotSupportedError("lastfm", "fetchSavedAlbums (not applicable)"); + }, + + async searchTrack(_tokens, query: TrackSearchQuery): Promise { + const data = await lastfmGet({ + method: "track.search", + track: query.title, + artist: query.artist, + limit: "5", + }); + return data.results.trackmatches.track.map(toCanonicalTrack); + }, + + async addLikedSongs(): Promise { + throw new NotSupportedError("lastfm", "addLikedSongs (read-only source)"); + }, + + async createPlaylist(): Promise<{ id: string }> { + throw new NotSupportedError("lastfm", "createPlaylist (read-only source)"); + }, + + async addTracksToPlaylist(): Promise { + throw new NotSupportedError("lastfm", "addTracksToPlaylist (read-only source)"); + }, + + async followArtist(): Promise { + throw new NotSupportedError("lastfm", "followArtist (read-only source)"); + }, + + async saveAlbum(): Promise { + throw new NotSupportedError("lastfm", "saveAlbum (read-only source)"); + }, +}; diff --git a/lib/auth/lastfm-auth.ts b/lib/auth/lastfm-auth.ts new file mode 100644 index 0000000..4bbbb65 --- /dev/null +++ b/lib/auth/lastfm-auth.ts @@ -0,0 +1,68 @@ +import { createHash } from "node:crypto"; +import type { TokenSet } from "@/lib/types/model"; + +const AUTH_BASE = "https://www.last.fm/api/auth/"; +const API_BASE = "https://ws.audioscrobbler.com/2.0/"; + +function apiKey(): string { + const key = process.env.LASTFM_API_KEY; + if (!key) throw new Error("LASTFM_API_KEY is not set"); + return key; +} + +function apiSecret(): string { + const secret = process.env.LASTFM_API_SECRET; + if (!secret) throw new Error("LASTFM_API_SECRET is not set"); + return secret; +} + +/** Last.fm's API signing scheme: sort params (excluding format/callback), concat name+value, append secret, MD5. */ +function computeApiSig(params: Record, secret: string): string { + const base = + Object.keys(params) + .filter((k) => k !== "format" && k !== "callback") + .sort() + .map((k) => `${k}${params[k]}`) + .join("") + secret; + return createHash("md5").update(base, "utf8").digest("hex"); +} + +export function buildLastfmAuthUrl(callbackUrl: string): string { + const params = new URLSearchParams({ api_key: apiKey(), cb: callbackUrl }); + return `${AUTH_BASE}?${params.toString()}`; +} + +interface LastfmSessionResponse { + session: { name: string; key: string; subscriber: number }; +} + +const HUNDRED_YEARS_MS = 100 * 365 * 24 * 60 * 60 * 1000; + +/** + * Last.fm session keys don't expire, so we set a far-future expiresAt and + * repurpose TokenSet.scope to carry the username (Last.fm has no OAuth scopes) — + * getAccountProfile needs it and auth.getSession is the only place it's returned. + */ +export async function exchangeLastfmToken(token: string): Promise { + const params = { api_key: apiKey(), method: "auth.getSession", token }; + const api_sig = computeApiSig(params, apiSecret()); + + const body = new URLSearchParams({ ...params, api_sig, format: "json" }); + const res = await fetch(API_BASE, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body, + }); + + const text = await res.text(); + if (!res.ok) { + throw new Error(`Last.fm auth.getSession failed: ${res.status} ${text}`); + } + + const data = JSON.parse(text) as LastfmSessionResponse; + return { + accessToken: data.session.key, + expiresAt: Date.now() + HUNDRED_YEARS_MS, + scope: data.session.name, + }; +} diff --git a/lib/matching/lastfm-disambiguate.ts b/lib/matching/lastfm-disambiguate.ts new file mode 100644 index 0000000..f3f7e86 --- /dev/null +++ b/lib/matching/lastfm-disambiguate.ts @@ -0,0 +1,8 @@ +import { getLastfmTrackInfo } from "@/lib/adapters/lastfm"; +import type { LastfmDisambiguate } from "@/lib/matching/engine"; + +export const lastfmDisambiguate: LastfmDisambiguate = async (source) => { + const artist = source.artists[0]; + if (!artist) return null; + return getLastfmTrackInfo(artist, source.title); +};