diff --git a/app/api/music/musicbrainz/route.ts b/app/api/music/musicbrainz/route.ts
new file mode 100644
index 0000000..79b9c26
--- /dev/null
+++ b/app/api/music/musicbrainz/route.ts
@@ -0,0 +1,57 @@
+import { NextResponse } from 'next/server'
+
+interface MBRecording {
+ id: string
+ title: string
+ score: number
+ length?: number
+ 'artist-credit'?: { name: string }[]
+ releases?: { title: string; date?: string }[]
+}
+
+export async function GET(request: Request) {
+ const { searchParams } = new URL(request.url)
+ const q = searchParams.get('q')
+
+ if (!q || q.length < 2) {
+ return NextResponse.json({ results: [] })
+ }
+
+ try {
+ const controller = new AbortController()
+ const timeout = setTimeout(() => controller.abort(), 5000)
+
+ const res = await fetch(
+ `https://musicbrainz.org/ws/2/recording?query=${encodeURIComponent(q)}&fmt=json&limit=20`,
+ {
+ headers: {
+ 'User-Agent': 'SoulSync/1.0 (jeffemmett@gmail.com)',
+ Accept: 'application/json',
+ },
+ signal: controller.signal,
+ cache: 'no-store',
+ }
+ )
+ clearTimeout(timeout)
+
+ if (!res.ok) {
+ throw new Error(`MusicBrainz returned ${res.status}`)
+ }
+
+ const data = await res.json()
+ const results = (data.recordings || []).map((r: MBRecording) => ({
+ mbid: r.id,
+ title: r.title,
+ artist: r['artist-credit']?.[0]?.name || 'Unknown',
+ album: r.releases?.[0]?.title || '',
+ year: r.releases?.[0]?.date?.slice(0, 4) || '',
+ duration: r.length ? Math.round(r.length / 1000) : 0,
+ score: r.score,
+ }))
+
+ return NextResponse.json({ results })
+ } catch (error) {
+ console.error('MusicBrainz search error:', error)
+ return NextResponse.json({ error: 'MusicBrainz search failed' }, { status: 502 })
+ }
+}
diff --git a/app/api/music/offline/route.ts b/app/api/music/offline/route.ts
new file mode 100644
index 0000000..a14ff71
--- /dev/null
+++ b/app/api/music/offline/route.ts
@@ -0,0 +1,121 @@
+import { NextResponse } from 'next/server'
+import { navidromeGet } from '@/lib/navidrome'
+
+const OFFLINE_PLAYLIST_NAME = '__soulsync_offline__'
+
+interface SubsonicPlaylist {
+ id: string
+ name: string
+ songCount: number
+ coverArt: string
+}
+
+interface SubsonicSong {
+ id: string
+ title: string
+ artist: string
+ album: string
+ albumId: string
+ duration: number
+ coverArt: string
+}
+
+interface PlaylistsResult {
+ playlists?: { playlist?: SubsonicPlaylist[] }
+}
+
+interface PlaylistResult {
+ playlist?: {
+ id: string
+ name: string
+ songCount: number
+ entry?: SubsonicSong[]
+ }
+}
+
+/** Find or create the offline sync playlist, returning its id + songs */
+async function getOrCreateOfflinePlaylist() {
+ // Find existing
+ const data = await navidromeGet('getPlaylists.view')
+ const existing = (data.playlists?.playlist || []).find(
+ (p) => p.name === OFFLINE_PLAYLIST_NAME
+ )
+
+ if (existing) {
+ // Fetch full playlist with entries
+ const full = await navidromeGet('getPlaylist.view', { id: existing.id })
+ const songs = (full.playlist?.entry || []).map((s) => ({
+ id: s.id,
+ title: s.title,
+ artist: s.artist,
+ album: s.album,
+ albumId: s.albumId,
+ duration: s.duration,
+ coverArt: s.coverArt,
+ }))
+ return { id: existing.id, songs }
+ }
+
+ // Create it
+ await navidromeGet('createPlaylist.view', { name: OFFLINE_PLAYLIST_NAME })
+ // Re-fetch to get its id
+ const data2 = await navidromeGet('getPlaylists.view')
+ const created = (data2.playlists?.playlist || []).find(
+ (p) => p.name === OFFLINE_PLAYLIST_NAME
+ )
+ return { id: created?.id || '', songs: [] }
+}
+
+/** GET: return offline playlist id + songs */
+export async function GET() {
+ try {
+ const result = await getOrCreateOfflinePlaylist()
+ return NextResponse.json(result)
+ } catch (error) {
+ console.error('Offline playlist error:', error)
+ return NextResponse.json({ error: 'Failed to get offline playlist' }, { status: 502 })
+ }
+}
+
+/** POST: add a song to the offline playlist */
+export async function POST(request: Request) {
+ try {
+ const { songId } = await request.json()
+ if (!songId) {
+ return NextResponse.json({ error: 'songId required' }, { status: 400 })
+ }
+ const { id: playlistId } = await getOrCreateOfflinePlaylist()
+ await navidromeGet('updatePlaylist.view', {
+ playlistId,
+ songIdToAdd: songId,
+ })
+ return NextResponse.json({ success: true })
+ } catch (error) {
+ console.error('Add to offline playlist error:', error)
+ return NextResponse.json({ error: 'Failed to add song' }, { status: 502 })
+ }
+}
+
+/** DELETE: remove a song from the offline playlist by songId */
+export async function DELETE(request: Request) {
+ try {
+ const { songId } = await request.json()
+ if (!songId) {
+ return NextResponse.json({ error: 'songId required' }, { status: 400 })
+ }
+ const { id: playlistId, songs } = await getOrCreateOfflinePlaylist()
+ // Subsonic removeFromPlaylist uses songIndexToRemove (0-based index)
+ const index = songs.findIndex((s) => s.id === songId)
+ if (index === -1) {
+ return NextResponse.json({ error: 'Song not in offline playlist' }, { status: 404 })
+ }
+ await navidromeGet('updatePlaylist.view', {
+ playlistId,
+ songIndexToRemove: String(index),
+ })
+ return NextResponse.json({ success: true })
+ } catch (error) {
+ console.error('Remove from offline playlist error:', error)
+ return NextResponse.json({ error: 'Failed to remove song' }, { status: 502 })
+ }
+}
diff --git a/app/api/music/playlists/route.ts b/app/api/music/playlists/route.ts
index 7e1ff6f..3cc3844 100644
--- a/app/api/music/playlists/route.ts
+++ b/app/api/music/playlists/route.ts
@@ -18,13 +18,15 @@ interface PlaylistsResult {
export async function GET() {
try {
const data = await navidromeGet('getPlaylists.view')
- const playlists = (data.playlists?.playlist || []).map((p) => ({
- id: p.id,
- name: p.name,
- songCount: p.songCount,
- duration: p.duration,
- coverArt: p.coverArt,
- }))
+ const playlists = (data.playlists?.playlist || [])
+ .filter((p) => p.name !== '__soulsync_offline__')
+ .map((p) => ({
+ id: p.id,
+ name: p.name,
+ songCount: p.songCount,
+ duration: p.duration,
+ coverArt: p.coverArt,
+ }))
return NextResponse.json({ playlists })
} catch (error) {
console.error('Playlists error:', error)
diff --git a/app/api/music/search/route.ts b/app/api/music/search/route.ts
index 80ad979..3b13d10 100644
--- a/app/api/music/search/route.ts
+++ b/app/api/music/search/route.ts
@@ -12,6 +12,7 @@ interface SubsonicSong {
year: number
coverArt: string
suffix: string
+ bitRate: number
}
interface SearchResult {
@@ -31,12 +32,26 @@ export async function GET(request: Request) {
try {
const data = await navidromeGet('search3.view', {
query: q,
- songCount: '30',
+ songCount: '50',
albumCount: '0',
artistCount: '0',
})
- const songs = (data.searchResult3?.song || []).map((s) => ({
+ const rawSongs = data.searchResult3?.song || []
+
+ // Dedup by title+artist, keeping highest bitRate (then most recent year)
+ const seen = new Map()
+ for (const s of rawSongs) {
+ const key = `${s.title.toLowerCase().trim()}|||${s.artist.toLowerCase().trim()}`
+ const existing = seen.get(key)
+ if (!existing ||
+ (s.bitRate || 0) > (existing.bitRate || 0) ||
+ ((s.bitRate || 0) === (existing.bitRate || 0) && (s.year || 0) > (existing.year || 0))) {
+ seen.set(key, s)
+ }
+ }
+
+ const songs = Array.from(seen.values()).map((s) => ({
id: s.id,
title: s.title,
artist: s.artist,
diff --git a/app/api/music/slskd/auto-download/route.ts b/app/api/music/slskd/auto-download/route.ts
new file mode 100644
index 0000000..399fa9d
--- /dev/null
+++ b/app/api/music/slskd/auto-download/route.ts
@@ -0,0 +1,79 @@
+import { NextResponse } from 'next/server'
+import { slskdFetch } from '@/lib/slskd'
+import { extractBestFiles, type SlskdRawResponse } from '@/lib/slskd-dedup'
+
+function sleep(ms: number) {
+ return new Promise((resolve) => setTimeout(resolve, ms))
+}
+
+export async function POST(request: Request) {
+ try {
+ const { artist, title } = await request.json()
+ if (!artist || !title) {
+ return NextResponse.json({ error: 'artist and title required' }, { status: 400 })
+ }
+
+ const query = `${artist} ${title}`
+
+ // Start slskd search
+ const searchRes = await slskdFetch('/searches', {
+ method: 'POST',
+ body: JSON.stringify({ searchText: query }),
+ })
+
+ if (!searchRes.ok) {
+ throw new Error(`slskd search returned ${searchRes.status}`)
+ }
+
+ const { id: searchId } = await searchRes.json()
+
+ // Poll up to 15s (5 polls x 3s)
+ let bestFile = null
+ for (let i = 0; i < 5; i++) {
+ await sleep(3000)
+
+ const res = await slskdFetch(`/searches/${searchId}`)
+ if (!res.ok) continue
+
+ const data = await res.json()
+ const responses: SlskdRawResponse[] = (data.responses || [])
+ .filter((r: SlskdRawResponse) => r.files?.length > 0)
+
+ const files = extractBestFiles(responses, 1)
+ if (files.length > 0) {
+ bestFile = files[0]
+ if (data.state === 'Completed' || data.state === 'TimedOut') break
+ }
+ }
+
+ if (!bestFile) {
+ return NextResponse.json({ success: false, searchId, error: 'No results found' })
+ }
+
+ // Trigger download
+ const dlRes = await slskdFetch(
+ `/transfers/downloads/${encodeURIComponent(bestFile.bestPeer.username)}`,
+ {
+ method: 'POST',
+ body: JSON.stringify([{
+ filename: bestFile.filename,
+ size: bestFile.size,
+ }]),
+ }
+ )
+
+ if (!dlRes.ok) {
+ throw new Error(`slskd download returned ${dlRes.status}`)
+ }
+
+ return NextResponse.json({
+ success: true,
+ searchId,
+ filename: bestFile.displayName,
+ peer: bestFile.bestPeer.username,
+ })
+ } catch (error) {
+ console.error('Auto-download error:', error)
+ return NextResponse.json({ error: 'Auto-download failed' }, { status: 502 })
+ }
+}
diff --git a/app/api/music/slskd/results/[searchId]/route.ts b/app/api/music/slskd/results/[searchId]/route.ts
index 81c0e3b..a37cc2e 100644
--- a/app/api/music/slskd/results/[searchId]/route.ts
+++ b/app/api/music/slskd/results/[searchId]/route.ts
@@ -1,19 +1,6 @@
import { NextResponse } from 'next/server'
import { slskdFetch } from '@/lib/slskd'
-
-interface SlskdFile {
- filename: string
- size: number
- bitRate: number
- length: number
-}
-
-interface SlskdSearchResponse {
- username: string
- files: SlskdFile[]
- freeUploadSlots: number
- speed: number
-}
+import { extractBestFiles, type SlskdRawResponse } from '@/lib/slskd-dedup'
export async function GET(
_request: Request,
@@ -30,23 +17,12 @@ export async function GET(
const data = await res.json()
const isComplete = data.state === 'Completed' || data.state === 'TimedOut'
- // Flatten results: each response has username + files
- const results = (data.responses || [])
- .filter((r: SlskdSearchResponse) => r.files?.length > 0)
- .slice(0, 20)
- .map((r: SlskdSearchResponse) => ({
- username: r.username,
- freeSlots: r.freeUploadSlots,
- speed: r.speed,
- files: r.files.slice(0, 5).map((f: SlskdFile) => ({
- filename: f.filename,
- size: f.size,
- bitRate: f.bitRate,
- length: f.length,
- })),
- }))
+ const responses: SlskdRawResponse[] = (data.responses || [])
+ .filter((r: SlskdRawResponse) => r.files?.length > 0)
- return NextResponse.json({ results, isComplete })
+ const files = extractBestFiles(responses)
+
+ return NextResponse.json({ files, isComplete })
} catch (error) {
console.error('Soulseek results error:', error)
return NextResponse.json({ error: 'Failed to get results' }, { status: 502 })
diff --git a/app/layout.tsx b/app/layout.tsx
index 60c3fab..d93b55f 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -4,6 +4,8 @@ import { Geist, Geist_Mono, Fredoka, Permanent_Marker } from "next/font/google"
import { MusicProvider } from "@/components/music/music-provider"
import { MiniPlayer } from "@/components/music/mini-player"
import { UpdateBanner } from "@/components/update-banner"
+import { OfflineProvider } from "@/lib/stores/offline"
+import { ServiceWorkerRegister } from "@/components/sw-register"
import "./globals.css"
const _geist = Geist({ subsets: ["latin"] })
@@ -73,10 +75,13 @@ export default function RootLayout({
return (
+
- {children}
-
+
+ {children}
+
+
diff --git a/app/music/page.tsx b/app/music/page.tsx
index 1e42f6f..f4550ba 100644
--- a/app/music/page.tsx
+++ b/app/music/page.tsx
@@ -6,7 +6,8 @@ import { Badge } from '@/components/ui/badge'
import { JefflixLogo } from '@/components/jefflix-logo'
import { SongRow } from '@/components/music/search-results'
import { useMusicPlayer, type Track } from '@/components/music/music-provider'
-import { Search, Music, Download, Loader2, ArrowLeft, AlertCircle, ListMusic, ChevronRight, ChevronDown } from 'lucide-react'
+import { MusicBrainzResultsSection } from '@/components/music/musicbrainz-results'
+import { Search, Music, Download, Loader2, ArrowLeft, AlertCircle, ListMusic, ChevronRight, ChevronDown, WifiOff, Users } from 'lucide-react'
import Link from 'next/link'
interface Playlist {
@@ -16,16 +17,18 @@ interface Playlist {
coverArt: string
}
-interface SlskdResult {
- username: string
- freeSlots: number
- speed: number
- files: {
- filename: string
- size: number
- bitRate: number
- length: number
- }[]
+interface SlskdFile {
+ displayName: string
+ filename: string
+ size: number
+ bitRate: number
+ length: number
+ bestPeer: {
+ username: string
+ freeSlots: number
+ speed: number
+ }
+ peerCount: number
}
export default function MusicPage() {
@@ -47,7 +50,7 @@ export default function MusicPage() {
// Soulseek state
const [slskMode, setSlskMode] = useState(false)
const [slskSearchId, setSlskSearchId] = useState(null)
- const [slskResults, setSlskResults] = useState([])
+ const [slskResults, setSlskResults] = useState([])
const [slskSearching, setSlskSearching] = useState(false)
const [downloading, setDownloading] = useState(null)
const pollRef = useRef(null)
@@ -134,7 +137,7 @@ export default function MusicPage() {
try {
const res = await fetch(`/api/music/slskd/results/${searchId}`)
const d = await res.json()
- setSlskResults(d.results || [])
+ setSlskResults(d.files || [])
if (!d.isComplete) {
pollRef.current = setTimeout(poll, 2000)
} else {
@@ -147,14 +150,17 @@ export default function MusicPage() {
poll()
}
- const triggerDownload = async (username: string, files: SlskdResult['files']) => {
- const key = `${username}:${files[0]?.filename}`
+ const triggerDownload = async (file: SlskdFile) => {
+ const key = `${file.bestPeer.username}:${file.filename}`
setDownloading(key)
try {
await fetch('/api/music/slskd/download', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ username, files }),
+ body: JSON.stringify({
+ username: file.bestPeer.username,
+ files: [{ filename: file.filename, size: file.size }],
+ }),
})
} catch {}
setDownloading(null)
@@ -178,12 +184,20 @@ export default function MusicPage() {
-
-
-
+
+
+
+
+
+
+
+
@@ -244,7 +258,7 @@ export default function MusicPage() {
{!searching && songs.length > 0 && (
{songs.map((song, i) => (
-
+
))}
)}
@@ -269,6 +283,11 @@ export default function MusicPage() {
Type at least 2 characters to search
)}
+
+ {/* MusicBrainz Discovery */}
+ {debouncedQuery.length >= 2 && (
+
+ )}
>
)}
@@ -283,59 +302,51 @@ export default function MusicPage() {
)}
{slskResults.length > 0 && (
-
+
{slskSearching && (
-
+
Still searching...
)}
- {slskResults.map((result) => (
-
-
-
- {result.username}
-
- {result.freeSlots > 0 ? `${result.freeSlots} free slots` : 'No free slots'}
-
-
-
+
+ {slskResults.map((file) => {
+ const sizeMB = (file.size / 1024 / 1024).toFixed(1)
+ const key = `${file.bestPeer.username}:${file.filename}`
- {result.files.map((file) => {
- const name = file.filename.split('\\').pop() || file.filename
- const sizeMB = (file.size / 1024 / 1024).toFixed(1)
- const key = `${result.username}:${file.filename}`
-
- return (
-
-
-
{name}
-
- {sizeMB} MB
- {file.bitRate > 0 && ` · ${file.bitRate} kbps`}
-
+ return (
+
+
+
{file.displayName}
+
+ {sizeMB} MB
+ {file.bitRate > 0 && ` · ${file.bitRate} kbps`}
+ {' · '}{file.bestPeer.username}
-
- )
- })}
-
- ))}
+ {file.peerCount > 1 && (
+
+
+ {file.peerCount}
+
+ )}
+
+
+ )
+ })}
+
)}
@@ -409,7 +420,7 @@ export default function MusicPage() {
) : (
{playlistSongs.map((song, i) => (
-
+
))}
)}
diff --git a/app/offline/page.tsx b/app/offline/page.tsx
new file mode 100644
index 0000000..c8313c4
--- /dev/null
+++ b/app/offline/page.tsx
@@ -0,0 +1,171 @@
+'use client'
+
+import { useState } from 'react'
+import { Button } from '@/components/ui/button'
+import { JefflixLogo } from '@/components/jefflix-logo'
+import { SongRow } from '@/components/music/search-results'
+import { useMusicPlayer } from '@/components/music/music-provider'
+import { useOffline } from '@/lib/stores/offline'
+import {
+ ArrowLeft,
+ Download,
+ HardDrive,
+ Loader2,
+ Play,
+ RefreshCw,
+ Trash2,
+ WifiOff,
+} from 'lucide-react'
+import Link from 'next/link'
+
+function formatBytes(bytes: number) {
+ if (bytes === 0) return '0 B'
+ const k = 1024
+ const sizes = ['B', 'KB', 'MB', 'GB']
+ const i = Math.floor(Math.log(bytes) / Math.log(k))
+ return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
+}
+
+export default function OfflinePage() {
+ const { state, playTrack } = useMusicPlayer()
+ const {
+ offlineTracks,
+ queue,
+ activeDownloadId,
+ storageUsed,
+ clearAll,
+ sync,
+ loading,
+ } = useOffline()
+ const [syncing, setSyncing] = useState(false)
+ const [clearing, setClearing] = useState(false)
+ const hasPlayer = !!state.currentTrack
+
+ const handleSync = async () => {
+ setSyncing(true)
+ await sync()
+ setSyncing(false)
+ }
+
+ const handleClearAll = async () => {
+ if (!confirm('Remove all downloaded songs? They can be re-downloaded later.')) return
+ setClearing(true)
+ await clearAll()
+ setClearing(false)
+ }
+
+ const playAllOffline = () => {
+ if (offlineTracks.length > 0) {
+ playTrack(offlineTracks[0], offlineTracks, 0)
+ }
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Hero */}
+
+
+
+
+
Offline Library
+
+ Songs downloaded for offline playback. Syncs across all your devices.
+
+
+
+ {/* Stats + Actions */}
+
+
+
+ {formatBytes(storageUsed)} used
+
+
+
+ {offlineTracks.length} songs
+
+
+
+ {offlineTracks.length > 0 && (
+ <>
+
+
+ >
+ )}
+
+
+ {/* Download queue */}
+ {queue.length > 0 && (
+
+
+
+ Downloading ({queue.length} remaining)
+
+
+ {queue.slice(0, 5).map((t) => (
+
+ {t.id === activeDownloadId && }
+ {t.title} — {t.artist}
+
+ ))}
+ {queue.length > 5 && (
+
...and {queue.length - 5} more
+ )}
+
+
+ )}
+
+ {/* Songs list */}
+ {loading ? (
+
+
+
+ ) : offlineTracks.length === 0 ? (
+
+
+
+ No songs downloaded yet. Tap the download icon on any song to save it for offline.
+
+
+
+
+
+ ) : (
+
+ {offlineTracks.map((song, i) => (
+
+ ))}
+
+ )}
+
+
+
+ )
+}
diff --git a/components/music/download-button.tsx b/components/music/download-button.tsx
new file mode 100644
index 0000000..1031d5a
--- /dev/null
+++ b/components/music/download-button.tsx
@@ -0,0 +1,58 @@
+'use client'
+
+import type { Track } from './music-provider'
+import { useOffline } from '@/lib/stores/offline'
+import { Download, Loader2, CheckCircle, Clock } from 'lucide-react'
+
+interface DownloadButtonProps {
+ track: Track
+ className?: string
+ size?: 'sm' | 'md'
+}
+
+export function DownloadButton({ track, className = '', size = 'sm' }: DownloadButtonProps) {
+ const { offlineIds, download, remove, getStatus } = useOffline()
+ const isOffline = offlineIds.has(track.id)
+ const status = getStatus(track.id)
+
+ const iconSize = size === 'sm' ? 'h-4 w-4' : 'h-5 w-5'
+ const padding = size === 'sm' ? 'p-1.5' : 'p-2'
+
+ if (isOffline) {
+ return (
+
+ )
+ }
+
+ if (status === 'downloading') {
+ return (
+
+
+
+ )
+ }
+
+ if (status === 'queued') {
+ return (
+
+
+
+ )
+ }
+
+ return (
+
+ )
+}
diff --git a/components/music/full-screen-player.tsx b/components/music/full-screen-player.tsx
index 8d3e981..4bf6f27 100644
--- a/components/music/full-screen-player.tsx
+++ b/components/music/full-screen-player.tsx
@@ -3,6 +3,7 @@
import { useState, useEffect } from 'react'
import { Drawer } from 'vaul'
import { useMusicPlayer } from './music-provider'
+import { DownloadButton } from './download-button'
import { PlaylistPicker } from './playlist-picker'
import { Slider } from '@/components/ui/slider'
import { Button } from '@/components/ui/button'
@@ -16,6 +17,7 @@ import {
Volume2,
VolumeX,
ChevronDown,
+ Speaker,
} from 'lucide-react'
function formatTime(secs: number) {
@@ -26,7 +28,7 @@ function formatTime(secs: number) {
}
export function FullScreenPlayer() {
- const { state, togglePlay, seek, setVolume, nextTrack, prevTrack, setFullScreen } = useMusicPlayer()
+ const { state, togglePlay, seek, setVolume, nextTrack, prevTrack, setFullScreen, outputDevices, currentOutputId, setOutputDevice } = useMusicPlayer()
const [lyrics, setLyrics] = useState
(null)
const [loadingLyrics, setLoadingLyrics] = useState(false)
const [playlistOpen, setPlaylistOpen] = useState(false)
@@ -146,8 +148,27 @@ export function FullScreenPlayer() {
/>
+ {/* Audio output selector */}
+ {outputDevices.length > 1 && (
+
+
+
+
+ )}
+
{/* Actions */}
+