feat/fix: implement WebSockets for NowPlaying, better data fetching with addl. Last.fm fetch, docker build fix

This commit is contained in:
Aidan 2025-09-07 00:09:06 -04:00
parent 4cec7406c3
commit 7121ec926f
11 changed files with 514 additions and 175 deletions

View file

@ -21,13 +21,16 @@ WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN groupadd --system --gid 1001 nodejs
RUN useradd --system --uid 1001 nextjs
RUN groupadd --system --gid 999 nodejs
RUN useradd --system --uid 999 nextjs
COPY --from=builder /app/public ./build/public
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./build/
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./build/.next/static
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/server.ts ./
COPY --from=builder --chown=nextjs:nodejs /app/lib ./lib
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
USER nextjs
@ -36,4 +39,4 @@ EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "build/server.js"]
CMD ["bun", "run", "server.ts"]

View file

@ -17,9 +17,10 @@ Just create a `.env` file with the below variables, run `docker compose -d --bui
## Environment Variables
| Variable | Description |
|----------------------|-------------------------------------------------------------------------------------|
| `LISTENBRAINZ_TOKEN` | Get this from your ListenBrainz [user settings](https://listenbrainz.org/settings/) |
| Variable | Required? | Description |
|----------------------|-----------|-------------------------------------------------------------------------------------|
| `LISTENBRAINZ_TOKEN` | No | Get this from your ListenBrainz [user settings](https://listenbrainz.org/settings/) |
| `LASTFM_API_KEY` | Yes | Get this from your Last.fm [API account](https://www.last.fm/api/account/create) |
## MusicBrainz

View file

@ -1,13 +1,16 @@
import React from 'react'
import { Metadata } from 'next'
import Head from 'next/head'
import { Metadata, Viewport } from 'next'
import './globals.css'
import { GeistSans } from 'geist/font/sans'
import AnimatedTitle from '../components/AnimatedTitle'
import I18nProvider from '../components/I18nProvider'
export const metadata: Metadata = {
title: 'aidxn.cc',
description: "The Internet home of Aidan. Come on in!",
authors: [{ name: 'aidxn.cc' }],
robots: 'index, follow',
metadataBase: new URL('https://aidxn.cc'),
openGraph: {
type: "website",
url: "https://aidxn.cc",
@ -22,6 +25,32 @@ export const metadata: Metadata = {
},
],
},
icons: {
icon: [
{ url: '/favicon-16x16.png', sizes: '16x16', type: 'image/png' },
{ url: '/favicon-32x32.png', sizes: '32x32', type: 'image/png' },
{ url: '/favicon-96x96.png', sizes: '96x96', type: 'image/png' },
{ url: '/android-icon-192x192.png', sizes: '192x192', type: 'image/png' },
],
apple: [
{ url: '/apple-icon-57x57.png', sizes: '57x57' },
{ url: '/apple-icon-60x60.png', sizes: '60x60' },
{ url: '/apple-icon-72x72.png', sizes: '72x72' },
{ url: '/apple-icon-76x76.png', sizes: '76x76' },
{ url: '/apple-icon-114x114.png', sizes: '114x114' },
{ url: '/apple-icon-120x120.png', sizes: '120x120' },
{ url: '/apple-icon-144x144.png', sizes: '144x144' },
{ url: '/apple-icon-152x152.png', sizes: '152x152' },
{ url: '/apple-icon-180x180.png', sizes: '180x180' },
],
},
manifest: '/manifest.json',
}
export const viewport: Viewport = {
themeColor: '#111827',
width: 'device-width',
initialScale: 1,
}
export default function RootLayout({
@ -31,30 +60,6 @@ export default function RootLayout({
}) {
return (
<html lang="en" className="dark">
<Head>
<link rel="apple-touch-icon" sizes="57x57" href="/apple-icon-57x57.png" />
<link rel="apple-touch-icon" sizes="60x60" href="/apple-icon-60x60.png" />
<link rel="apple-touch-icon" sizes="72x72" href="/apple-icon-72x72.png" />
<link rel="apple-touch-icon" sizes="76x76" href="/apple-icon-76x76.png" />
<link rel="apple-touch-icon" sizes="114x114" href="/apple-icon-114x114.png" />
<link rel="apple-touch-icon" sizes="120x120" href="/apple-icon-120x120.png" />
<link rel="apple-touch-icon" sizes="144x144" href="/apple-icon-144x144.png" />
<link rel="apple-touch-icon" sizes="152x152" href="/apple-icon-152x152.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-icon-180x180.png" />
<link rel="icon" type="image/png" sizes="192x192" href="/android-icon-192x192.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="96x96" href="/favicon-96x96.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="manifest" href="/manifest.json" />
<meta name="msapplication-TileColor" content="#ffffff" />
<meta name="msapplication-TileImage" content="/ms-icon-144x144.png" />
<meta name="theme-color" content="#ffffff" />
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="index, follow" />
<meta name="language" content="English" />
<meta name="author" content="aidxn.cc" />
</Head>
<body className={`${GeistSans.className} bg-gray-900 text-gray-100`}>
<AnimatedTitle />
<I18nProvider>
@ -63,5 +68,4 @@ export default function RootLayout({
</body>
</html>
);
}
}

View file

@ -4,6 +4,7 @@ import Header from '@/components/Header'
import Footer from '@/components/Footer'
import Button from '@/components/objects/Button'
import LastPlayed from '@/components/widgets/NowPlaying'
import LiveIndicator from '@/components/widgets/LiveIndicator'
import Image from 'next/image'
@ -59,12 +60,7 @@ export default function Home() {
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 p-4">
<div className="relative border-2 border-gray-700 rounded-lg hover:border-gray-600 transition-colors duration-300 p-4">
<div className="absolute top-2 right-2">
<div className="flex items-center gap-1 bg-black bg-opacity-50 rounded-full px-2 py-1">
<div className="w-1 h-1 bg-red-400 rounded-full animate-pulse"></div>
<div className="text-white text-xs">
LIVE
</div>
</div>
<LiveIndicator />
</div>
<div className="flex justify-center items-center h-full">
<LastPlayed />

View file

@ -0,0 +1,36 @@
"use client"
import { useEffect, useState } from "react"
import { connectSocket } from "@/lib/socket"
const LiveIndicator = () => {
const [connected, setConnected] = useState(false)
useEffect(() => {
const socket = connectSocket()
socket.on('connect', () => {
setConnected(true)
})
socket.on('disconnect', () => {
setConnected(false)
})
return () => {
socket.off('connect')
socket.off('disconnect')
}
}, [])
return (
<div className="flex items-center gap-1 bg-black bg-opacity-50 rounded-full px-2 py-1">
<div className={`w-1 h-1 rounded-full ${connected ? "bg-red-400 animate-pulse" : "bg-gray-400"}`}></div>
<div className="text-white text-xs">
{connected ? "LIVE" : "Connecting..."}
</div>
</div>
)
}
export default LiveIndicator

View file

@ -1,8 +1,7 @@
"use client"
import type React from "react"
import { useEffect, useState, useCallback } from "react"
import Image from "next/image"
import { useEffect, useState } from "react"
import { Loader2, AlertCircle } from "lucide-react"
import { PiMusicNotesFill } from "react-icons/pi";
import { FaBluetoothB } from "react-icons/fa6";
@ -12,116 +11,79 @@ import { TbDiscOff } from "react-icons/tb"
import { Progress } from "@/components/ui/progress"
import Link from "@/components/objects/Link"
import ScrollTxt from "@/components/objects/MusicText"
import { connectSocket, disconnectSocket } from "@/lib/socket"
interface Track {
track_name: string
artist_name: string
interface LastFmResponse {
album?: {
image?: Array<{ size: string; '#text': string }>
}
track?: {
album?: {
image?: Array<{ size: string; '#text': string }>
}
}
}
interface NowPlayingData {
track_name?: string
artist_name?: string
release_name?: string
mbid?: string
coverArt?: string | null
lastFmData?: LastFmResponse
status: 'loading' | 'partial' | 'complete' | 'error'
message?: string
}
const NowPlaying: React.FC = () => {
const [track, setTrack] = useState<Track | null>(null)
const [coverArt, setCoverArt] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [albumArtLoading, setAlbumArtLoading] = useState(false)
const [loadingStatus, setLoadingStatus] = useState("Initializing")
const [error, setError] = useState<string | null>(null)
const [progress, setProgress] = useState(0)
const [steps, setSteps] = useState(0)
const [nowPlaying, setNowPlaying] = useState<NowPlayingData>({ status: 'loading' })
const [currentTime, setCurrentTime] = useState(new Date())
const [volume, setVolume] = useState(25)
const [screenOn, setScreenOn] = useState(true)
const updateProgress = useCallback((current: number, total: number, status: string) => {
setProgress(current)
setSteps(total)
setLoadingStatus(status)
console.log(`[${current}/${total}] ${status}`)
}, [])
const fetchAlbumArt = useCallback(async (artist: string, album?: string) => {
if (!album) {
updateProgress(0, 0, "No album found")
setCoverArt(null)
setAlbumArtLoading(false)
return
}
try {
setAlbumArtLoading(true)
updateProgress(2, 3, `Searching for album: ${artist} - ${album}`)
const response = await fetch(
`https://musicbrainz.org/ws/2/release/?query=artist:${encodeURIComponent(
artist
)}%20AND%20release:${encodeURIComponent(album)}&fmt=json`
)
if (!response.ok) {
updateProgress(0, 0, `Album art fetch error: ${response.status}`)
setError("Error fetching album art (see console for details)")
setAlbumArtLoading(false)
return
}
const data = await response.json()
if (data.releases && data.releases.length > 0) {
const mbid = data.releases[0].id
updateProgress(3, 3, "Fetching cover art...")
setTrack(prev => prev ? { ...prev, mbid: `${mbid || null}` } : { track_name: "", artist_name: "", release_name: undefined, mbid: `${mbid || null}` })
const coverArtResponse = await fetch(`https://coverartarchive.org/release/${mbid}/front`)
if (coverArtResponse.ok) {
setCoverArt(coverArtResponse.url)
setAlbumArtLoading(false)
} else {
updateProgress(0, 0, "Cover art not found")
setCoverArt(null)
setAlbumArtLoading(false)
}
} else {
updateProgress(0, 0, "No releases found")
setCoverArt(null)
setAlbumArtLoading(false)
}
} catch (error) {
updateProgress(0, 0, `Error: ${error}`)
setCoverArt(null)
setAlbumArtLoading(false)
}
}, [updateProgress])
const fetchNowPlaying = useCallback(async () => {
updateProgress(1, 3, "Fetching current listen...")
try {
const response = await fetch("https://api.listenbrainz.org/1/user/p0ntus/playing-now")
const data = await response.json()
if (data.payload.count > 0 && data.payload.listens[0].track_metadata) {
const trackMetadata = data.payload.listens[0].track_metadata
console.log("= TRACK METADATA =")
if (trackMetadata.track_name) { console.log("🎵", trackMetadata.track_name) }
if (trackMetadata.artist_name) { console.log("🎤", trackMetadata.artist_name) }
if (trackMetadata.release_name) { console.log("💿", trackMetadata.release_name) }
setTrack({
track_name: trackMetadata.track_name,
artist_name: trackMetadata.artist_name,
release_name: trackMetadata.release_name,
mbid: trackMetadata.mbid,
})
setLoading(false)
updateProgress(2, 3, "Finding album art...")
await fetchAlbumArt(trackMetadata.artist_name, trackMetadata.release_name)
} else {
updateProgress(0, 0, "No track playing")
setLoading(false)
}
} catch (error) {
updateProgress(0, 0, `Error: ${error}`)
setError("Error fetching now playing data")
setLoading(false)
}
}, [fetchAlbumArt, updateProgress])
const [progressSteps, setProgressSteps] = useState({ current: 0, total: 3 })
useEffect(() => {
fetchNowPlaying()
}, [fetchNowPlaying])
const socket = connectSocket()
socket.on('connect', () => {
console.log('Connected to server')
socket.emit('requestNowPlaying')
socket.emit('startAutoRefresh')
})
socket.on('disconnect', () => {
console.log('[i] Disconnected from server')
})
socket.on('nowPlaying', (data: NowPlayingData) => {
console.log('Received now playing data:', data)
setNowPlaying(prevState => ({
...prevState,
...data
}))
if (data.status === 'loading') {
setProgressSteps({ current: 1, total: 3 })
} else if (data.status === 'partial') {
setProgressSteps({ current: 2, total: 3 })
} else if (data.status === 'complete') {
setProgressSteps({ current: 3, total: 3 })
}
})
socket.on('connect_error', (error) => {
console.error('[!] Connection error:', error)
setNowPlaying({ status: 'error', message: 'Connection failed' })
})
return () => {
socket.off('connect')
socket.off('disconnect')
socket.off('nowPlaying')
socket.off('connect_error')
disconnectSocket()
}
}, [])
useEffect(() => {
const timer = setInterval(() => {
@ -139,28 +101,33 @@ const NowPlaying: React.FC = () => {
}
const renderScreenContent = () => {
if (loading) {
if (nowPlaying.status === 'loading') {
return (
<div className="flex flex-col items-center justify-center h-full">
<Loader2 className="animate-spin text-white mb-4" size={32} />
<div className="text-white text-xs text-center px-4">
<div className="mb-2">{loadingStatus}</div>
<Progress value={steps > 0 ? (progress * 100) / steps : 0} className="h-1" />
<div className="mb-2">{nowPlaying.message || 'Connecting...'}</div>
<Progress
value={progressSteps.total > 0 ? (progressSteps.current * 100) / progressSteps.total : 0}
className="h-1"
/>
</div>
</div>
)
}
if (error) {
if (nowPlaying.status === 'error') {
return (
<div className="flex flex-col items-center justify-center h-full">
<AlertCircle className="text-red-500 mb-4" size={32} />
<div className="text-red-500 text-xs text-center px-4">{error}</div>
<div className="text-red-500 text-xs text-center px-4">
{nowPlaying.message || 'Error loading data'}
</div>
</div>
)
}
if (!track) {
if (!nowPlaying.track_name) {
return (
<div className="flex flex-col items-center justify-center h-full">
<TbDiscOff className="text-gray-400 mb-4" size={32} />
@ -175,34 +142,33 @@ const NowPlaying: React.FC = () => {
}
// normal state
const currentTrack = track!;
return (
<>
<a
href={currentTrack.mbid ? `https://musicbrainz.org/release/${currentTrack.mbid}` : `https://listenbrainz.org/user/p0ntus`}
href={nowPlaying.mbid ? `https://musicbrainz.org/release/${nowPlaying.mbid}` : `https://listenbrainz.org/user/p0ntus`}
target="_blank"
rel="noopener noreferrer"
className="bg-gradient-to-b from-gray-700 to-gray-900 border-b border-gray-700 px-2 py-0 block" style={{background: 'linear-gradient(to bottom, #4b5563 0%, #374151 30%, #1f2937 70%, #111827 100%)'}}
>
<div className="text-center leading-none pb-1">
<ScrollTxt text={currentTrack.artist_name.toUpperCase()} type="artist" />
<ScrollTxt text={currentTrack.track_name} type="track" className="-mt-0.5" />
{currentTrack.release_name && <ScrollTxt text={currentTrack.release_name} type="release" className="-mt-1.5" />}
<ScrollTxt text={nowPlaying.artist_name?.toUpperCase() || ''} type="artist" />
<ScrollTxt text={nowPlaying.track_name || ''} type="track" className="-mt-0.5" />
{nowPlaying.release_name && <ScrollTxt text={nowPlaying.release_name} type="release" className="-mt-1.5" />}
</div>
</a>
{/* Album art */}
<div className="relative w-full aspect-square">
{albumArtLoading ? (
{nowPlaying.status === 'partial' && !nowPlaying.coverArt ? (
<div className="w-full h-full bg-gray-700 flex flex-col items-center justify-center">
<Loader2 className="animate-spin text-gray-400 mb-2" size={32} />
<div className="text-gray-400 text-xs">Fetching Album Art</div>
</div>
) : coverArt ? (
<Image
src={coverArt}
alt={currentTrack.track_name}
fill
style={{ objectFit: "cover" }}
) : nowPlaying.coverArt ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={nowPlaying.coverArt}
alt={nowPlaying.track_name || 'Album cover'}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full bg-gray-700 flex items-center justify-center">
@ -216,7 +182,7 @@ const NowPlaying: React.FC = () => {
return (
<div className="flex justify-center items-center">
<div className={`relative w-52 bg-[#D4C29A] rounded-xs border border-[#BFAF8A] z-10 ${track?.release_name ? "h-[24.25rem]" : "h-[23.6rem]"}`}>
<div className={`relative w-52 bg-[#D4C29A] rounded-xs border border-[#BFAF8A] z-10 ${nowPlaying.release_name ? "h-[24.25rem]" : "h-[23.6rem]"}`}>
{/* Volume buttons */}
<div className="absolute -left-[2.55px] top-8 rounded-l w-[1.75px] flex flex-col z-0">
<div className="h-8 bg-[#BFAF8A] border-b border-[#A09070] rounded-l cursor-pointer" onClick={() => setVolume(v => Math.min(100, v + 5))}></div> {/* up */}
@ -245,8 +211,8 @@ const NowPlaying: React.FC = () => {
<div className="w-full h-full bg-black"></div>
)}
{/* Player controls and seekbar */}
{screenOn && track && (
<div className={`bg-gradient-to-b from-gray-700 to-gray-900 ${track?.release_name ? "pb-3" : "pb-[12.5px]"} flex flex-col items-center`} style={{background: 'linear-gradient(to bottom, #4b5563 0%, #374151 30%, #1f2937 70%, #111827 100%)'}}>
{screenOn && nowPlaying.track_name && (
<div className={`bg-gradient-to-b from-gray-700 to-gray-900 ${nowPlaying.release_name ? "pb-3" : "pb-[12.5px]"} flex flex-col items-center`} style={{background: 'linear-gradient(to bottom, #4b5563 0%, #374151 30%, #1f2937 70%, #111827 100%)'}}>
<div className="flex justify-center items-center gap-0 px-2">
<button className="hover:drop-shadow-[0_0_8px_rgba(255,255,255,0.9)] hover:filter hover:brightness-110 transition-all duration-200 p-1 rounded-full overflow-hidden">
<svg width="38" height="34" viewBox="0 0 24 20" className="drop-shadow-sm">
@ -323,4 +289,4 @@ const NowPlaying: React.FC = () => {
)
}
export default NowPlaying
export default NowPlaying

263
lib/now-playing-server.ts Normal file
View file

@ -0,0 +1,263 @@
import { Server as SocketServer } from 'socket.io'
interface TrackMetadata {
track_name: string
artist_name: string
release_name?: string
mbid?: string
additional_info?: {
recording_mbid?: string
artist_mbids?: string[]
release_mbid?: string
}
}
interface LastFmImage {
size: string
'#text': string
}
interface LastFmAlbum {
image?: LastFmImage[]
}
interface LastFmTrack {
album?: LastFmAlbum
}
interface LastFmResponse {
album?: LastFmAlbum
track?: LastFmTrack
}
interface NowPlayingData {
track_name?: string
artist_name?: string
release_name?: string
mbid?: string
coverArt?: string | null
lastFmData?: LastFmResponse
status: 'loading' | 'partial' | 'complete' | 'error'
message?: string
}
export class NowPlayingService {
private readonly io: SocketServer
private readonly lastFmApiKey: string | undefined
constructor(io: SocketServer) {
this.io = io
this.lastFmApiKey = process.env.LASTFM_API_KEY
}
async fetchNowPlaying(socketId: string) {
const emit = (data: Partial<NowPlayingData>) => {
this.io.to(socketId).emit('nowPlaying', data)
}
try {
emit({ status: 'loading', message: 'Fetching from ListenBrainz...' })
const listenBrainzResponse = await fetch(
'https://api.listenbrainz.org/1/user/p0ntus/playing-now',
{
headers: process.env.LISTENBRAINZ_TOKEN
? { Authorization: `Token ${process.env.LISTENBRAINZ_TOKEN}` }
: {}
}
)
if (!listenBrainzResponse.ok) {
emit({
status: 'error',
message: `ListenBrainz error: ${listenBrainzResponse.status}`
})
return
}
const listenBrainzData = await listenBrainzResponse.json()
if (listenBrainzData.payload.count === 0) {
emit({
status: 'complete',
message: 'No track currently playing'
})
return
}
const trackMetadata: TrackMetadata = listenBrainzData.payload.listens[0].track_metadata
emit({
status: 'partial',
track_name: trackMetadata.track_name,
artist_name: trackMetadata.artist_name,
release_name: trackMetadata.release_name,
mbid: trackMetadata.additional_info?.release_mbid,
message: 'Fetching additional info...'
})
// Try to get data from Last.fm if MBID
let lastFmData: LastFmResponse | null = null
let lastFmCoverArt: string | null = null
if (this.lastFmApiKey) {
emit({ status: 'partial', message: 'Fetching from Last.fm...' })
const lastFmQueries = []
// Try with MBID if available
if (trackMetadata.additional_info?.recording_mbid) {
lastFmQueries.push(this.fetchLastFmByMbid(trackMetadata.additional_info.recording_mbid))
}
// Also try with track and artist name
lastFmQueries.push(this.fetchLastFmByTrack(
trackMetadata.artist_name,
trackMetadata.track_name
))
const lastFmResults = await Promise.allSettled(lastFmQueries)
for (const result of lastFmResults) {
if (result.status === 'fulfilled' && result.value) {
lastFmData = result.value
// Extract cover
if (lastFmData.album?.image) {
const images = lastFmData.album.image
const largeImage = images.find((img: LastFmImage) => img.size === 'extralarge') ||
images.find((img: LastFmImage) => img.size === 'large') ||
images[images.length - 1]
if (largeImage && largeImage['#text'] && largeImage['#text'].trim() !== '') {
lastFmCoverArt = largeImage['#text']
}
} else if (lastFmData.track?.album?.image) {
const images = lastFmData.track.album.image
const largeImage = images.find((img: LastFmImage) => img.size === 'extralarge') ||
images.find((img: LastFmImage) => img.size === 'large') ||
images[images.length - 1]
if (largeImage && largeImage['#text'] && largeImage['#text'].trim() !== '') {
lastFmCoverArt = largeImage['#text']
}
}
break
}
}
}
// Get album art
let finalCoverArt = lastFmCoverArt
if (!finalCoverArt) {
if (trackMetadata.additional_info?.release_mbid) {
emit({ status: 'partial', message: 'Fetching from Cover Art Archive...' })
try {
const coverArtResponse = await fetch(
`https://coverartarchive.org/release/${trackMetadata.additional_info.release_mbid}/front`
)
if (coverArtResponse.ok) {
finalCoverArt = coverArtResponse.url
}
} catch (error) {
console.log('[!] Cover Art Archive direct fetch failed:', error)
}
}
if (!finalCoverArt && trackMetadata.release_name && trackMetadata.artist_name) {
emit({ status: 'partial', message: 'Searching MusicBrainz for album art...' })
try {
const mbSearchResponse = await fetch(
`https://musicbrainz.org/ws/2/release/?query=artist:${encodeURIComponent(
trackMetadata.artist_name
)}%20AND%20release:${encodeURIComponent(trackMetadata.release_name)}&fmt=json&limit=1`
)
if (mbSearchResponse.ok) {
const mbData = await mbSearchResponse.json()
if (mbData.releases && mbData.releases.length > 0) {
const releaseMbid = mbData.releases[0].id
try {
const coverArtResponse = await fetch(
`https://coverartarchive.org/release/${releaseMbid}/front`
)
if (coverArtResponse.ok) {
finalCoverArt = coverArtResponse.url
}
} catch (error) {
console.log('[!] Cover Art Archive fallback fetch failed:', error)
}
}
}
} catch (error) {
console.log('[!] MusicBrainz search failed:', error)
}
}
}
emit({
status: 'complete',
track_name: trackMetadata.track_name,
artist_name: trackMetadata.artist_name,
release_name: trackMetadata.release_name,
mbid: trackMetadata.additional_info?.release_mbid || trackMetadata.mbid,
coverArt: finalCoverArt || null,
lastFmData: lastFmData || undefined,
message: 'Complete'
})
} catch (error) {
console.error('[!] Error in fetchNowPlaying:', error)
emit({
status: 'error',
message: error instanceof Error ? error.message : 'Unknown error occurred'
})
}
}
private async fetchLastFmByMbid(mbid: string): Promise<LastFmResponse | null> {
if (!this.lastFmApiKey) return null
try {
const response = await fetch(
`https://ws.audioscrobbler.com/2.0/?method=track.getInfoByMbid&mbid=${mbid}&api_key=${this.lastFmApiKey}&format=json`
)
if (response.ok) {
return await response.json() as LastFmResponse
}
} catch (error) {
console.log('[!] Last.fm MBID fetch failed:', error)
}
return null
}
private async fetchLastFmByTrack(artist: string, track: string): Promise<LastFmResponse | null> {
if (!this.lastFmApiKey) return null
try {
const params = new URLSearchParams({
method: 'track.getInfo',
api_key: this.lastFmApiKey,
artist: artist,
track: track,
format: 'json',
autocorrect: '1'
})
const response = await fetch(`https://ws.audioscrobbler.com/2.0/?${params}`)
if (response.ok) {
return await response.json() as LastFmResponse
}
} catch (error) {
console.log('[!] Last.fm track fetch failed:', error)
}
return null
}
}

28
lib/socket.ts Normal file
View file

@ -0,0 +1,28 @@
"use client"
import { io, Socket } from "socket.io-client"
let socket: Socket | null = null
export const getSocket = (): Socket => {
if (!socket) {
socket = io(undefined, {
autoConnect: false,
})
}
return socket
}
export const connectSocket = (): Socket => {
const s = getSocket()
if (!s.connected) {
s.connect()
}
return s
}
export const disconnectSocket = (): void => {
if (socket?.connected) {
socket.disconnect()
}
}

View file

@ -16,12 +16,6 @@ const nextConfig: NextConfig = {
port: '',
pathname: '/**',
},
{
protocol: 'https',
hostname: '*.archive.org',
port: '',
pathname: '/**',
},
],
dangerouslyAllowSVG: true,
},

View file

@ -3,9 +3,9 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"dev": "bun run server.ts",
"build": "next build",
"start": "next start",
"start": "NODE_ENV=production bun run server.ts",
"lint": "next lint"
},
"dependencies": {
@ -22,6 +22,8 @@
"react-i18next": "^15.7.3",
"react-icons": "^5.5.0",
"recharts": "^3.1.2",
"socket.io": "^4.8.1",
"socket.io-client": "^4.8.1",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
"tw-animate-css": "^1.3.8"
@ -29,13 +31,14 @@
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
"@tailwindcss/postcss": "^4.1.13",
"@types/node": "^20.19.13",
"@types/node": "^24.3.1",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
"eslint": "^9.35.0",
"eslint-config-next": "15.1.3",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.13",
"tsx": "^4.20.5",
"typescript": "^5.9.2"
},
"trustedDependencies": [

45
server.ts Normal file
View file

@ -0,0 +1,45 @@
import { createServer } from "node:http";
import next from "next";
import { Server } from "socket.io";
import { NowPlayingService } from "./lib/now-playing-server";
const dev = process.env.NODE_ENV !== "production";
const hostname = "localhost";
const port = parseInt(process.env.PORT || "3000", 10);
const app = next({ dev, hostname, port });
const handler = app.getRequestHandler();
app.prepare().then(() => {
const httpServer = createServer(handler);
const io = new Server(httpServer);
const nowPlayingService = new NowPlayingService(io);
io.on("connection", (socket) => {
console.log("[WS] Client connected:", socket.id);
socket.on("requestNowPlaying", async () => {
await nowPlayingService.fetchNowPlaying(socket.id);
});
socket.on("startAutoRefresh", () => {
const intervalId = setInterval(async () => {
await nowPlayingService.fetchNowPlaying(socket.id);
}, 30000);
socket.on("disconnect", () => {
clearInterval(intervalId);
console.log("[WS] Client disconnected:", socket.id);
});
});
socket.on("disconnect", () => {
console.log("[WS] Client disconnected:", socket.id);
});
});
httpServer.listen(port, () => {
console.log(`✓ Ready on http://${hostname}:${port}`);
});
});