Compare commits

..

1 Commits

Author SHA1 Message Date
Vercel 369c37dffd Fix React Server Components CVE vulnerabilities
Updated dependencies to fix Next.js and React CVE vulnerabilities.

The fix-react2shell-next tool automatically updated the following packages to their secure versions:
- next
- react-server-dom-webpack
- react-server-dom-parcel  
- react-server-dom-turbopack

All package.json files have been scanned and vulnerable versions have been patched to the correct fixed versions based on the official React advisory.

Co-authored-by: Vercel <vercel[bot]@users.noreply.github.com>
2025-12-22 13:58:13 +00:00
87 changed files with 821 additions and 12666 deletions

View File

@ -1,27 +0,0 @@
# Git
.git
.gitignore
# Development
node_modules
.next
.cache
# Documentation
README.md
CLAUDE.md
*.md
# IDE
.idea
.vscode
*.swp
# OS
.DS_Store
Thumbs.db
# Environment (keep .env.example)
.env
.env.local
.env*.local

View File

@ -1,24 +0,0 @@
# SMTP settings (Mailcow)
SMTP_HOST=mail.rmail.online
SMTP_PORT=587
SMTP_USER=noreply@jefflix.lol
SMTP_PASS=your-mailbox-password
# Admin email to receive access request notifications
ADMIN_EMAIL=jeff@jeffemmett.com
# Channel approval token signing secret (generate with: openssl rand -hex 32)
TOKEN_SECRET=your-random-secret-here
# Threadfin credentials for one-click channel activation
THREADFIN_USER=your-threadfin-username
THREADFIN_PASS=your-threadfin-password
# Navidrome (Music / Subsonic API)
NAVIDROME_URL=https://music.jefflix.lol
NAVIDROME_USER=your-navidrome-username
NAVIDROME_PASS=your-navidrome-password
# slskd (Soulseek P2P downloads)
SLSKD_URL=https://slskd.jefflix.lol
SLSKD_API_KEY=your-slskd-api-key

View File

@ -1,68 +0,0 @@
# Gitea Actions CI/CD — Static Site (no tests, build + deploy only)
# Copy to: <repo>/.gitea/workflows/ci.yml
# Replace: jefflix-website, /opt/websites/jefflix-website, https://jefflix.lol/
name: CI/CD
on:
push:
branches: [main]
env:
REGISTRY: localhost:3000
IMAGE: localhost:3000/jeffemmett/jefflix-website
jobs:
deploy:
runs-on: ubuntu-latest
container:
image: docker:cli
steps:
- name: Setup tools
run: apk add --no-cache git openssh-client curl
- name: Checkout
run: git clone --depth 1 --branch ${{ github.ref_name }} http://token:${{ github.token }}@server:3000/${{ github.repository }}.git .
- name: Set image tag
run: |
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-8)
echo "IMAGE_TAG=${SHORT_SHA}" >> $GITHUB_ENV
- name: Build and push image
run: |
docker build -t ${{ env.IMAGE }}:${{ env.IMAGE_TAG }} -t ${{ env.IMAGE }}:latest .
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login ${{ env.REGISTRY }} -u ${{ secrets.REGISTRY_USER }} --password-stdin
docker push ${{ env.IMAGE }}:${{ env.IMAGE_TAG }}
docker push ${{ env.IMAGE }}:latest
- name: Deploy
run: |
mkdir -p ~/.ssh
echo "${{ secrets.DEPLOY_SSH_KEY }}" | base64 -d > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh -o StrictHostKeyChecking=no -i ~/.ssh/deploy_key root@${{ secrets.DEPLOY_HOST }} "
cd /opt/websites/jefflix-website
cat .last-deployed-tag 2>/dev/null > .rollback-tag || true
echo '${{ env.IMAGE_TAG }}' > .last-deployed-tag
docker pull ${{ env.IMAGE }}:${{ env.IMAGE_TAG }}
IMAGE_TAG=${{ env.IMAGE_TAG }} docker compose up -d --no-build
"
- name: Smoke test
run: |
sleep 15
STATUS=$(ssh -o StrictHostKeyChecking=no -i ~/.ssh/deploy_key root@${{ secrets.DEPLOY_HOST }} \
"cd /opt/websites/jefflix-website && docker compose ps --format '{{{{.Status}}}}' 2>/dev/null | head -1 || echo 'unknown'")
if echo "$STATUS" | grep -qi "up"; then
echo "Smoke test passed (container status: $STATUS)"
else
echo "Smoke test failed (container status: $STATUS) — rolling back"
ROLLBACK_TAG=$(ssh -o StrictHostKeyChecking=no -i ~/.ssh/deploy_key root@${{ secrets.DEPLOY_HOST }} "cat /opt/websites/jefflix-website/.rollback-tag 2>/dev/null")
if [ -n "$ROLLBACK_TAG" ]; then
ssh -o StrictHostKeyChecking=no -i ~/.ssh/deploy_key root@${{ secrets.DEPLOY_HOST }} \
"cd /opt/websites/jefflix-website && IMAGE_TAG=$ROLLBACK_TAG docker compose up -d --no-build"
echo "Rolled back to $ROLLBACK_TAG"
fi
exit 1
fi

40
.gitignore vendored
View File

@ -1,40 +0,0 @@
# Dependencies
node_modules/
.pnpm-store/
# Build outputs
.next/
out/
build/
dist/
# Environment files (keep .env.example)
.env
.env.local
.env.*.local
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# TypeScript
*.tsbuildinfo
next-env.d.ts
# Vercel
.vercel
# Testing
coverage/

View File

View File

@ -1 +1,30 @@
# Jefflix website
*Automatically synced with your [v0.app](https://v0.app) deployments*
[![Deployed on Vercel](https://img.shields.io/badge/Deployed%20on-Vercel-black?style=for-the-badge&logo=vercel)](https://vercel.com/jeff-emmetts-projects/v0-jefflix-website)
[![Built with v0](https://img.shields.io/badge/Built%20with-v0.app-black?style=for-the-badge)](https://v0.app/chat/rSGm1BAgi15)
## Overview
This repository will stay in sync with your deployed chats on [v0.app](https://v0.app).
Any changes you make to your deployed app will be automatically pushed to this repository from [v0.app](https://v0.app).
## Deployment
Your project is live at:
**[https://vercel.com/jeff-emmetts-projects/v0-jefflix-website](https://vercel.com/jeff-emmetts-projects/v0-jefflix-website)**
## Build your app
Continue building your app on:
**[https://v0.app/chat/rSGm1BAgi15](https://v0.app/chat/rSGm1BAgi15)**
## How It Works
1. Create and modify your project using [v0.app](https://v0.app)
2. Deploy your chats from the v0 interface
3. Changes are automatically pushed to this repository
4. Vercel deploys the latest version from this repository

View File

@ -1,163 +0,0 @@
import { NextRequest } from 'next/server'
import nodemailer from 'nodemailer'
import { verifyApproveToken } from '@/lib/token'
import { activateChannels } from '@/lib/threadfin'
function htmlPage(title: string, body: string): Response {
return new Response(
`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${title} Jefflix</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 600px; margin: 60px auto; padding: 0 20px; color: #1a1a1a; background: #0a0a0a; color: #e5e5e5; }
.card { background: #1a1a1a; border: 1px solid #333; border-radius: 12px; padding: 32px; }
.icon { font-size: 48px; margin-bottom: 16px; }
h1 { margin: 0 0 16px; font-size: 24px; }
ul { padding-left: 20px; line-height: 1.8; }
code { background: #2a2a2a; padding: 2px 6px; border-radius: 4px; font-size: 13px; }
.success { color: #4ade80; }
.warning { color: #facc15; }
.error { color: #f87171; }
.muted { color: #888; font-size: 13px; margin-top: 24px; }
</style>
</head>
<body>
<div class="card">
${body}
</div>
</body>
</html>`,
{ status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } },
)
}
export async function GET(request: NextRequest) {
const token = request.nextUrl.searchParams.get('token')
if (!token) {
return htmlPage('Invalid Link', `
<div class="icon">&#10060;</div>
<h1 class="error">Invalid Link</h1>
<p>No approval token provided.</p>
`)
}
const payload = verifyApproveToken(token)
if (!payload) {
return htmlPage('Expired or Invalid', `
<div class="icon">&#9203;</div>
<h1 class="error">Link Expired or Invalid</h1>
<p>This approval link has expired or is invalid. Channel requests expire after 7 days.</p>
<p>Ask the user to submit a new request.</p>
`)
}
try {
const result = await activateChannels(
payload.channels.map((ch) => ch.id),
)
// Send confirmation email to the requester
await sendConfirmationEmail(payload.email, result.activated, result.notFound, payload.channels)
const activatedHtml = result.activated.length > 0
? `<h2 class="success">Activated (${result.activated.length})</h2>
<ul>${result.activated.map((id) => {
const ch = payload.channels.find((c) => c.id === id)
return `<li><strong>${ch?.name ?? id}</strong> — <code>${id}</code></li>`
}).join('')}</ul>`
: ''
const notFoundHtml = result.notFound.length > 0
? `<h2 class="warning">Not Found in Playlists (${result.notFound.length})</h2>
<ul>${result.notFound.map((id) => {
const ch = payload.channels.find((c) => c.id === id)
return `<li><strong>${ch?.name ?? id}</strong> — <code>${id}</code></li>`
}).join('')}</ul>
<p class="muted">These channels aren't in the current M3U playlists (english/news/sports). Add them to Threadfin's playlist sources first.</p>`
: ''
return htmlPage('Channels Approved', `
<div class="icon">&#9989;</div>
<h1 class="success">Channels Approved</h1>
<p>Request from <strong>${escapeHtml(payload.email)}</strong> has been processed.</p>
${activatedHtml}
${notFoundHtml}
<p class="muted">A confirmation email has been sent to the requester.</p>
`)
} catch (err) {
console.error('Channel activation error:', err)
return htmlPage('Activation Failed', `
<div class="icon">&#10060;</div>
<h1 class="error">Activation Failed</h1>
<p>Could not connect to Threadfin or activate channels.</p>
<p><code>${escapeHtml(err instanceof Error ? err.message : String(err))}</code></p>
<p>Check Threadfin is running and credentials are correct.</p>
`)
}
}
async function sendConfirmationEmail(
to: string,
activated: string[],
notFound: string[],
channels: { id: string; name: string }[],
) {
const smtpHost = process.env.SMTP_HOST
const smtpUser = process.env.SMTP_USER
const smtpPass = process.env.SMTP_PASS
if (!smtpHost || !smtpUser || !smtpPass) return
const transporter = nodemailer.createTransport({
host: smtpHost,
port: Number(process.env.SMTP_PORT) || 587,
secure: false,
auth: { user: smtpUser, pass: smtpPass },
tls: { rejectUnauthorized: false },
})
const activatedList = activated.map((id) => {
const ch = channels.find((c) => c.id === id)
return `<li>&#9989; <strong>${escapeHtml(ch?.name ?? id)}</strong></li>`
}).join('')
const notFoundList = notFound.map((id) => {
const ch = channels.find((c) => c.id === id)
return `<li>&#10060; <strong>${escapeHtml(ch?.name ?? id)}</strong> — not available in current playlists</li>`
}).join('')
await transporter.sendMail({
from: `Jefflix <${smtpUser}>`,
to,
subject: `[Jefflix] Your channel request has been processed`,
html: `
<h2>Your Channel Request Update</h2>
${activated.length > 0 ? `
<p>The following channels have been activated and should appear in your guide shortly:</p>
<ul style="line-height: 1.8;">${activatedList}</ul>
` : ''}
${notFound.length > 0 ? `
<p>The following channels were not found in the current playlists:</p>
<ul style="line-height: 1.8;">${notFoundList}</ul>
<p style="color: #666; font-size: 13px;">These may become available when new playlist sources are added.</p>
` : ''}
${activated.length > 0 ? '<p>It may take a few minutes for the channels to appear in your TV guide. If you don\'t see them after 15 minutes, try refreshing your IPTV app.</p>' : ''}
<hr style="margin: 20px 0; border: none; border-top: 1px solid #ddd;" />
<p style="color: #666; font-size: 12px;">Jefflix · ${new Date().toLocaleString()}</p>
`,
})
}
function escapeHtml(text: string): string {
const map: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;',
}
return text.replace(/[&<>"']/g, (char) => map[char])
}

View File

@ -1,55 +0,0 @@
import { NextResponse } from 'next/server'
const IPTV_ORG_URL = 'https://iptv-org.github.io/api/channels.json'
interface IptvChannel {
id: string
name: string
country: string
categories: string[]
is_nsfw: boolean
is_closed: boolean
}
interface SlimChannel {
id: string
name: string
country: string
categories: string[]
}
let cache: { data: SlimChannel[]; fetchedAt: number } | null = null
const CACHE_TTL = 60 * 60 * 1000 // 1 hour
export async function GET() {
try {
if (cache && Date.now() - cache.fetchedAt < CACHE_TTL) {
return NextResponse.json(cache.data)
}
const res = await fetch(IPTV_ORG_URL, { next: { revalidate: 3600 } })
if (!res.ok) {
throw new Error(`iptv-org API returned ${res.status}`)
}
const channels: IptvChannel[] = await res.json()
const filtered: SlimChannel[] = channels
.filter((ch) => !ch.is_nsfw && !ch.is_closed)
.map(({ id, name, country, categories }) => ({ id, name, country, categories }))
cache = { data: filtered, fetchedAt: Date.now() }
return NextResponse.json(filtered)
} catch (error) {
console.error('Failed to fetch channels:', error)
// Return cached data even if stale on error
if (cache) {
return NextResponse.json(cache.data)
}
return NextResponse.json(
{ error: 'Failed to load channel list' },
{ status: 502 }
)
}
}

View File

@ -1,25 +0,0 @@
import { navidromeFetch } from '@/lib/navidrome'
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const { searchParams } = new URL(request.url)
const size = searchParams.get('size') || '300'
try {
const res = await navidromeFetch('getCoverArt.view', { id, size })
const contentType = res.headers.get('content-type') || 'image/jpeg'
return new Response(res.body, {
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=604800',
},
})
} catch (error) {
console.error('Cover art error:', error)
return new Response('Cover art failed', { status: 502 })
}
}

View File

@ -1,70 +0,0 @@
import { NextResponse } from 'next/server'
import { navidromeGet } from '@/lib/navidrome'
interface LyricsResult {
lyrics?: {
artist?: string
title?: string
value?: string
}
}
interface LrcLibResult {
syncedLyrics?: string | null
plainLyrics?: string | null
}
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const artist = searchParams.get('artist') || ''
const title = searchParams.get('title') || ''
if (!artist || !title) {
return NextResponse.json({ lyrics: null, synced: null })
}
// Try LRCLIB first for synced (timed) lyrics
try {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 4000)
const lrcRes = await fetch(
`https://lrclib.net/api/get?artist_name=${encodeURIComponent(artist)}&track_name=${encodeURIComponent(title)}`,
{
headers: {
'User-Agent': 'SoulSync/1.0 (jeffemmett@gmail.com)',
},
signal: controller.signal,
cache: 'no-store',
}
)
clearTimeout(timeout)
if (lrcRes.ok) {
const lrc: LrcLibResult = await lrcRes.json()
if (lrc.syncedLyrics) {
return NextResponse.json({
lyrics: lrc.plainLyrics || lrc.syncedLyrics.replace(/\[\d{2}:\d{2}\.\d{2,3}\]\s?/g, ''),
synced: lrc.syncedLyrics,
})
}
if (lrc.plainLyrics) {
return NextResponse.json({ lyrics: lrc.plainLyrics, synced: null })
}
}
} catch {
// LRCLIB unavailable, fall through to Navidrome
}
// Fallback to Navidrome plain lyrics
try {
const data = await navidromeGet<LyricsResult>('getLyrics.view', { artist, title })
return NextResponse.json({
lyrics: data.lyrics?.value || null,
synced: null,
})
} catch (error) {
console.error('Lyrics error:', error)
return NextResponse.json({ lyrics: null, synced: null })
}
}

View File

@ -1,57 +0,0 @@
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 })
}
}

View File

@ -1,121 +0,0 @@
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<PlaylistsResult>('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<PlaylistResult>('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<PlaylistsResult>('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 })
}
}

View File

@ -1,91 +0,0 @@
import { NextResponse } from 'next/server'
import { navidromeGet } from '@/lib/navidrome'
interface SubsonicSong {
id: string
title: string
artist: string
album: string
albumId: string
duration: number
track: number
year: number
coverArt: string
suffix: string
}
interface PlaylistResult {
playlist?: {
id: string
name: string
songCount: number
coverArt: string
entry?: SubsonicSong[]
}
}
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
try {
const data = await navidromeGet<PlaylistResult>('getPlaylist.view', { id })
const pl = data.playlist
if (!pl) return NextResponse.json({ error: 'Playlist not found' }, { status: 404 })
// Dedup by title+artist, keeping first occurrence
const seen = new Set<string>()
const songs = (pl.entry || []).reduce<Array<{
id: string; title: string; artist: string; album: string; albumId: string;
duration: number; track: number; year: number; coverArt: string; suffix: string;
}>>((acc, s) => {
const key = `${s.title.toLowerCase().trim()}|||${s.artist.toLowerCase().trim()}`
if (!seen.has(key)) {
seen.add(key)
acc.push({
id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, duration: s.duration, track: s.track,
year: s.year, coverArt: s.coverArt, suffix: s.suffix,
})
}
return acc
}, [])
return NextResponse.json({
id: pl.id,
name: pl.name,
songCount: pl.songCount,
coverArt: pl.coverArt,
songs,
})
} catch (error) {
console.error('Get playlist error:', error)
return NextResponse.json({ error: 'Failed to load playlist' }, { status: 502 })
}
}
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
try {
const { songId } = await request.json()
if (!songId) {
return NextResponse.json({ error: 'songId required' }, { status: 400 })
}
await navidromeGet('updatePlaylist.view', {
playlistId: id,
songIdToAdd: songId,
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Add to playlist error:', error)
return NextResponse.json({ error: 'Failed to add to playlist' }, { status: 502 })
}
}

View File

@ -1,20 +0,0 @@
import { NextResponse } from 'next/server'
import { navidromeGet } from '@/lib/navidrome'
export async function POST(request: Request) {
try {
const { name, songId } = await request.json()
if (!name) {
return NextResponse.json({ error: 'name required' }, { status: 400 })
}
const params: Record<string, string> = { name }
if (songId) params.songId = songId
await navidromeGet('createPlaylist.view', params)
return NextResponse.json({ success: true })
} catch (error) {
console.error('Create playlist error:', error)
return NextResponse.json({ error: 'Failed to create playlist' }, { status: 502 })
}
}

View File

@ -1,35 +0,0 @@
import { NextResponse } from 'next/server'
import { navidromeGet } from '@/lib/navidrome'
interface SubsonicPlaylist {
id: string
name: string
songCount: number
duration: number
coverArt: string
}
interface PlaylistsResult {
playlists?: {
playlist?: SubsonicPlaylist[]
}
}
export async function GET() {
try {
const data = await navidromeGet<PlaylistsResult>('getPlaylists.view')
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)
return NextResponse.json({ error: 'Failed to load playlists' }, { status: 502 })
}
}

View File

@ -1,72 +0,0 @@
import { NextResponse } from 'next/server'
import { navidromeGet } from '@/lib/navidrome'
interface SubsonicSong {
id: string
title: string
artist: string
album: string
albumId: string
duration: number
track: number
year: number
coverArt: string
suffix: string
bitRate: number
}
interface SearchResult {
searchResult3?: {
song?: SubsonicSong[]
}
}
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({ songs: [] })
}
try {
const data = await navidromeGet<SearchResult>('search3.view', {
query: q,
songCount: '50',
albumCount: '0',
artistCount: '0',
})
const rawSongs = data.searchResult3?.song || []
// Dedup by title+artist, keeping highest bitRate (then most recent year)
const seen = new Map<string, SubsonicSong>()
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,
album: s.album,
albumId: s.albumId,
duration: s.duration,
track: s.track,
year: s.year,
coverArt: s.coverArt,
suffix: s.suffix,
}))
return NextResponse.json({ songs })
} catch (error) {
console.error('Music search error:', error)
return NextResponse.json({ error: 'Search failed' }, { status: 502 })
}
}

View File

@ -1,79 +0,0 @@
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 })
}
}

View File

@ -1,28 +0,0 @@
import { NextResponse } from 'next/server'
import { slskdFetch } from '@/lib/slskd'
export async function POST(request: Request) {
try {
const { username, files } = await request.json()
if (!username || !files?.length) {
return NextResponse.json({ error: 'username and files required' }, { status: 400 })
}
const res = await slskdFetch(`/transfers/downloads/${encodeURIComponent(username)}`, {
method: 'POST',
body: JSON.stringify(files.map((f: { filename: string; size: number }) => ({
filename: f.filename,
size: f.size,
}))),
})
if (!res.ok) {
throw new Error(`slskd download returned ${res.status}`)
}
return NextResponse.json({ success: true })
} catch (error) {
console.error('Soulseek download error:', error)
return NextResponse.json({ error: 'Download request failed' }, { status: 502 })
}
}

View File

@ -1,30 +0,0 @@
import { NextResponse } from 'next/server'
import { slskdFetch } from '@/lib/slskd'
import { extractBestFiles, type SlskdRawResponse } from '@/lib/slskd-dedup'
export async function GET(
_request: Request,
{ params }: { params: Promise<{ searchId: string }> }
) {
const { searchId } = await params
try {
const res = await slskdFetch(`/searches/${searchId}`)
if (!res.ok) {
throw new Error(`slskd results returned ${res.status}`)
}
const data = await res.json()
const isComplete = data.state === 'Completed' || data.state === 'TimedOut'
const responses: SlskdRawResponse[] = (data.responses || [])
.filter((r: SlskdRawResponse) => r.files?.length > 0)
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 })
}
}

View File

@ -1,26 +0,0 @@
import { NextResponse } from 'next/server'
import { slskdFetch } from '@/lib/slskd'
export async function POST(request: Request) {
try {
const { query } = await request.json()
if (!query) {
return NextResponse.json({ error: 'query required' }, { status: 400 })
}
const res = await slskdFetch('/searches', {
method: 'POST',
body: JSON.stringify({ searchText: query }),
})
if (!res.ok) {
throw new Error(`slskd search returned ${res.status}`)
}
const data = await res.json()
return NextResponse.json({ searchId: data.id })
} catch (error) {
console.error('Soulseek search error:', error)
return NextResponse.json({ error: 'Soulseek search failed' }, { status: 502 })
}
}

View File

@ -1,26 +0,0 @@
import { navidromeFetch } from '@/lib/navidrome'
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
try {
const res = await navidromeFetch('stream.view', { id })
const contentType = res.headers.get('content-type') || 'audio/mpeg'
const contentLength = res.headers.get('content-length')
const headers: Record<string, string> = {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=86400',
'Accept-Ranges': 'bytes',
}
if (contentLength) headers['Content-Length'] = contentLength
return new Response(res.body, { headers })
} catch (error) {
console.error('Stream error:', error)
return new Response('Stream failed', { status: 502 })
}
}

View File

@ -1,88 +0,0 @@
import { NextResponse } from 'next/server'
const RADIO_GARDEN_API = 'https://radio.garden/api'
const HEADERS = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'application/json',
'Referer': 'https://radio.garden/',
}
interface RGChannelItem {
page: {
url: string
title: string
place: { id: string; title: string }
country: { id: string; title: string }
website?: string
}
}
interface RGChannelsResponse {
data: {
title: string
content: Array<{ items: RGChannelItem[] }>
}
}
export interface SlimChannel {
id: string
title: string
placeTitle: string
country: string
website?: string
}
// Simple LRU-ish cache: map with max 500 entries
const channelCache = new Map<string, { data: SlimChannel[]; fetchedAt: number }>()
const CACHE_TTL = 60 * 60 * 1000 // 1 hour
const MAX_CACHE = 500
function extractChannelId(url: string): string {
// url format: "/listen/kcsm/HQQcSxCf"
const parts = url.split('/')
return parts[parts.length - 1]
}
export async function GET(
_request: Request,
{ params }: { params: Promise<{ placeId: string }> }
) {
const { placeId } = await params
try {
const cached = channelCache.get(placeId)
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL) {
return NextResponse.json(cached.data)
}
const res = await fetch(`${RADIO_GARDEN_API}/ara/content/page/${placeId}/channels`, {
headers: HEADERS,
signal: AbortSignal.timeout(10000),
})
if (!res.ok) throw new Error(`Radio Garden returned ${res.status}`)
const json: RGChannelsResponse = await res.json()
const channels: SlimChannel[] = (json.data.content?.[0]?.items || []).map((item) => ({
id: extractChannelId(item.page.url),
title: item.page.title,
placeTitle: item.page.place.title,
country: item.page.country.title,
website: item.page.website,
}))
// Evict oldest if over limit
if (channelCache.size >= MAX_CACHE) {
const oldest = channelCache.keys().next().value
if (oldest) channelCache.delete(oldest)
}
channelCache.set(placeId, { data: channels, fetchedAt: Date.now() })
return NextResponse.json(channels)
} catch (error) {
console.error(`Failed to fetch channels for ${placeId}:`, error)
const cached = channelCache.get(placeId)
if (cached) return NextResponse.json(cached.data)
return NextResponse.json({ error: 'Failed to load channels' }, { status: 502 })
}
}

View File

@ -1,64 +0,0 @@
import { NextResponse } from 'next/server'
const RADIO_GARDEN_API = 'https://radio.garden/api'
const HEADERS = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'application/json',
'Referer': 'https://radio.garden/',
}
interface RGPlace {
id: string
title: string
country: string
geo: [number, number] // [lng, lat]
size: number
}
interface RGPlacesResponse {
data: { list: RGPlace[] }
}
export interface SlimPlace {
id: string
title: string
country: string
lat: number
lng: number
size: number
}
let cache: { data: SlimPlace[]; fetchedAt: number } | null = null
const CACHE_TTL = 24 * 60 * 60 * 1000 // 24 hours
export async function GET() {
try {
if (cache && Date.now() - cache.fetchedAt < CACHE_TTL) {
return NextResponse.json(cache.data)
}
const res = await fetch(`${RADIO_GARDEN_API}/ara/content/places`, {
headers: HEADERS,
signal: AbortSignal.timeout(10000),
})
if (!res.ok) throw new Error(`Radio Garden returned ${res.status}`)
const json: RGPlacesResponse = await res.json()
const places: SlimPlace[] = json.data.list.map((p) => ({
id: p.id,
title: p.title,
country: p.country,
lat: p.geo[1],
lng: p.geo[0],
size: p.size,
}))
cache = { data: places, fetchedAt: Date.now() }
return NextResponse.json(places)
} catch (error) {
console.error('Failed to fetch radio places:', error)
if (cache) return NextResponse.json(cache.data)
return NextResponse.json({ error: 'Failed to load radio stations' }, { status: 502 })
}
}

View File

@ -1,92 +0,0 @@
import { NextResponse } from 'next/server'
const RADIO_GARDEN_API = 'https://radio.garden/api'
const HEADERS = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'application/json',
'Referer': 'https://radio.garden/',
}
interface RGSearchHit {
_source: {
code: string
type: 'channel' | 'place' | 'country'
page: {
url: string
title: string
place?: { id: string; title: string }
country?: { id: string; title: string }
subtitle?: string
}
}
_score: number
}
interface RGSearchResponse {
hits: { hits: RGSearchHit[] }
}
function extractId(url: string): string {
const parts = url.split('/')
return parts[parts.length - 1]
}
export interface SearchStation {
id: string
title: string
placeId: string
placeTitle: string
country: string
}
export interface SearchPlace {
id: string
title: string
country: 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({ stations: [], places: [] })
}
try {
const res = await fetch(`${RADIO_GARDEN_API}/search?q=${encodeURIComponent(q)}`, {
headers: HEADERS,
signal: AbortSignal.timeout(10000),
})
if (!res.ok) throw new Error(`Radio Garden search returned ${res.status}`)
const json: RGSearchResponse = await res.json()
const stations: SearchStation[] = []
const places: SearchPlace[] = []
for (const hit of json.hits.hits) {
const src = hit._source
if (src.type === 'channel') {
stations.push({
id: extractId(src.page.url),
title: src.page.title,
placeId: src.page.place?.id || '',
placeTitle: src.page.place?.title || '',
country: src.page.country?.title || src.code,
})
} else if (src.type === 'place') {
places.push({
id: extractId(src.page.url),
title: src.page.title,
country: src.page.country?.title || src.code,
})
}
}
return NextResponse.json({ stations, places })
} catch (error) {
console.error('Radio search error:', error)
return NextResponse.json({ error: 'Search failed' }, { status: 502 })
}
}

View File

@ -1,46 +0,0 @@
import { NextResponse } from 'next/server'
const RADIO_GARDEN_API = 'https://radio.garden/api'
const HEADERS = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Referer': 'https://radio.garden/',
}
export async function GET(
_request: Request,
{ params }: { params: Promise<{ channelId: string }> }
) {
const { channelId } = await params
try {
// Follow the 302 redirect to get the actual stream URL
const res = await fetch(
`${RADIO_GARDEN_API}/ara/content/listen/${channelId}/channel.mp3`,
{
headers: HEADERS,
redirect: 'manual', // Don't auto-follow — we want the Location header
signal: AbortSignal.timeout(10000),
}
)
if (res.status === 302) {
const streamUrl = res.headers.get('location')
if (streamUrl) {
return NextResponse.json({ url: streamUrl })
}
}
// Some stations return 301 or other redirects
if (res.status >= 300 && res.status < 400) {
const streamUrl = res.headers.get('location')
if (streamUrl) {
return NextResponse.json({ url: streamUrl })
}
}
throw new Error(`Unexpected status ${res.status}`)
} catch (error) {
console.error(`Failed to resolve stream for ${channelId}:`, error)
return NextResponse.json({ error: 'Failed to resolve stream URL' }, { status: 502 })
}
}

View File

@ -1,103 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import nodemailer from 'nodemailer'
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { name, email, reason } = body
// Validate required fields
if (!name || !email) {
return NextResponse.json(
{ error: 'Name and email are required' },
{ status: 400 }
)
}
// Email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(email)) {
return NextResponse.json(
{ error: 'Invalid email format' },
{ status: 400 }
)
}
const smtpHost = process.env.SMTP_HOST
const smtpUser = process.env.SMTP_USER
const smtpPass = process.env.SMTP_PASS
if (!smtpHost || !smtpUser || !smtpPass) {
console.error('SMTP credentials not configured')
return NextResponse.json(
{ error: 'Email service not configured' },
{ status: 500 }
)
}
const adminEmail = process.env.ADMIN_EMAIL || 'jeff@jeffemmett.com'
const transporter = nodemailer.createTransport({
host: smtpHost,
port: Number(process.env.SMTP_PORT) || 587,
secure: false,
auth: { user: smtpUser, pass: smtpPass },
tls: { rejectUnauthorized: false },
})
await transporter.sendMail({
from: `Jefflix <${smtpUser}>`,
to: adminEmail,
subject: `[Jefflix] New Access Request from ${name}`,
html: `
<h2>New Jefflix Access Request</h2>
<p>Someone has requested access to Jefflix:</p>
<table style="border-collapse: collapse; margin: 20px 0;">
<tr>
<td style="padding: 8px; font-weight: bold; border: 1px solid #ddd;">Name:</td>
<td style="padding: 8px; border: 1px solid #ddd;">${escapeHtml(name)}</td>
</tr>
<tr>
<td style="padding: 8px; font-weight: bold; border: 1px solid #ddd;">Email:</td>
<td style="padding: 8px; border: 1px solid #ddd;"><a href="mailto:${escapeHtml(email)}">${escapeHtml(email)}</a></td>
</tr>
<tr>
<td style="padding: 8px; font-weight: bold; border: 1px solid #ddd;">Reason:</td>
<td style="padding: 8px; border: 1px solid #ddd;">${escapeHtml(reason || 'Not provided')}</td>
</tr>
<tr>
<td style="padding: 8px; font-weight: bold; border: 1px solid #ddd;">Requested:</td>
<td style="padding: 8px; border: 1px solid #ddd;">${new Date().toLocaleString()}</td>
</tr>
</table>
<p>To approve this request:</p>
<ol>
<li>Go to <a href="https://movies.jefflix.lol">Jellyfin Dashboard</a></li>
<li>Navigate to Dashboard Users Add User</li>
<li>Create an account for ${escapeHtml(name)} (${escapeHtml(email)})</li>
<li>Reply to this email to let them know their account is ready</li>
</ol>
<hr style="margin: 20px 0; border: none; border-top: 1px solid #ddd;" />
<p style="color: #666; font-size: 12px;">This is an automated message from Jefflix.</p>
`,
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Request access error:', error)
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
}
function escapeHtml(text: string): string {
const map: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;',
}
return text.replace(/[&<>"']/g, (char) => map[char])
}

View File

@ -1,129 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import nodemailer from 'nodemailer'
import { createApproveToken } from '@/lib/token'
interface ChannelSelection {
id: string
name: string
country: string
categories: string[]
}
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { email, channels } = body as { email: string; channels: ChannelSelection[] }
// Validate email
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!email || !emailRegex.test(email)) {
return NextResponse.json(
{ error: 'A valid email is required' },
{ status: 400 }
)
}
// Validate channels
if (!Array.isArray(channels) || channels.length === 0 || channels.length > 20) {
return NextResponse.json(
{ error: 'Select between 1 and 20 channels' },
{ status: 400 }
)
}
for (const ch of channels) {
if (!ch.id || !ch.name) {
return NextResponse.json(
{ error: 'Invalid channel data' },
{ status: 400 }
)
}
}
const smtpHost = process.env.SMTP_HOST
const smtpUser = process.env.SMTP_USER
const smtpPass = process.env.SMTP_PASS
if (!smtpHost || !smtpUser || !smtpPass) {
console.error('SMTP credentials not configured')
return NextResponse.json(
{ error: 'Email service not configured' },
{ status: 500 }
)
}
const adminEmail = process.env.ADMIN_EMAIL || 'jeff@jeffemmett.com'
const transporter = nodemailer.createTransport({
host: smtpHost,
port: Number(process.env.SMTP_PORT) || 587,
secure: false,
auth: { user: smtpUser, pass: smtpPass },
tls: { rejectUnauthorized: false },
})
const channelListHtml = channels
.map(
(ch) =>
`<li><strong>${escapeHtml(ch.name)}</strong> — <code>${escapeHtml(ch.id)}</code>` +
(ch.country ? ` (${escapeHtml(ch.country)})` : '') +
(ch.categories.length > 0 ? ` [${ch.categories.map(escapeHtml).join(', ')}]` : '') +
`</li>`
)
.join('\n')
const subject =
channels.length === 1
? `[Jefflix] Channel Request: ${escapeHtml(channels[0].name)}`
: `[Jefflix] Channel Request: ${channels.length} channels`
// Generate approve token for one-click activation
const approveToken = createApproveToken(
channels.map((ch) => ({ id: ch.id, name: ch.name })),
email,
)
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'https://jefflix.lol'
const approveUrl = `${baseUrl}/api/approve-channels?token=${approveToken}`
await transporter.sendMail({
from: `Jefflix <${smtpUser}>`,
to: adminEmail,
subject,
html: `
<h2>New Channel Request</h2>
<p><strong>${escapeHtml(email)}</strong> requested ${channels.length} channel${channels.length > 1 ? 's' : ''}:</p>
<div style="text-align: center; margin: 24px 0;">
<a href="${approveUrl}" style="display: inline-block; background: #22c55e; color: #fff; padding: 14px 32px; border-radius: 8px; text-decoration: none; font-size: 16px; font-weight: 600;">
&#9989; Approve &amp; Add Channels
</a>
</div>
<p style="text-align: center; color: #888; font-size: 12px;">One click activates these channels in Threadfin and notifies the requester. Link expires in 7 days.</p>
<ul style="line-height: 1.8;">
${channelListHtml}
</ul>
<hr style="margin: 20px 0; border: none; border-top: 1px solid #ddd;" />
<p style="color: #666; font-size: 12px;">Automated message from Jefflix · ${new Date().toLocaleString()}</p>
`,
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('Channel request error:', error)
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
}
function escapeHtml(text: string): string {
const map: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;',
}
return text.replace(/[&<>"']/g, (char) => map[char])
}

View File

@ -1,22 +0,0 @@
import { NextResponse } from "next/server"
const ACCESS_CODE = process.env.ACCESS_CODE || "42069"
export async function POST(request: Request) {
const { code } = await request.json()
if (code !== ACCESS_CODE) {
return NextResponse.json({ error: "Invalid code" }, { status: 401 })
}
const response = NextResponse.json({ success: true })
response.cookies.set("jefflix-access", "granted", {
httpOnly: true,
secure: true,
sameSite: "lax",
maxAge: 60 * 60 * 24 * 365, // 1 year
path: "/",
})
return response
}

View File

@ -1,16 +0,0 @@
import { NextResponse } from 'next/server'
// BUILD_ID is set at build time by Next.js standalone output.
// Falls back to a startup timestamp so every restart counts as a new version.
const VERSION = process.env.BUILD_ID || process.env.NEXT_BUILD_ID || Date.now().toString()
export function GET() {
return NextResponse.json(
{ version: VERSION },
{
headers: {
'Cache-Control': 'no-cache, no-store, must-revalidate',
},
}
)
}

View File

@ -1,67 +0,0 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { JefflixLogo } from "@/components/jefflix-logo"
export default function GatePage() {
const [code, setCode] = useState("")
const [error, setError] = useState("")
const [loading, setLoading] = useState(false)
const router = useRouter()
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError("")
setLoading(true)
const res = await fetch("/api/verify-code", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code }),
})
if (res.ok) {
router.push("/")
router.refresh()
} else {
setError("Wrong code. Try again.")
setLoading(false)
}
}
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="absolute inset-0 bg-gradient-to-br from-yellow-50/80 via-background/90 to-green-50/80" />
<div className="relative w-full max-w-md mx-auto px-4">
<div className="text-center space-y-8">
<JefflixLogo />
<p className="text-lg text-muted-foreground">
Enter the access code to continue
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<input
type="text"
inputMode="numeric"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="Access code"
autoFocus
className="w-full text-center text-2xl tracking-widest px-4 py-3 rounded-lg border-2 border-border bg-card focus:outline-none focus:ring-2 focus:ring-ring"
/>
{error && (
<p className="text-destructive font-medium">{error}</p>
)}
<button
type="submit"
disabled={loading || !code}
className="w-full py-3 px-6 rounded-lg bg-primary text-primary-foreground font-bold text-lg hover:opacity-90 transition-opacity disabled:opacity-50"
>
{loading ? "Checking..." : "Enter"}
</button>
</form>
</div>
</div>
</div>
)
}

View File

@ -1,11 +1,7 @@
import type React from "react"
import type { Metadata } from "next"
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 { Analytics } from "@vercel/analytics/next"
import "./globals.css"
const _geist = Geist({ subsets: ["latin"] })
@ -22,10 +18,9 @@ const _permanentMarker = Permanent_Marker({
})
export const metadata: Metadata = {
metadataBase: new URL("https://jefflix.com"),
title: "Jefflix - Seize the Streams of Production",
description: "A revolutionary approach to media consumption. Free from subscriptions, owned by the people.",
manifest: "/manifest.json",
generator: "v0.app",
icons: {
icon: [
{
@ -43,28 +38,6 @@ export const metadata: Metadata = {
],
apple: "/apple-icon.png",
},
openGraph: {
type: "website",
locale: "en_US",
url: "https://jefflix.com",
title: "Jefflix - Seize the Streams of Production",
description: "A revolutionary approach to media consumption. Free from subscriptions, owned by the people.",
siteName: "Jefflix",
images: [
{
url: "/og-image.jpg",
width: 1200,
height: 630,
alt: "Jefflix - Seize the Streams of Production",
},
],
},
twitter: {
card: "summary_large_image",
title: "Jefflix - Seize the Streams of Production",
description: "A revolutionary approach to media consumption. Free from subscriptions, owned by the people.",
images: ["/og-image.jpg"],
},
}
export default function RootLayout({
@ -75,14 +48,8 @@ export default function RootLayout({
return (
<html lang="en" className={`${_fredoka.variable} ${_permanentMarker.variable}`}>
<body className={`font-sans antialiased`}>
<ServiceWorkerRegister />
<UpdateBanner />
<MusicProvider>
<OfflineProvider>
{children}
<MiniPlayer />
</OfflineProvider>
</MusicProvider>
{children}
<Analytics />
</body>
</html>
)

View File

@ -1,492 +0,0 @@
'use client'
import { useState, useEffect, useRef, Suspense } from 'react'
import { useSearchParams } from 'next/navigation'
import { Button } from '@/components/ui/button'
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 { MusicBrainzResultsSection } from '@/components/music/musicbrainz-results'
import { useOffline } from '@/lib/stores/offline'
import { Search, Music, Download, Loader2, ArrowLeft, AlertCircle, ListMusic, ChevronRight, ChevronDown, WifiOff, Users, DownloadCloud, CheckCircle } from 'lucide-react'
import Link from 'next/link'
interface Playlist {
id: string
name: string
songCount: number
coverArt: string
}
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() {
return (
<Suspense>
<MusicPageInner />
</Suspense>
)
}
function MusicPageInner() {
const searchParams = useSearchParams()
const { state } = useMusicPlayer()
const { offlineIds, download: downloadTrack } = useOffline()
const [query, setQuery] = useState(() => searchParams.get('q') || '')
const [debouncedQuery, setDebouncedQuery] = useState('')
const [songs, setSongs] = useState<Track[]>([])
const [searching, setSearching] = useState(false)
const [searchError, setSearchError] = useState('')
const debounceRef = useRef<NodeJS.Timeout>(null)
// Playlist browsing state
const [playlists, setPlaylists] = useState<Playlist[]>([])
const [playlistsLoading, setPlaylistsLoading] = useState(true)
const [expandedPlaylist, setExpandedPlaylist] = useState<string | null>(null)
const [playlistSongs, setPlaylistSongs] = useState<Track[]>([])
const [playlistSongsLoading, setPlaylistSongsLoading] = useState(false)
// Soulseek state
const [slskMode, setSlskMode] = useState(false)
const [slskSearchId, setSlskSearchId] = useState<string | null>(null)
const [slskResults, setSlskResults] = useState<SlskdFile[]>([])
const [slskSearching, setSlskSearching] = useState(false)
const [downloading, setDownloading] = useState<string | null>(null)
const pollRef = useRef<NodeJS.Timeout>(null)
// Fetch playlists on mount
useEffect(() => {
fetch('/api/music/playlists')
.then((r) => r.json())
.then((d) => setPlaylists(d.playlists || []))
.catch(() => {})
.finally(() => setPlaylistsLoading(false))
}, [])
const togglePlaylist = async (id: string) => {
if (expandedPlaylist === id) {
setExpandedPlaylist(null)
setPlaylistSongs([])
return
}
setExpandedPlaylist(id)
setPlaylistSongsLoading(true)
try {
const res = await fetch(`/api/music/playlist/${id}`)
const d = await res.json()
setPlaylistSongs(d.songs || [])
} catch {
setPlaylistSongs([])
}
setPlaylistSongsLoading(false)
}
// Debounced Navidrome search
useEffect(() => {
if (slskMode) return
debounceRef.current = setTimeout(() => setDebouncedQuery(query), 300)
return () => { if (debounceRef.current) clearTimeout(debounceRef.current) }
}, [query, slskMode])
useEffect(() => {
if (!debouncedQuery || debouncedQuery.length < 2 || slskMode) {
setSongs([])
return
}
setSearching(true)
setSearchError('')
fetch(`/api/music/search?q=${encodeURIComponent(debouncedQuery)}`)
.then((r) => r.json())
.then((d) => {
if (d.error) throw new Error(d.error)
setSongs(d.songs || [])
})
.catch((e) => setSearchError(e.message))
.finally(() => setSearching(false))
}, [debouncedQuery, slskMode])
// Cleanup slskd polling on unmount
useEffect(() => {
return () => { if (pollRef.current) clearTimeout(pollRef.current) }
}, [])
const searchSoulseek = async () => {
setSlskMode(true)
setSlskSearching(true)
setSlskResults([])
try {
const res = await fetch('/api/music/slskd/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: query || debouncedQuery }),
})
const d = await res.json()
if (d.error) throw new Error(d.error)
setSlskSearchId(d.searchId)
pollSlskResults(d.searchId)
} catch {
setSlskSearching(false)
}
}
const pollSlskResults = (searchId: string) => {
const poll = async () => {
try {
const res = await fetch(`/api/music/slskd/results/${searchId}`)
const d = await res.json()
setSlskResults(d.files || [])
if (!d.isComplete) {
pollRef.current = setTimeout(poll, 2000)
} else {
setSlskSearching(false)
}
} catch {
setSlskSearching(false)
}
}
poll()
}
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: file.bestPeer.username,
files: [{ filename: file.filename, size: file.size }],
}),
})
} catch {}
setDownloading(null)
}
const exitSlsk = () => {
setSlskMode(false)
setSlskSearchId(null)
setSlskResults([])
setSlskSearching(false)
if (pollRef.current) clearTimeout(pollRef.current)
}
const hasPlayer = !!state.currentTrack
return (
<div className={`min-h-screen bg-background ${hasPlayer ? 'pb-20' : ''}`}>
{/* Header */}
<div className="border-b border-border">
<div className="container mx-auto px-4 py-4 flex items-center justify-between">
<Link href="/" className="inline-block">
<JefflixLogo size="small" />
</Link>
<div className="flex items-center gap-2">
<Link href="/offline">
<Button variant="ghost" size="sm">
<WifiOff className="h-4 w-4 mr-1.5" />
Offline
</Button>
</Link>
<Link href="/">
<Button variant="ghost" size="sm">
<ArrowLeft className="h-4 w-4 mr-1.5" />
Home
</Button>
</Link>
</div>
</div>
</div>
{/* Main */}
<div className="container mx-auto px-4 py-12 md:py-16">
<div className="max-w-2xl mx-auto">
{/* Hero */}
<div className="text-center space-y-4 mb-8">
<div className="inline-block p-4 bg-purple-100 dark:bg-purple-900/30 rounded-full">
<Music className="h-10 w-10 text-purple-600 dark:text-purple-400" />
</div>
<h1 className="text-3xl font-bold font-marker">Music</h1>
<p className="text-muted-foreground">
Search the library, play songs, and manage playlists.
</p>
</div>
{/* Search */}
<div className="relative mb-6">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 h-5 w-5 text-muted-foreground" />
<input
type="text"
value={query}
onChange={(e) => { setQuery(e.target.value); if (slskMode) exitSlsk() }}
className="w-full pl-12 pr-4 py-3 rounded-lg border border-border bg-background focus:outline-none focus:ring-2 focus:ring-purple-500"
placeholder="Search songs, artists, albums..."
autoFocus
/>
</div>
{/* Soulseek mode toggle */}
{slskMode && (
<div className="flex items-center gap-2 mb-4">
<Badge className="bg-yellow-600 text-white">Soulseek</Badge>
<span className="text-sm text-muted-foreground">Searching peer-to-peer network</span>
<button onClick={exitSlsk} className="text-sm text-primary hover:underline ml-auto">
Back to Library
</button>
</div>
)}
{/* Navidrome Results */}
{!slskMode && (
<>
{searching && (
<div className="flex justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-purple-600" />
</div>
)}
{searchError && (
<div className="flex items-center gap-2 p-4 bg-red-100 dark:bg-red-900/30 rounded-lg text-red-600 dark:text-red-400">
<AlertCircle className="h-5 w-5 flex-shrink-0" />
<p className="text-sm">{searchError}</p>
</div>
)}
{!searching && songs.length > 0 && (
<div className="border border-border rounded-lg divide-y divide-border overflow-hidden">
{songs.map((song, i) => (
<SongRow key={song.id} song={song} songs={songs} index={i} showDownload />
))}
</div>
)}
{!searching && debouncedQuery.length >= 2 && songs.length === 0 && !searchError && (
<div className="text-center py-8 space-y-4">
<p className="text-muted-foreground">
No results for &ldquo;{debouncedQuery}&rdquo; in the library
</p>
<Button
onClick={searchSoulseek}
className="bg-yellow-600 hover:bg-yellow-700 text-white"
>
<Download className="h-4 w-4 mr-1.5" />
Search Soulseek
</Button>
</div>
)}
{query.length > 0 && query.length < 2 && (
<p className="text-sm text-muted-foreground text-center">
Type at least 2 characters to search
</p>
)}
{/* MusicBrainz Discovery */}
{debouncedQuery.length >= 2 && (
<MusicBrainzResultsSection query={debouncedQuery} />
)}
</>
)}
{/* Soulseek Results */}
{slskMode && (
<>
{slskSearching && slskResults.length === 0 && (
<div className="flex flex-col items-center gap-2 py-8">
<Loader2 className="h-6 w-6 animate-spin text-yellow-600" />
<p className="text-sm text-muted-foreground">Searching peer-to-peer network...</p>
</div>
)}
{slskResults.length > 0 && (
<div className="space-y-1">
{slskSearching && (
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-2">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Still searching...
</div>
)}
<div className="border border-border rounded-lg divide-y divide-border overflow-hidden">
{slskResults.map((file) => {
const sizeMB = (file.size / 1024 / 1024).toFixed(1)
const key = `${file.bestPeer.username}:${file.filename}`
return (
<div key={key} className="flex items-center gap-3 px-4 py-3">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{file.displayName}</div>
<div className="text-xs text-muted-foreground">
{sizeMB} MB
{file.bitRate > 0 && ` · ${file.bitRate} kbps`}
{' · '}{file.bestPeer.username}
</div>
</div>
{file.peerCount > 1 && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 flex-shrink-0">
<Users className="h-3 w-3 mr-0.5" />
{file.peerCount}
</Badge>
)}
<Button
size="sm"
variant="outline"
onClick={() => triggerDownload(file)}
disabled={downloading === key}
>
{downloading === key ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Download className="h-3.5 w-3.5" />
)}
</Button>
</div>
)
})}
</div>
</div>
)}
{!slskSearching && slskResults.length === 0 && slskSearchId && (
<p className="text-center text-muted-foreground py-8">
No results found on Soulseek
</p>
)}
</>
)}
{/* Playlists - shown when not searching */}
{!slskMode && !debouncedQuery && (
<div className="mt-8">
<h2 className="text-lg font-bold flex items-center gap-2 mb-4">
<ListMusic className="h-5 w-5 text-purple-600 dark:text-purple-400" />
Playlists
</h2>
{playlistsLoading ? (
<div className="flex justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-purple-600" />
</div>
) : playlists.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
No playlists yet
</p>
) : (
<div className="border border-border rounded-lg divide-y divide-border overflow-hidden">
{playlists.map((p) => {
// Check how many songs in this playlist are already offline
const allOffline = expandedPlaylist === p.id && playlistSongs.length > 0 &&
playlistSongs.every((s) => offlineIds.has(s.id))
return (
<div key={p.id}>
<div className="flex items-center hover:bg-muted/50 transition-colors">
<button
onClick={() => togglePlaylist(p.id)}
className="flex-1 flex items-center gap-3 px-4 py-3 text-left"
>
<div className="flex-shrink-0 w-12 h-12 rounded overflow-hidden bg-muted">
{p.coverArt ? (
<img
src={`/api/music/cover/${p.coverArt}?size=96`}
alt={p.name}
className="w-full h-full object-cover"
loading="lazy"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<ListMusic className="h-5 w-5 text-muted-foreground" />
</div>
)}
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-sm truncate">{p.name}</div>
<div className="text-xs text-muted-foreground">{p.songCount} songs</div>
</div>
{expandedPlaylist === p.id ? (
<ChevronDown className="h-4 w-4 text-muted-foreground flex-shrink-0" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground flex-shrink-0" />
)}
</button>
{/* Download entire playlist */}
{expandedPlaylist === p.id && playlistSongs.length > 0 && (
<button
onClick={(e) => {
e.stopPropagation()
playlistSongs.forEach((s) => {
if (!offlineIds.has(s.id)) downloadTrack(s)
})
}}
className={`p-2 mr-2 rounded-full transition-colors flex-shrink-0 ${
allOffline
? 'text-green-500'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
}`}
title={allOffline ? 'All songs downloaded' : 'Download all for offline'}
disabled={allOffline}
>
{allOffline ? (
<CheckCircle className="h-5 w-5" />
) : (
<DownloadCloud className="h-5 w-5" />
)}
</button>
)}
</div>
{expandedPlaylist === p.id && (
<div className="bg-muted/30 border-t border-border">
{playlistSongsLoading ? (
<div className="flex justify-center py-6">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : playlistSongs.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-6">
Empty playlist
</p>
) : (
<div className="divide-y divide-border">
{playlistSongs.map((song, i) => (
<SongRow key={song.id} song={song} songs={playlistSongs} index={i} showDownload />
))}
</div>
)}
</div>
)}
</div>
)})}
</div>
)}
</div>
)}
{/* Info */}
<div className="mt-12 p-6 bg-muted/50 rounded-lg space-y-3">
<h3 className="font-bold mb-2">How does this work?</h3>
<p className="text-sm text-muted-foreground">
This searches your Navidrome music library. Songs play directly in the browser through a
persistent audio player. Can&apos;t find what you&apos;re looking for? Search Soulseek to find
and download music from the peer-to-peer network.
</p>
</div>
</div>
</div>
</div>
)
}

View File

@ -1,179 +0,0 @@
'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,
ListPlus,
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, addAllToQueue } = 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 (
<div className={`min-h-screen bg-background ${hasPlayer ? 'pb-20' : ''}`}>
{/* Header */}
<div className="border-b border-border">
<div className="container mx-auto px-4 py-4 flex items-center justify-between">
<Link href="/" className="inline-block">
<JefflixLogo size="small" />
</Link>
<Link href="/music">
<Button variant="ghost" size="sm">
<ArrowLeft className="h-4 w-4 mr-1.5" />
Music
</Button>
</Link>
</div>
</div>
<div className="container mx-auto px-4 py-12 md:py-16">
<div className="max-w-2xl mx-auto">
{/* Hero */}
<div className="text-center space-y-4 mb-8">
<div className="inline-block p-4 bg-blue-100 dark:bg-blue-900/30 rounded-full">
<WifiOff className="h-10 w-10 text-blue-600 dark:text-blue-400" />
</div>
<h1 className="text-3xl font-bold font-marker">Offline Library</h1>
<p className="text-muted-foreground">
Songs downloaded for offline playback. Syncs across all your devices.
</p>
</div>
{/* Stats + Actions */}
<div className="flex flex-wrap items-center gap-3 mb-6">
<div className="flex items-center gap-2 text-sm text-muted-foreground bg-muted/50 px-3 py-1.5 rounded-lg">
<HardDrive className="h-4 w-4" />
{formatBytes(storageUsed)} used
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground bg-muted/50 px-3 py-1.5 rounded-lg">
<Download className="h-4 w-4" />
{offlineTracks.length} songs
</div>
<div className="flex-1" />
<Button variant="outline" size="sm" onClick={handleSync} disabled={syncing}>
{syncing ? <Loader2 className="h-4 w-4 animate-spin mr-1.5" /> : <RefreshCw className="h-4 w-4 mr-1.5" />}
Sync
</Button>
{offlineTracks.length > 0 && (
<>
{state.currentTrack ? (
<Button size="sm" variant="outline" onClick={() => addAllToQueue(offlineTracks)}>
<ListPlus className="h-4 w-4 mr-1.5" />
Add All to Queue
</Button>
) : (
<Button size="sm" onClick={playAllOffline}>
<Play className="h-4 w-4 mr-1.5" />
Play All
</Button>
)}
<Button variant="destructive" size="sm" onClick={handleClearAll} disabled={clearing}>
{clearing ? <Loader2 className="h-4 w-4 animate-spin mr-1.5" /> : <Trash2 className="h-4 w-4 mr-1.5" />}
Clear All
</Button>
</>
)}
</div>
{/* Download queue */}
{queue.length > 0 && (
<div className="mb-6 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<h3 className="text-sm font-semibold mb-2 flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin text-blue-600" />
Downloading ({queue.length} remaining)
</h3>
<div className="space-y-1 text-sm text-muted-foreground">
{queue.slice(0, 5).map((t) => (
<div key={t.id} className="flex items-center gap-2">
{t.id === activeDownloadId && <Loader2 className="h-3 w-3 animate-spin" />}
<span className="truncate">{t.title} {t.artist}</span>
</div>
))}
{queue.length > 5 && (
<div className="text-xs">...and {queue.length - 5} more</div>
)}
</div>
</div>
)}
{/* Songs list */}
{loading ? (
<div className="flex justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-blue-600" />
</div>
) : offlineTracks.length === 0 ? (
<div className="text-center py-12 space-y-4">
<WifiOff className="h-12 w-12 mx-auto text-muted-foreground/30" />
<p className="text-muted-foreground">
No songs downloaded yet. Tap the download icon on any song to save it for offline.
</p>
<Link href="/music">
<Button variant="outline">
Browse Music
</Button>
</Link>
</div>
) : (
<div className="border border-border rounded-lg divide-y divide-border overflow-hidden">
{offlineTracks.map((song, i) => (
<SongRow key={song.id} song={song} songs={offlineTracks} index={i} showDownload />
))}
</div>
)}
</div>
</div>
</div>
)
}

View File

@ -1,6 +1,6 @@
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Film, Music, Server, Users, Heart, Rocket, Tv, Home, FolderGitIcon as SolidarityFistIcon, HandHeart, Landmark, Palette, Radio, ListPlus, Play, Upload, Waves } from "lucide-react"
import { Film, Music, Server, Users, Heart, Rocket, Tv, Home, FolderGitIcon as SolidarityFistIcon, HandHeart, Landmark, Palette } from "lucide-react"
import { JefflixLogo } from "@/components/jefflix-logo"
export default function JefflixPage() {
@ -48,80 +48,34 @@ export default function JefflixPage() {
<Button
asChild
size="lg"
className="text-lg px-8 py-6 font-bold bg-blue-600 hover:bg-blue-700 text-white"
className="text-lg px-8 py-6 font-bold bg-red-600 hover:bg-red-700 text-white"
variant="default"
>
<a href="https://requests.jefflix.lol">
<ListPlus className="mr-2 h-5 w-5" />
Request a Show or Movie
<a href="https://movies.jefflix.lol">
<Film className="mr-2 h-5 w-5" />
Movies
</a>
</Button>
<Button
asChild
size="lg"
className="text-lg px-8 py-6 font-bold bg-red-600 hover:bg-red-700 text-white"
className="text-lg px-8 py-6 font-bold bg-blue-600 hover:bg-blue-700 text-white"
variant="default"
>
<a href="https://movies.jefflix.lol">
<Play className="mr-2 h-5 w-5" />
Watch a Show or Movie
<a href="https://shows.jefflix.lol">
<Tv className="mr-2 h-5 w-5" />
Shows
</a>
</Button>
<Button
asChild
size="lg"
className="text-lg px-8 py-6 font-bold bg-green-600 hover:bg-green-700 text-white"
variant="default"
variant="outline"
>
<a href="https://upload.jefflix.lol">
<Upload className="mr-2 h-5 w-5" />
Upload Shows or Movies
</a>
</Button>
</div>
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
<Button
asChild
size="lg"
className="text-lg px-8 py-6 font-bold bg-orange-600 hover:bg-orange-700 text-white"
variant="default"
>
<a href="https://tv.jefflix.lol">
<Tv className="mr-2 h-5 w-5" />
Watch Live TV
</a>
</Button>
<Button
asChild
size="lg"
className="text-lg px-8 py-6 font-bold bg-cyan-600 hover:bg-cyan-700 text-white"
variant="default"
>
<a href="/request-channel">
<Radio className="mr-2 h-5 w-5" />
Request a Channel
</a>
</Button>
<Button
asChild
size="lg"
className="text-lg px-8 py-6 font-bold bg-purple-600 hover:bg-purple-700 text-white"
variant="default"
>
<a href="/music">
<a href="https://music.jefflix.lol">
<Music className="mr-2 h-5 w-5" />
Listen to Music
</a>
</Button>
<Button
asChild
size="lg"
className="text-lg px-8 py-6 font-bold bg-rose-600 hover:bg-rose-700 text-white"
variant="default"
>
<a href="/radio">
<Waves className="mr-2 h-5 w-5" />
Listen to Radio
Music
</a>
</Button>
</div>
@ -250,20 +204,20 @@ export default function JefflixPage() {
</p>
</div>
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center pt-6">
<Button asChild size="lg" className="text-lg px-8 py-6 font-bold bg-blue-600 hover:bg-blue-700 text-white">
<a href="https://requests.jefflix.lol">
<ListPlus className="mr-2 h-5 w-5" />
Request a Show or Movie
<Button asChild size="lg" className="text-lg px-8 py-6 font-bold bg-red-600 hover:bg-red-700 text-white">
<a href="https://movies.jefflix.lol">
<Film className="mr-2 h-5 w-5" />
Movies
</a>
</Button>
<Button
asChild
size="lg"
className="text-lg px-8 py-6 font-bold bg-red-600 hover:bg-red-700 text-white"
className="text-lg px-8 py-6 font-bold bg-blue-600 hover:bg-blue-700 text-white"
>
<a href="https://movies.jefflix.lol">
<Play className="mr-2 h-5 w-5" />
Watch a Show or Movie
<a href="https://shows.jefflix.lol">
<Tv className="mr-2 h-5 w-5" />
Shows
</a>
</Button>
<Button
@ -271,35 +225,9 @@ export default function JefflixPage() {
size="lg"
className="text-lg px-8 py-6 font-bold bg-green-600 hover:bg-green-700 text-white"
>
<a href="https://upload.jefflix.lol">
<Upload className="mr-2 h-5 w-5" />
Upload Shows or Movies
</a>
</Button>
</div>
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
<Button asChild size="lg" className="text-lg px-8 py-6 font-bold bg-orange-600 hover:bg-orange-700 text-white">
<a href="https://tv.jefflix.lol">
<Tv className="mr-2 h-5 w-5" />
Watch Live TV
</a>
</Button>
<Button asChild size="lg" className="text-lg px-8 py-6 font-bold bg-cyan-600 hover:bg-cyan-700 text-white">
<a href="/request-channel">
<Radio className="mr-2 h-5 w-5" />
Request a Channel
</a>
</Button>
<Button asChild size="lg" className="text-lg px-8 py-6 font-bold bg-purple-600 hover:bg-purple-700 text-white">
<a href="/music">
<a href="https://music.jefflix.lol">
<Music className="mr-2 h-5 w-5" />
Listen to Music
</a>
</Button>
<Button asChild size="lg" className="text-lg px-8 py-6 font-bold bg-rose-600 hover:bg-rose-700 text-white">
<a href="/radio">
<Waves className="mr-2 h-5 w-5" />
Listen to Radio
Music
</a>
</Button>
</div>
@ -393,7 +321,7 @@ export default function JefflixPage() {
Powered by Jellyfin. Built on solidarity. Infrastructure for the sharing economystarting digitally.
</p>
<div className="pt-4 text-sm text-muted-foreground">
© 2026 Support artists Build movements Question everything
© 2025 Support artists Build movements Question everything
</div>
</div>
</div>

View File

@ -1,403 +0,0 @@
'use client'
import { useState, useEffect, useRef, useCallback } from 'react'
import { useMusicPlayer, type RadioTrack } from '@/components/music/music-provider'
import { GlobeLoader, type GlobePoint } from '@/components/globe/globe-loader'
import { JefflixLogo } from '@/components/jefflix-logo'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
ArrowLeft,
Search,
Radio,
Loader2,
Play,
MapPin,
Globe2,
X,
} from 'lucide-react'
import Link from 'next/link'
import type { RadioPlace, RadioChannel } from '@/lib/radio'
import { getPlaces, getChannels, resolveStreamUrl, searchRadio } from '@/lib/radio'
export default function RadioPage() {
const { state, playTrack } = useMusicPlayer()
// Globe data
const [places, setPlaces] = useState<RadioPlace[]>([])
const [loadingPlaces, setLoadingPlaces] = useState(true)
const [placesError, setPlacesError] = useState('')
// Selected place & channels
const [selectedPlace, setSelectedPlace] = useState<RadioPlace | null>(null)
const [channels, setChannels] = useState<RadioChannel[]>([])
const [loadingChannels, setLoadingChannels] = useState(false)
// Search
const [query, setQuery] = useState('')
const [debouncedQuery, setDebouncedQuery] = useState('')
const [searchResults, setSearchResults] = useState<{
stations: Array<{ id: string; title: string; placeId: string; placeTitle: string; country: string }>
places: Array<{ id: string; title: string; country: string }>
} | null>(null)
const [searching, setSearching] = useState(false)
const debounceRef = useRef<NodeJS.Timeout>(null)
// Playing state
const [resolvingId, setResolvingId] = useState<string | null>(null)
// Globe focus
const [focusLat, setFocusLat] = useState<number | undefined>(undefined)
const [focusLng, setFocusLng] = useState<number | undefined>(undefined)
// Load places on mount
useEffect(() => {
getPlaces()
.then((data) => {
setPlaces(data)
setLoadingPlaces(false)
})
.catch((err) => {
setPlacesError(err.message)
setLoadingPlaces(false)
})
}, [])
// Debounce search
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => setDebouncedQuery(query), 300)
return () => { if (debounceRef.current) clearTimeout(debounceRef.current) }
}, [query])
// Execute search
useEffect(() => {
if (debouncedQuery.length < 2) {
setSearchResults(null)
return
}
setSearching(true)
searchRadio(debouncedQuery)
.then(setSearchResults)
.catch(() => setSearchResults(null))
.finally(() => setSearching(false))
}, [debouncedQuery])
// Convert places to globe points
const globePoints: GlobePoint[] = places.map((p) => ({
lat: p.lat,
lng: p.lng,
id: p.id,
label: `${p.title}, ${p.country}`,
color: '#f43f5e', // rose-500
size: p.size,
}))
// Handle globe point click
const handlePointClick = useCallback((point: GlobePoint) => {
const place = places.find((p) => p.id === point.id)
if (!place) return
setSelectedPlace(place)
setFocusLat(place.lat)
setFocusLng(place.lng)
setLoadingChannels(true)
getChannels(place.id)
.then(setChannels)
.catch(() => setChannels([]))
.finally(() => setLoadingChannels(false))
}, [places])
// Handle search place click
const handleSearchPlaceClick = useCallback((placeId: string, placeTitle: string, country: string) => {
// Find in places array for geo data, or just load channels
const place = places.find((p) => p.id === placeId)
if (place) {
setSelectedPlace(place)
setFocusLat(place.lat)
setFocusLng(place.lng)
} else {
setSelectedPlace({ id: placeId, title: placeTitle, country, lat: 0, lng: 0, size: 1 })
}
setQuery('')
setSearchResults(null)
setLoadingChannels(true)
getChannels(placeId)
.then(setChannels)
.catch(() => setChannels([]))
.finally(() => setLoadingChannels(false))
}, [places])
// Play a station
const handlePlayStation = useCallback(async (channel: RadioChannel) => {
setResolvingId(channel.id)
try {
const streamUrl = await resolveStreamUrl(channel.id)
const radioTrack: RadioTrack = {
type: 'radio',
id: `radio:${channel.id}`,
title: channel.title,
artist: `${channel.placeTitle}, ${channel.country}`,
album: '',
albumId: '',
duration: 0,
coverArt: '',
streamUrl,
}
playTrack(radioTrack)
} catch (err) {
console.error('Failed to play station:', err)
} finally {
setResolvingId(null)
}
}, [playTrack])
// Play search station directly
const handlePlaySearchStation = useCallback(async (station: { id: string; title: string; placeTitle: string; country: string }) => {
setResolvingId(station.id)
try {
const streamUrl = await resolveStreamUrl(station.id)
const radioTrack: RadioTrack = {
type: 'radio',
id: `radio:${station.id}`,
title: station.title,
artist: `${station.placeTitle}, ${station.country}`,
album: '',
albumId: '',
duration: 0,
coverArt: '',
streamUrl,
}
playTrack(radioTrack)
} catch (err) {
console.error('Failed to play station:', err)
} finally {
setResolvingId(null)
}
}, [playTrack])
const isPlaying = (channelId: string) =>
state.currentTrack?.id === `radio:${channelId}` && state.isPlaying
return (
<div className={`min-h-screen bg-background ${state.currentTrack ? 'pb-20' : ''}`}>
{/* Header */}
<div className="border-b border-border bg-card/50 backdrop-blur-sm sticky top-0 z-40">
<div className="container mx-auto px-4 py-3 flex items-center gap-4">
<Link href="/" className="flex-shrink-0">
<JefflixLogo />
</Link>
<div className="flex-1" />
<Link href="/">
<Button variant="ghost" size="sm">
<ArrowLeft className="h-4 w-4 mr-1" />
Home
</Button>
</Link>
</div>
</div>
{/* Hero */}
<div className="container mx-auto px-4 py-6 text-center">
<div className="flex items-center justify-center gap-3 mb-2">
<Radio className="h-8 w-8 text-rose-500" />
<h1 className="text-3xl font-bold">World Radio</h1>
</div>
<p className="text-muted-foreground max-w-lg mx-auto">
Explore live radio stations from around the globe. Click a point on the globe or search for stations.
</p>
</div>
{/* Main content */}
<div className="container mx-auto px-4 pb-8">
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
{/* Globe */}
<div className="lg:col-span-3 relative">
<div className="rounded-xl border border-border bg-card overflow-hidden">
{loadingPlaces ? (
<div className="flex items-center justify-center h-[400px] lg:h-[550px]">
<div className="flex flex-col items-center gap-3 text-muted-foreground">
<Loader2 className="h-8 w-8 animate-spin" />
<span className="text-sm">Loading {places.length > 0 ? `${places.length.toLocaleString()} stations` : 'stations'}...</span>
</div>
</div>
) : placesError ? (
<div className="flex items-center justify-center h-[400px] lg:h-[550px] text-destructive">
<p>Failed to load stations: {placesError}</p>
</div>
) : (
<GlobeLoader
points={globePoints}
onPointClick={handlePointClick}
height={typeof window !== 'undefined' && window.innerWidth >= 1024 ? 550 : 400}
autoRotate={!selectedPlace}
focusLat={focusLat}
focusLng={focusLng}
/>
)}
</div>
{places.length > 0 && (
<p className="text-xs text-muted-foreground text-center mt-2">
<Globe2 className="h-3 w-3 inline mr-1" />
{places.length.toLocaleString()} locations worldwide
</p>
)}
</div>
{/* Station panel */}
<div className="lg:col-span-2">
{/* Search */}
<div className="relative mb-4">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search stations, cities, countries..."
className="w-full pl-10 pr-10 py-2.5 rounded-lg border border-border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-rose-500/50"
/>
{query && (
<button
onClick={() => { setQuery(''); setSearchResults(null) }}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{/* Search results */}
{searchResults && (
<div className="rounded-lg border border-border bg-card mb-4">
<div className="px-4 py-2 border-b border-border">
<h3 className="text-sm font-medium text-muted-foreground">
{searching ? 'Searching...' : `${searchResults.stations.length} stations, ${searchResults.places.length} places`}
</h3>
</div>
<ScrollArea className="max-h-[300px]">
{/* Station results */}
{searchResults.stations.map((station) => (
<button
key={station.id}
onClick={() => handlePlaySearchStation(station)}
disabled={resolvingId === station.id}
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-muted/50 transition-colors text-left border-b border-border/50 last:border-0"
>
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-rose-500/10 flex items-center justify-center">
{resolvingId === station.id ? (
<Loader2 className="h-4 w-4 animate-spin text-rose-500" />
) : isPlaying(station.id) ? (
<div className="flex items-center gap-0.5">
<div className="w-0.5 h-3 bg-rose-500 animate-pulse rounded-full" />
<div className="w-0.5 h-4 bg-rose-500 animate-pulse rounded-full" style={{ animationDelay: '150ms' }} />
<div className="w-0.5 h-2 bg-rose-500 animate-pulse rounded-full" style={{ animationDelay: '300ms' }} />
</div>
) : (
<Play className="h-3.5 w-3.5 text-rose-500 ml-0.5" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{station.title}</div>
<div className="text-xs text-muted-foreground truncate">
{station.placeTitle}, {station.country}
</div>
</div>
<Radio className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
</button>
))}
{/* Place results */}
{searchResults.places.map((place) => (
<button
key={place.id}
onClick={() => handleSearchPlaceClick(place.id, place.title, place.country)}
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-muted/50 transition-colors text-left border-b border-border/50 last:border-0"
>
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-blue-500/10 flex items-center justify-center">
<MapPin className="h-3.5 w-3.5 text-blue-500" />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{place.title}</div>
<div className="text-xs text-muted-foreground">{place.country}</div>
</div>
<Badge variant="secondary" className="text-[10px]">Place</Badge>
</button>
))}
</ScrollArea>
</div>
)}
{/* Selected place channels */}
{selectedPlace && !searchResults && (
<div className="rounded-lg border border-border bg-card">
<div className="px-4 py-3 border-b border-border flex items-center gap-2">
<MapPin className="h-4 w-4 text-rose-500" />
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold truncate">{selectedPlace.title}</h3>
<p className="text-xs text-muted-foreground">{selectedPlace.country}</p>
</div>
<button
onClick={() => { setSelectedPlace(null); setChannels([]) }}
className="text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
</div>
{loadingChannels ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : channels.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
No stations found in this area
</div>
) : (
<ScrollArea className="max-h-[400px]">
{channels.map((channel) => (
<button
key={channel.id}
onClick={() => handlePlayStation(channel)}
disabled={resolvingId === channel.id}
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-muted/50 transition-colors text-left border-b border-border/50 last:border-0"
>
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-rose-500/10 flex items-center justify-center">
{resolvingId === channel.id ? (
<Loader2 className="h-4 w-4 animate-spin text-rose-500" />
) : isPlaying(channel.id) ? (
<div className="flex items-center gap-0.5">
<div className="w-0.5 h-3 bg-rose-500 animate-pulse rounded-full" />
<div className="w-0.5 h-5 bg-rose-500 animate-pulse rounded-full" style={{ animationDelay: '150ms' }} />
<div className="w-0.5 h-2.5 bg-rose-500 animate-pulse rounded-full" style={{ animationDelay: '300ms' }} />
</div>
) : (
<Play className="h-5 w-5 text-rose-500 ml-0.5" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{channel.title}</div>
<div className="text-xs text-muted-foreground truncate">
{channel.placeTitle}, {channel.country}
</div>
</div>
</button>
))}
</ScrollArea>
)}
</div>
)}
{/* Empty state */}
{!selectedPlace && !searchResults && (
<div className="rounded-lg border border-border bg-card px-6 py-12 text-center">
<Globe2 className="h-12 w-12 text-muted-foreground/30 mx-auto mb-4" />
<h3 className="font-medium mb-1">Explore World Radio</h3>
<p className="text-sm text-muted-foreground">
Click a point on the globe to discover radio stations, or use the search bar to find specific stations and cities.
</p>
</div>
)}
</div>
</div>
</div>
</div>
)
}

View File

@ -1,186 +0,0 @@
'use client'
import { useState } from 'react'
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { JefflixLogo } from "@/components/jefflix-logo"
import { UserPlus, CheckCircle, AlertCircle, ArrowLeft } from "lucide-react"
import Link from 'next/link'
export default function RequestAccessPage() {
const [formData, setFormData] = useState({
name: '',
email: '',
reason: '',
})
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle')
const [errorMessage, setErrorMessage] = useState('')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setStatus('loading')
setErrorMessage('')
try {
const response = await fetch('/api/request-access', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(formData),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to submit request')
}
setStatus('success')
} catch (error) {
setStatus('error')
setErrorMessage(error instanceof Error ? error.message : 'Something went wrong')
}
}
if (status === 'success') {
return (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<div className="max-w-md w-full text-center space-y-6">
<div className="inline-block p-6 bg-green-100 dark:bg-green-900/30 rounded-full">
<CheckCircle className="h-12 w-12 text-green-600 dark:text-green-400" />
</div>
<h1 className="text-2xl font-bold">Request Submitted!</h1>
<p className="text-muted-foreground">
Your access request has been sent. You'll receive an email once your account is ready.
</p>
<p className="text-sm text-muted-foreground">
This usually happens within 24-48 hours.
</p>
<div className="text-left bg-muted/50 rounded-lg p-6 mt-4 space-y-3">
<h3 className="font-bold text-center">Once you're approved:</h3>
<ul className="text-sm text-muted-foreground space-y-2 list-disc list-inside">
<li>Request movies & shows at <a href="https://requests.jefflix.lol" className="text-blue-600 hover:underline font-medium">requests.jefflix.lol</a></li>
<li>Watch them at <a href="https://movies.jefflix.lol" className="text-red-600 hover:underline font-medium">movies.jefflix.lol</a></li>
<li>Both sites can be installed as apps on your phone's home screen, or use the Jellyfin app on a smart TV</li>
</ul>
</div>
<Link href="/">
<Button variant="outline" className="mt-4">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Jefflix
</Button>
</Link>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-background">
{/* Header */}
<div className="border-b border-border">
<div className="container mx-auto px-4 py-4">
<Link href="/" className="inline-block">
<JefflixLogo size="small" />
</Link>
</div>
</div>
{/* Main Content */}
<div className="container mx-auto px-4 py-12 md:py-20">
<div className="max-w-lg mx-auto">
<div className="text-center space-y-4 mb-8">
<div className="inline-block p-4 bg-orange-100 dark:bg-orange-900/30 rounded-full">
<UserPlus className="h-10 w-10 text-orange-600 dark:text-orange-400" />
</div>
<h1 className="text-3xl font-bold font-marker">Request Access</h1>
<p className="text-muted-foreground">
Jefflix is a community media server. To protect the community and ensure quality access,
we require approval for new accounts.
</p>
<Badge className="bg-orange-600 text-white">
Required for Live Sports & Premium Content
</Badge>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-2">
<label htmlFor="name" className="text-sm font-medium">
Your Name *
</label>
<input
type="text"
id="name"
required
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-4 py-3 rounded-lg border border-border bg-background focus:outline-none focus:ring-2 focus:ring-orange-500"
placeholder="Enter your name"
/>
</div>
<div className="space-y-2">
<label htmlFor="email" className="text-sm font-medium">
Email Address *
</label>
<input
type="email"
id="email"
required
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="w-full px-4 py-3 rounded-lg border border-border bg-background focus:outline-none focus:ring-2 focus:ring-orange-500"
placeholder="your@email.com"
/>
<p className="text-xs text-muted-foreground">
We'll send your login details to this address
</p>
</div>
<div className="space-y-2">
<label htmlFor="reason" className="text-sm font-medium">
How do you know Jeff? (optional)
</label>
<textarea
id="reason"
value={formData.reason}
onChange={(e) => setFormData({ ...formData, reason: e.target.value })}
className="w-full px-4 py-3 rounded-lg border border-border bg-background focus:outline-none focus:ring-2 focus:ring-orange-500 min-h-[100px]"
placeholder="Just helps me know who's joining the community..."
/>
</div>
{status === 'error' && (
<div className="flex items-center gap-2 p-4 bg-red-100 dark:bg-red-900/30 rounded-lg text-red-600 dark:text-red-400">
<AlertCircle className="h-5 w-5 flex-shrink-0" />
<p className="text-sm">{errorMessage}</p>
</div>
)}
<Button
type="submit"
disabled={status === 'loading'}
className="w-full py-6 text-lg font-bold bg-orange-600 hover:bg-orange-700 text-white"
>
{status === 'loading' ? 'Submitting...' : 'Request Access'}
</Button>
</form>
<div className="mt-8 p-6 bg-muted/50 rounded-lg space-y-4">
<h3 className="font-bold mb-2">What happens next?</h3>
<ol className="text-sm text-muted-foreground space-y-2 list-decimal list-inside">
<li>Your request is sent to the admin for review</li>
<li>Once approved, you'll get an email with your login details</li>
<li>Request movies & shows at <a href="https://requests.jefflix.lol" className="text-blue-600 hover:underline font-medium">requests.jefflix.lol</a></li>
<li>Watch them at <a href="https://movies.jefflix.lol" className="text-red-600 hover:underline font-medium">movies.jefflix.lol</a></li>
</ol>
<p className="text-xs text-muted-foreground border-t border-border pt-3">
Both sites can be installed as apps on your phone's home screen. On a smart TV, use the Jellyfin app and connect to movies.jefflix.lol.
</p>
</div>
</div>
</div>
</div>
)
}

View File

@ -1,486 +0,0 @@
'use client'
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { JefflixLogo } from "@/components/jefflix-logo"
import { GlobeLoader, type GlobePoint } from "@/components/globe/globe-loader"
import { countryToLatLng } from "@/lib/country-centroids"
import { Search, CheckCircle, AlertCircle, ArrowLeft, X, Loader2, Globe2, List } from "lucide-react"
import Link from 'next/link'
interface Channel {
id: string
name: string
country: string
categories: string[]
}
export default function RequestChannelPage() {
const [channels, setChannels] = useState<Channel[]>([])
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState('')
const [query, setQuery] = useState('')
const [debouncedQuery, setDebouncedQuery] = useState('')
const [selected, setSelected] = useState<Channel[]>([])
const [email, setEmail] = useState('')
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle')
const [errorMessage, setErrorMessage] = useState('')
const debounceRef = useRef<NodeJS.Timeout>(null)
// Globe view toggle
const [view, setView] = useState<'search' | 'globe'>('search')
const [globeCountry, setGlobeCountry] = useState<string | null>(null)
// Build globe points from channels (aggregate by country)
const globePoints: GlobePoint[] = useMemo(() => {
const countryMap = new Map<string, { count: number; coords: [number, number] }>()
for (const ch of channels) {
if (!ch.country) continue
const existing = countryMap.get(ch.country)
if (existing) {
existing.count++
} else {
const coords = countryToLatLng(ch.country)
if (coords) {
countryMap.set(ch.country, { count: 1, coords })
}
}
}
return Array.from(countryMap.entries()).map(([country, { count, coords }]) => ({
lat: coords[0],
lng: coords[1],
id: country,
label: `${country} (${count} channels)`,
color: '#06b6d4', // cyan-500
size: Math.max(1, Math.min(8, Math.log2(count + 1) * 2)),
}))
}, [channels])
// Channels filtered for globe-selected country
const globeFilteredChannels = useMemo(() => {
if (!globeCountry) return []
return channels.filter((ch) => ch.country === globeCountry)
}, [channels, globeCountry])
const handleGlobePointClick = useCallback((point: GlobePoint) => {
setGlobeCountry(point.id) // id = country name
}, [])
// Fetch channel list on mount
useEffect(() => {
fetch('/api/channels')
.then((res) => {
if (!res.ok) throw new Error('Failed to load channels')
return res.json()
})
.then((data: Channel[]) => {
setChannels(data)
setLoading(false)
})
.catch((err) => {
setLoadError(err.message)
setLoading(false)
})
}, [])
// Debounce search input
useEffect(() => {
debounceRef.current = setTimeout(() => setDebouncedQuery(query), 200)
return () => { if (debounceRef.current) clearTimeout(debounceRef.current) }
}, [query])
const results = debouncedQuery.length < 2
? []
: channels.filter((ch) => {
const q = debouncedQuery.toLowerCase()
return (
ch.name.toLowerCase().includes(q) ||
ch.country.toLowerCase().includes(q) ||
ch.categories.some((c) => c.toLowerCase().includes(q))
)
})
const totalMatches = results.length
const displayResults = results.slice(0, 50)
const toggleChannel = useCallback((ch: Channel) => {
setSelected((prev) =>
prev.some((s) => s.id === ch.id)
? prev.filter((s) => s.id !== ch.id)
: prev.length < 20
? [...prev, ch]
: prev
)
}, [])
const removeChannel = useCallback((id: string) => {
setSelected((prev) => prev.filter((s) => s.id !== id))
}, [])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (selected.length === 0) return
setStatus('submitting')
setErrorMessage('')
try {
const res = await fetch('/api/request-channel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email,
channels: selected.map(({ id, name, country, categories }) => ({
id, name, country, categories,
})),
}),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Failed to submit request')
setStatus('success')
} catch (err) {
setStatus('error')
setErrorMessage(err instanceof Error ? err.message : 'Something went wrong')
}
}
if (status === 'success') {
return (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<div className="max-w-md w-full text-center space-y-6">
<div className="inline-block p-6 bg-green-100 dark:bg-green-900/30 rounded-full">
<CheckCircle className="h-12 w-12 text-green-600 dark:text-green-400" />
</div>
<h1 className="text-2xl font-bold">Request Submitted!</h1>
<p className="text-muted-foreground">
We&apos;ll look into adding {selected.length === 1 ? 'this channel' : `these ${selected.length} channels`} and
let you know once {selected.length === 1 ? "it's" : "they're"} available.
</p>
<p className="text-sm text-muted-foreground">
Most channels are added within a few days.
</p>
<Link href="/">
<Button variant="outline" className="mt-4">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Jefflix
</Button>
</Link>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-background">
{/* Header */}
<div className="border-b border-border">
<div className="container mx-auto px-4 py-4">
<Link href="/" className="inline-block">
<JefflixLogo size="small" />
</Link>
</div>
</div>
{/* Main Content */}
<div className="container mx-auto px-4 py-12 md:py-20">
<div className="max-w-2xl mx-auto">
<div className="text-center space-y-4 mb-8">
<div className="inline-block p-4 bg-cyan-100 dark:bg-cyan-900/30 rounded-full">
<Search className="h-10 w-10 text-cyan-600 dark:text-cyan-400" />
</div>
<h1 className="text-3xl font-bold font-marker">Request a Channel</h1>
<p className="text-muted-foreground">
Search the iptv-org catalog and select channels you&apos;d like us to add to the Live TV lineup.
</p>
{/* View toggle */}
{!loading && !loadError && (
<div className="flex items-center justify-center gap-2">
<Button
variant={view === 'search' ? 'default' : 'outline'}
size="sm"
onClick={() => setView('search')}
className={view === 'search' ? 'bg-cyan-600 hover:bg-cyan-700' : ''}
>
<List className="h-4 w-4 mr-1.5" />
Search
</Button>
<Button
variant={view === 'globe' ? 'default' : 'outline'}
size="sm"
onClick={() => setView('globe')}
className={view === 'globe' ? 'bg-cyan-600 hover:bg-cyan-700' : ''}
>
<Globe2 className="h-4 w-4 mr-1.5" />
Globe
</Button>
</div>
)}
</div>
{loading ? (
<div className="flex flex-col items-center gap-4 py-16">
<Loader2 className="h-8 w-8 animate-spin text-cyan-600" />
<p className="text-muted-foreground">Loading channel catalog...</p>
</div>
) : loadError ? (
<div className="flex flex-col items-center gap-4 py-16">
<AlertCircle className="h-8 w-8 text-red-500" />
<p className="text-muted-foreground">{loadError}</p>
<Button variant="outline" onClick={() => window.location.reload()}>
Try Again
</Button>
</div>
) : view === 'globe' ? (
/* Globe View */
<div className="space-y-6">
<div className="rounded-xl border border-border bg-card overflow-hidden">
<GlobeLoader
points={globePoints}
onPointClick={handleGlobePointClick}
height={450}
autoRotate={!globeCountry}
/>
</div>
{/* Selected chips (shared with search view) */}
{selected.length > 0 && (
<div className="flex flex-wrap gap-2">
{selected.map((ch) => (
<Badge
key={ch.id}
className="bg-cyan-600 text-white pl-3 pr-1 py-1.5 text-sm flex items-center gap-1 cursor-pointer hover:bg-cyan-700"
onClick={() => removeChannel(ch.id)}
>
{ch.name}
<X className="h-3.5 w-3.5 ml-1" />
</Badge>
))}
</div>
)}
{/* Country channel list */}
{globeCountry && (
<div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border flex items-center justify-between">
<div>
<h3 className="font-semibold">{globeCountry}</h3>
<p className="text-xs text-muted-foreground">{globeFilteredChannels.length} channels</p>
</div>
<button onClick={() => setGlobeCountry(null)} className="text-muted-foreground hover:text-foreground">
<X className="h-4 w-4" />
</button>
</div>
<div className="max-h-[300px] overflow-y-auto divide-y divide-border">
{globeFilteredChannels.map((ch) => {
const isSelected = selected.some((s) => s.id === ch.id)
return (
<button
key={ch.id}
type="button"
onClick={() => toggleChannel(ch)}
className={`w-full text-left px-4 py-3 flex items-center justify-between gap-3 hover:bg-muted/50 transition-colors ${
isSelected ? 'bg-cyan-50 dark:bg-cyan-950/30' : ''
}`}
>
<div className="min-w-0">
<div className="font-medium truncate">{ch.name}</div>
<div className="flex flex-wrap gap-1 mt-1">
{ch.categories.map((cat) => (
<Badge key={cat} variant="secondary" className="text-xs">{cat}</Badge>
))}
</div>
</div>
{isSelected && <CheckCircle className="h-5 w-5 text-cyan-600 flex-shrink-0" />}
</button>
)
})}
</div>
</div>
)}
{!globeCountry && (
<p className="text-center text-sm text-muted-foreground">
Click a point on the globe to browse channels by country
</p>
)}
{/* Email + Submit (same as search view) */}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<label htmlFor="globe-email" className="text-sm font-medium">Your Email</label>
<input
type="email"
id="globe-email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-4 py-3 rounded-lg border border-border bg-background focus:outline-none focus:ring-2 focus:ring-cyan-500"
placeholder="your@email.com"
/>
</div>
{status === 'error' && (
<div className="flex items-center gap-2 p-4 bg-red-100 dark:bg-red-900/30 rounded-lg text-red-600 dark:text-red-400">
<AlertCircle className="h-5 w-5 flex-shrink-0" />
<p className="text-sm">{errorMessage}</p>
</div>
)}
<Button
type="submit"
disabled={status === 'submitting' || selected.length === 0}
className="w-full py-6 text-lg font-bold bg-cyan-600 hover:bg-cyan-700 text-white disabled:opacity-50"
>
{status === 'submitting'
? 'Submitting...'
: selected.length === 0
? 'Select channels to request'
: `Request ${selected.length} Channel${selected.length > 1 ? 's' : ''}`}
</Button>
</form>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Search input */}
<div className="relative">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 h-5 w-5 text-muted-foreground" />
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="w-full pl-12 pr-4 py-3 rounded-lg border border-border bg-background focus:outline-none focus:ring-2 focus:ring-cyan-500"
placeholder="Search by channel name, country, or category..."
autoFocus
/>
</div>
{/* Selected chips */}
{selected.length > 0 && (
<div className="flex flex-wrap gap-2">
{selected.map((ch) => (
<Badge
key={ch.id}
className="bg-cyan-600 text-white pl-3 pr-1 py-1.5 text-sm flex items-center gap-1 cursor-pointer hover:bg-cyan-700"
onClick={() => removeChannel(ch.id)}
>
{ch.name}
<X className="h-3.5 w-3.5 ml-1" />
</Badge>
))}
</div>
)}
{/* Results */}
{debouncedQuery.length >= 2 && (
<div className="border border-border rounded-lg divide-y divide-border max-h-[400px] overflow-y-auto">
{displayResults.length === 0 ? (
<div className="p-6 text-center text-muted-foreground">
No channels found for &quot;{debouncedQuery}&quot;
</div>
) : (
<>
{displayResults.map((ch) => {
const isSelected = selected.some((s) => s.id === ch.id)
return (
<button
key={ch.id}
type="button"
onClick={() => toggleChannel(ch)}
className={`w-full text-left px-4 py-3 flex items-center justify-between gap-3 hover:bg-muted/50 transition-colors ${
isSelected ? 'bg-cyan-50 dark:bg-cyan-950/30' : ''
}`}
>
<div className="min-w-0">
<div className="font-medium truncate">{ch.name}</div>
<div className="flex flex-wrap gap-1 mt-1">
{ch.country && (
<Badge variant="outline" className="text-xs">
{ch.country}
</Badge>
)}
{ch.categories.map((cat) => (
<Badge key={cat} variant="secondary" className="text-xs">
{cat}
</Badge>
))}
</div>
</div>
{isSelected && (
<CheckCircle className="h-5 w-5 text-cyan-600 flex-shrink-0" />
)}
</button>
)
})}
{totalMatches > 50 && (
<div className="p-3 text-center text-sm text-muted-foreground bg-muted/30">
{totalMatches - 50} more refine your search
</div>
)}
</>
)}
</div>
)}
{debouncedQuery.length > 0 && debouncedQuery.length < 2 && (
<p className="text-sm text-muted-foreground text-center">
Type at least 2 characters to search
</p>
)}
{/* Email */}
<div className="space-y-2">
<label htmlFor="email" className="text-sm font-medium">
Your Email
</label>
<input
type="email"
id="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-4 py-3 rounded-lg border border-border bg-background focus:outline-none focus:ring-2 focus:ring-cyan-500"
placeholder="your@email.com"
/>
<p className="text-xs text-muted-foreground">
We&apos;ll let you know when the channels are added
</p>
</div>
{/* Error */}
{status === 'error' && (
<div className="flex items-center gap-2 p-4 bg-red-100 dark:bg-red-900/30 rounded-lg text-red-600 dark:text-red-400">
<AlertCircle className="h-5 w-5 flex-shrink-0" />
<p className="text-sm">{errorMessage}</p>
</div>
)}
{/* Submit */}
<Button
type="submit"
disabled={status === 'submitting' || selected.length === 0}
className="w-full py-6 text-lg font-bold bg-cyan-600 hover:bg-cyan-700 text-white disabled:opacity-50"
>
{status === 'submitting'
? 'Submitting...'
: selected.length === 0
? 'Select channels to request'
: `Request ${selected.length} Channel${selected.length > 1 ? 's' : ''}`}
</Button>
</form>
)}
<div className="mt-8 p-6 bg-muted/50 rounded-lg space-y-4">
<h3 className="font-bold mb-2">How does this work?</h3>
<p className="text-sm text-muted-foreground">
This catalog comes from <a href="https://iptv-org.github.io" className="text-cyan-600 hover:underline font-medium">iptv-org</a>,
a community-maintained collection of publicly available IPTV streams. Select the channels
you want and we&apos;ll map them into the Jefflix Live TV lineup.
</p>
<p className="text-xs text-muted-foreground border-t border-border pt-3">
Not all channels have working streamswe&apos;ll do our best and let you know the result.
</p>
</div>
</div>
</div>
</div>
)
}

View File

@ -1,17 +0,0 @@
project_name: "Jefflix"
default_status: "To Do"
statuses: ["To Do", "In Progress", "Done"]
labels: []
milestones: []
date_format: yyyy-mm-dd
max_column_width: 20
default_editor: "nvim"
auto_open_browser: true
default_port: 6420
remote_operations: true
auto_commit: false
bypass_git_hooks: false
check_active_branches: true
active_branch_days: 30
task_prefix: "task"
onStatusChange: 'python3 /home/jeffe/Github/dev-ops/scripts/backlog-notify.py'

View File

@ -1,62 +0,0 @@
---
id: task-1
title: Configure Spotify API for SoulSync
status: Done
assignee: []
created_date: '2026-01-08 09:05'
updated_date: '2026-01-08 09:06'
labels: []
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Set up Spotify Developer app and connect to SoulSync for playlist syncing to Navidrome
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Create Spotify Developer app at developer.spotify.com/dashboard
- [x] #2 Set redirect URI to https://soulsync.jefflix.lol/callback
- [x] #3 Copy Client ID and Client Secret to SoulSync settings
- [x] #4 Test Spotify connection shows green in SoulSync dashboard
- [ ] #5 Test syncing a playlist from Spotify to Soulseek/Navidrome
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
## SoulSync Deployment Info
**URLs:**
- SoulSync: https://soulsync.jefflix.lol
- slskd: https://slskd.jefflix.lol (login: slskd/slskd)
- Navidrome: https://music.jefflix.lol
**Current Status (Updated 2026-01-28):**
- Soulseek: CONNECTED (green) as jeffemmett
- Navidrome: CONNECTED (green)
- Spotify: ✅ CONNECTED
**Completed:**
- Added Traefik route for OAuth callback (port 8888)
- Configured Spotify credentials in SoulSync config
- Successfully completed OAuth authorization
- Also configured Lidarr with 35 Spotify playlists
- qBittorrent connected to Lidarr for torrent downloads
**To Configure Spotify:**
1. Go to https://developer.spotify.com/dashboard
2. Create App named "SoulSync"
3. Add Redirect URI: `https://soulsync.jefflix.lol/callback`
4. Copy Client ID and Client Secret
5. Enter in SoulSync Settings > Spotify section
6. Save and authorize with your Spotify account
**Server Config:**
- Docker compose: /opt/soulsync/docker-compose.yml
- Config file: /opt/soulsync/config/soulsync/config/config.json
- slskd config: /opt/soulsync/config/slskd/slskd.yml
<!-- SECTION:NOTES:END -->

View File

@ -1,75 +0,0 @@
---
id: TASK-2
title: Integrate IPTV for live TV (Threadfin + iptv-org + Eleza TV)
status: In Progress
assignee: []
created_date: '2026-02-04 20:56'
updated_date: '2026-04-06 03:18'
labels: []
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Integrate Eleza TV (https://elezatv.com) as an IPTV source for live sports in Jellyfin. Eleza offers 40,000+ channels with EPG, SD/HD/FHD/4K quality, and 54,000+ VOD titles. Pricing: $69/yr for 1 connection, $96/yr for 2 connections. Need to: subscribe to a plan, get M3U/Xtream Codes credentials, configure Jellyfin Live TV & DVR with the IPTV source, set up EPG guide data, and replace the current Sportsnet-dependent sports button on jefflix.lol.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Subscribe to Eleza TV plan (1 or 2 connections)
- [ ] #2 Obtain M3U playlist URL or Xtream Codes API credentials
- [ ] #3 Configure Jellyfin Live TV with Eleza TV IPTV source
- [ ] #4 Set up EPG/TV guide data in Jellyfin
- [ ] #5 Test live sports playback (e.g. NBA, NFL, Premier League)
- [ ] #6 Update jefflix.lol Live Sports button to use new channels
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
### 2026-03-22: IPTV Infrastructure Deployed
- **Threadfin** container running on Netcup (`/opt/media-server/`)
- Admin UI at `threadfin.jeffemmett.com` via Traefik
- `tv.jefflix.lol` redirect → Jellyfin Live TV section configured
- "Watch Live TV" + "Listen to Music" buttons added to jefflix.lol website
- Using **iptv-org/iptv** (90k+ stars) for community-curated free channel lists
**Still TODO:**
- Configure Threadfin with M3U playlists (English, sports, news channels)
- Add XMLTV EPG source for program guide
- Configure Jellyfin Live TV tuner (M3U → `http://threadfin:34400/auto/v1/m3u`)
- Add Cloudflare DNS CNAME for `tv.jefflix.lol`
- Enable Threadfin web auth
- Test playback end-to-end
### 2026-03-22: IPTV Fully Deployed
**All infrastructure live:**
- Threadfin running at `threadfin.jeffemmett.com` (3,468 streams from iptv-org)
- `tv.jefflix.lol` redirects to Jellyfin Live TV section
- Jellyfin has 3 M3U tuners: English (2,269ch), News, Sports
- 3,468 total Live TV channels available in Jellyfin
- "Watch Live TV" + "Listen to Music" buttons on jefflix.lol website
- Cloudflare DNS + tunnel configured for both subdomains
**Remaining manual tasks:**
- Set up Threadfin web auth (visit threadfin.jeffemmett.com → Settings)
- Map XEPG channels in Threadfin for curated filtered playlist
- EPG guide requires self-hosted iptv-org/epg generator (no pre-built guides available)
- Eleza TV subscription still optional for premium sports content
### 2026-04-06: EPG Guide Fixed - Full 24h Coverage
**Problems identified and fixed:**
1. **Mismatched FAST streams**: 11 channels had FAST/Pluto TV streams but EPG was for real cable channels. Replaced with real tvpass.org HLS feeds.
2. **EPG source overnight gap**: tvpassport.com only covered 10:00+ UTC. Switched 31 channels to tvguide.com/tvtv.us for full 24h coverage.
3. **Stale Jellyfin listing provider**: Old XMLTV provider had cached state causing 6h time shifts. Fixed by deleting and re-creating the provider.
**Changes on Netcup (media-server stack):**
- channels.m3u: Replaced 11 stream URLs with tvpass.org feeds, fixed display names
- channels.xml: Switched 14 US channels to tvguide.com, 17 to tvtv.us, kept tvpassport.com for intl news
- Jellyfin: Deleted old XMLTV listing provider, re-created fresh
**Result:** 40+ channels with correct EPG at any hour, up from 5-6. Fixed remaining 5 broken channels (A&E, E!, FX, Disney Channel, CBS News) by switching from stale tvguide.com site IDs to tvtv.us. All 56 EPG-configured channels now have full 24h guide data.
<!-- SECTION:NOTES:END -->

View File

@ -1,47 +0,0 @@
---
id: TASK-3
title: Set up Navidrome mobile access (Android + iOS)
status: To Do
assignee: []
created_date: '2026-02-24 07:13'
labels:
- soulsync
- mobile
- navidrome
dependencies: []
references:
- 'https://soulsync.jefflix.lol'
- 'https://music.jefflix.lol'
- 'https://soulseek.jefflix.lol'
- /home/jeffe/Github/jefflix-website/soulsync-docker-compose.yml
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Configure Navidrome (music.jefflix.lol) for mobile streaming on both Android and Apple devices using Subsonic-compatible apps.
Navidrome exposes the Subsonic API, so no custom app development is needed - just configure a native mobile client on each platform.
**Recommended Apps:**
- **Android:** Symfonium (paid, best UX), Subtracks (free/open-source), DSub, Ultrasonic
- **iOS/Apple:** play:Sub, Amperfy (free/open-source), SubStreamer, iSub
**Server URL:** https://music.jefflix.lol
**Prerequisites:**
- Navidrome must be accessible externally (verify Cloudflare tunnel routing)
- User account(s) created in Navidrome
- Subsonic API enabled in Navidrome settings (usually on by default)
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Navidrome is accessible externally at music.jefflix.lol
- [ ] #2 Subsonic API endpoint responds (music.jefflix.lol/rest/ping)
- [ ] #3 Android app installed and streaming music successfully
- [ ] #4 iOS app installed and streaming music successfully
- [ ] #5 Offline download/caching tested on at least one platform
- [ ] #6 Document the setup (app name, settings) for future reference
<!-- AC:END -->

View File

@ -1,40 +0,0 @@
---
id: TASK-4
title: Verify SoulSync playlist sync pipeline end-to-end
status: To Do
assignee: []
created_date: '2026-02-24 07:13'
labels:
- soulsync
- maintenance
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Ensure the full SoulSync pipeline is working: Spotify playlist → SoulSync orchestration → Soulseek download → Navidrome library update.
Spotify API was configured (task-1), but we should verify the full flow works reliably before relying on mobile access.
**Services to check:**
- SoulSync web UI: https://soulsync.jefflix.lol
- slskd (Soulseek): https://soulseek.jefflix.lol
- Navidrome: https://music.jefflix.lol
**Key checks:**
- Are Spotify playlists syncing to SoulSync?
- Are downloads completing via Soulseek?
- Is Navidrome picking up new files from the music directory?
- Are there any stale/failed downloads to clean up?
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 SoulSync shows Spotify playlists synced
- [ ] #2 At least one playlist successfully downloads tracks via Soulseek
- [ ] #3 Downloaded tracks appear in Navidrome library
- [ ] #4 No stuck/failed jobs in the queue
- [ ] #5 slskd Soulseek connection is healthy and sharing
<!-- AC:END -->

View File

@ -1,44 +0,0 @@
---
id: TASK-5
title: Audit games platform deployment on Netcup
status: To Do
assignee: []
created_date: '2026-02-24 07:13'
labels:
- games
- infrastructure
- audit
dependencies: []
references:
- 'https://games.jeffemmett.com'
- /home/jeffe/Github/games-platform/docker-compose.yml
- /home/jeffe/Github/games-platform/DEPLOYMENT.md
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Check the current state of the games platform deployment at games.jeffemmett.com on Netcup RS 8000.
**What to check:**
- Are all Docker containers running? (postgres, redis, backend, worker, frontend, nginx)
- Is the site accessible at https://games.jeffemmett.com?
- What games (if any) are currently in the library?
- Check /data/games/ directories for existing ROMs
- Review database for any game entries
- Check if the auto-deploy webhook is working
- Verify EmulatorJS loads correctly in browser
**Location on server:** /opt/apps/games-platform
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 All 6 Docker containers verified running (or restarted)
- [ ] #2 games.jeffemmett.com loads in browser
- [ ] #3 Inventory of existing games documented
- [ ] #4 Database health confirmed
- [ ] #5 Gitea webhook auto-deploy verified
- [ ] #6 EmulatorJS emulator loads on a game page
<!-- AC:END -->

View File

@ -1,45 +0,0 @@
---
id: TASK-6
title: Catalog desired retro games for games platform
status: To Do
assignee: []
created_date: '2026-02-24 07:14'
labels:
- games
- content
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Create a wishlist of old/retro games to add to the games platform. Go through memories of classic games across all supported platforms and build a catalog of what to source.
**Supported platforms:**
- PlayStation 1 (.iso, .bin, .cue, .pbp)
- Nintendo 64 (.z64, .n64, .v64)
- Super Nintendo (.smc, .sfc)
- Game Boy Advance (.gba)
- Game Boy Color (.gbc, .gb)
- NES (.nes)
- Sega Genesis (.md, .bin, .gen)
- Sega Dreamcast (.cdi, .gdi, .chd)
- PSP (.iso, .cso, .pbp)
**Process:**
1. List out all desired games by platform
2. Note which ones are personal favorites / must-haves
3. Research ROM availability and file sizes
4. Prioritize which to add first
5. Source ROM files (user handles this manually)
6. Upload via the games platform API or direct file copy
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Game wishlist created with at least 10 games across multiple platforms
- [ ] #2 Games prioritized by platform and personal preference
- [ ] #3 File size estimates noted for storage planning
- [ ] #4 Top 5 must-have games identified for first batch
<!-- AC:END -->

View File

@ -1,43 +0,0 @@
---
id: TASK-7
title: Add ROM files and populate games library
status: To Do
assignee: []
created_date: '2026-02-24 07:14'
labels:
- games
- content
dependencies:
- TASK-5
- TASK-4
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Once games are sourced (from task: Catalog desired retro games), upload ROMs to the games platform and register them in the database.
**Methods to add games:**
1. **Direct file copy:** SCP ROMs to /data/games/{platform}/ on Netcup, then register via API
2. **Upload API:** POST to /api/upload with ROM file
3. **Web UI:** Upload through the games platform interface
**For each game added:**
- Copy ROM to correct platform directory
- Add cover art if available
- Register in database with metadata (title, year, description, genre)
- Test that it loads in EmulatorJS
- Verify save states work
**Storage location on Netcup:** /data/games/{ps1,n64,snes,gba,gbc,nes,genesis,dreamcast,psp}/
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 At least 5 games uploaded and playable
- [ ] #2 Each game has cover art and metadata
- [ ] #3 EmulatorJS loads and runs each game
- [ ] #4 Save states work for at least one game per platform
- [ ] #5 Games appear correctly in the library browse page
<!-- AC:END -->

View File

@ -1,23 +0,0 @@
'use client'
import dynamic from 'next/dynamic'
import { Loader2 } from 'lucide-react'
export type { GlobePoint } from './globe-visualization'
const GlobeVisualization = dynamic(
() => import('./globe-visualization').then((m) => m.GlobeVisualization),
{
ssr: false,
loading: () => (
<div className="flex items-center justify-center h-full w-full min-h-[300px]">
<div className="flex flex-col items-center gap-3 text-muted-foreground">
<Loader2 className="h-8 w-8 animate-spin" />
<span className="text-sm">Loading globe...</span>
</div>
</div>
),
}
)
export { GlobeVisualization as GlobeLoader }

View File

@ -1,97 +0,0 @@
'use client'
import { useRef, useEffect, useState, useCallback } from 'react'
import Globe from 'react-globe.gl'
export interface GlobePoint {
lat: number
lng: number
id: string
label: string
color: string
size?: number
}
interface GlobeVisualizationProps {
points: GlobePoint[]
onPointClick: (point: GlobePoint) => void
height?: number
autoRotate?: boolean
focusLat?: number
focusLng?: number
}
export function GlobeVisualization({
points,
onPointClick,
height = 500,
autoRotate = true,
focusLat,
focusLng,
}: GlobeVisualizationProps) {
const globeRef = useRef<any>(null)
const containerRef = useRef<HTMLDivElement>(null)
const [containerWidth, setContainerWidth] = useState(0)
// Responsive width
useEffect(() => {
if (!containerRef.current) return
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
setContainerWidth(entry.contentRect.width)
}
})
observer.observe(containerRef.current)
setContainerWidth(containerRef.current.clientWidth)
return () => observer.disconnect()
}, [])
// Configure controls after globe is ready
const handleGlobeReady = useCallback(() => {
const globe = globeRef.current
if (!globe) return
const controls = globe.controls()
if (controls) {
controls.autoRotate = autoRotate
controls.autoRotateSpeed = 0.5
controls.enableDamping = true
controls.dampingFactor = 0.1
}
// Set initial view
globe.pointOfView({ lat: focusLat ?? 20, lng: focusLng ?? 0, altitude: 2.5 }, 0)
}, [autoRotate, focusLat, focusLng])
// Focus on specific coordinates when they change
useEffect(() => {
if (globeRef.current && focusLat != null && focusLng != null) {
globeRef.current.pointOfView({ lat: focusLat, lng: focusLng, altitude: 1.5 }, 1000)
}
}, [focusLat, focusLng])
return (
<div ref={containerRef} className="w-full" style={{ height }}>
{containerWidth > 0 && (
<Globe
ref={globeRef}
width={containerWidth}
height={height}
globeImageUrl="/textures/earth-blue-marble.jpg"
backgroundColor="rgba(0,0,0,0)"
showAtmosphere={true}
atmosphereColor="rgba(100,150,255,0.3)"
atmosphereAltitude={0.15}
pointsData={points}
pointLat="lat"
pointLng="lng"
pointColor="color"
pointAltitude={0.01}
pointRadius={(d: any) => Math.max(0.15, Math.min(0.6, (d.size || 1) * 0.12))}
pointLabel={(d: any) => `<div class="text-sm font-medium px-2 py-1 bg-background/90 border border-border rounded shadow-lg">${d.label}</div>`}
onPointClick={(point: any) => onPointClick(point as GlobePoint)}
onGlobeReady={handleGlobeReady}
enablePointerInteraction={true}
/>
)}
</div>
)
}

View File

@ -1,58 +0,0 @@
'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 (
<button
onClick={(e) => { e.stopPropagation(); remove(track.id) }}
className={`${padding} rounded-full hover:bg-muted/50 transition-colors text-green-500 ${className}`}
title="Downloaded — tap to remove"
>
<CheckCircle className={iconSize} />
</button>
)
}
if (status === 'downloading') {
return (
<span className={`${padding} text-muted-foreground ${className}`} title="Downloading...">
<Loader2 className={`${iconSize} animate-spin`} />
</span>
)
}
if (status === 'queued') {
return (
<span className={`${padding} text-muted-foreground ${className}`} title="Queued for download">
<Clock className={iconSize} />
</span>
)
}
return (
<button
onClick={(e) => { e.stopPropagation(); download(track) }}
className={`${padding} rounded-full hover:bg-muted/50 transition-colors text-muted-foreground hover:text-foreground ${className}`}
title="Download for offline"
>
<Download className={iconSize} />
</button>
)
}

View File

@ -1,267 +0,0 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { Drawer } from 'vaul'
import { useMusicPlayer, isRadioTrack } from './music-provider'
import { DownloadButton } from './download-button'
import { PlaylistPicker } from './playlist-picker'
import { SyncedLyrics } from './synced-lyrics'
import { QueueView } from './queue-view'
import { Slider } from '@/components/ui/slider'
import { Button } from '@/components/ui/button'
import {
Play,
Pause,
SkipBack,
SkipForward,
ListPlus,
ListMusic,
Share2,
Volume2,
VolumeX,
ChevronDown,
Speaker,
Shuffle,
Radio,
} from 'lucide-react'
function formatTime(secs: number) {
if (!secs || !isFinite(secs)) return '0:00'
const m = Math.floor(secs / 60)
const s = Math.floor(secs % 60)
return `${m}:${s.toString().padStart(2, '0')}`
}
export function FullScreenPlayer() {
const { state, togglePlay, seek, setVolume, nextTrack, prevTrack, setFullScreen, toggleShuffle, outputDevices, currentOutputId, setOutputDevice } = useMusicPlayer()
const router = useRouter()
const [lyrics, setLyrics] = useState<string | null>(null)
const [syncedLyrics, setSyncedLyrics] = useState<string | null>(null)
const [loadingLyrics, setLoadingLyrics] = useState(false)
const [playlistOpen, setPlaylistOpen] = useState(false)
const [queueOpen, setQueueOpen] = useState(false)
const track = state.currentTrack
const isRadio = track ? isRadioTrack(track) : false
// Fetch lyrics when track changes (skip for radio)
useEffect(() => {
if (!track || isRadioTrack(track)) {
setLyrics(null)
setSyncedLyrics(null)
setLoadingLyrics(false)
return
}
setLyrics(null)
setSyncedLyrics(null)
setLoadingLyrics(true)
fetch(`/api/music/lyrics?artist=${encodeURIComponent(track.artist)}&title=${encodeURIComponent(track.title)}`)
.then((r) => r.json())
.then((d) => {
setLyrics(d.lyrics)
setSyncedLyrics(d.synced || null)
})
.catch(() => { setLyrics(null); setSyncedLyrics(null) })
.finally(() => setLoadingLyrics(false))
}, [track?.id]) // eslint-disable-line react-hooks/exhaustive-deps
const handleShare = async () => {
if (!track) return
const text = `${track.title} - ${track.artist}`
if (navigator.share) {
try {
await navigator.share({ title: text, text })
} catch {}
} else {
await navigator.clipboard.writeText(text)
}
}
return (
<>
<Drawer.Root open={state.isFullScreen} onOpenChange={setFullScreen}>
<Drawer.Portal>
<Drawer.Overlay className="fixed inset-0 bg-black/60 z-50" />
<Drawer.Content className="fixed inset-x-0 bottom-0 z-50 flex max-h-[96vh] flex-col rounded-t-2xl bg-background border-t border-border">
<div className="mx-auto mt-2 h-1.5 w-12 rounded-full bg-muted-foreground/30" />
<Drawer.Title className="sr-only">{isRadio ? 'Radio Player' : 'Music Player'}</Drawer.Title>
<Drawer.Description className="sr-only">
{isRadio ? 'Live radio player with station controls' : 'Full-screen music player with controls, lyrics, and playlist management'}
</Drawer.Description>
{track && (
<div className="flex flex-col items-center px-6 pb-8 pt-4 overflow-y-auto">
{/* Close button */}
<button
onClick={() => setFullScreen(false)}
className="self-start mb-4 p-1 rounded-full hover:bg-muted/50 transition-colors"
>
<ChevronDown className="h-6 w-6 text-muted-foreground" />
</button>
{/* Album art */}
<div className="w-64 h-64 sm:w-72 sm:h-72 rounded-xl overflow-hidden shadow-2xl mb-8 bg-muted flex-shrink-0">
{track.coverArt ? (
<img
src={`/api/music/cover/${track.coverArt}?size=600`}
alt={track.album}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-muted-foreground">
{isRadio ? <Radio className="h-16 w-16 text-rose-400" /> : <Volume2 className="h-16 w-16" />}
</div>
)}
</div>
{/* Title / Artist */}
<div className="text-center mb-6 max-w-sm">
<h2 className="text-xl font-bold truncate">{track.title}</h2>
<button
className="text-muted-foreground truncate hover:text-purple-400 hover:underline transition-colors"
onClick={() => {
setFullScreen(false)
router.push(`/music?q=${encodeURIComponent(track.artist)}`)
}}
>
{track.artist}
</button>
<p className="text-sm text-muted-foreground/70 truncate">{track.album}</p>
</div>
{/* Progress */}
<div className="w-full max-w-sm mb-4">
{isRadio ? (
<>
<div className="h-2 w-full bg-rose-500/20 rounded-full overflow-hidden mb-2">
<div className="h-full w-full bg-rose-500 animate-pulse" />
</div>
<div className="flex justify-center text-xs">
<span className="font-bold px-2 py-0.5 rounded bg-rose-500/20 text-rose-400 animate-pulse">LIVE</span>
</div>
</>
) : (
<>
<Slider
value={[state.progress]}
max={state.duration || 1}
step={1}
onValueChange={([v]) => seek(v)}
className="mb-2"
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>{formatTime(state.progress)}</span>
<span>{formatTime(state.duration)}</span>
</div>
</>
)}
</div>
{/* Controls */}
<div className="flex items-center gap-6 mb-6">
{!isRadio && (
<button
onClick={toggleShuffle}
className={`p-2 rounded-full transition-colors ${state.shuffleEnabled ? 'text-purple-400' : 'hover:bg-muted/50'}`}
title={state.shuffleEnabled ? 'Disable shuffle' : 'Enable shuffle'}
>
<Shuffle className="h-5 w-5" />
</button>
)}
<button onClick={prevTrack} className="p-2 hover:bg-muted/50 rounded-full transition-colors">
<SkipBack className="h-6 w-6" />
</button>
<button
onClick={togglePlay}
className="p-4 bg-primary text-primary-foreground rounded-full hover:bg-primary/90 transition-colors"
>
{state.isPlaying ? <Pause className="h-7 w-7" /> : <Play className="h-7 w-7 ml-0.5" />}
</button>
<button onClick={nextTrack} className="p-2 hover:bg-muted/50 rounded-full transition-colors">
<SkipForward className="h-6 w-6" />
</button>
</div>
{/* Volume */}
<div className="flex items-center gap-3 w-full max-w-[200px] mb-6">
<button onClick={() => setVolume(state.volume === 0 ? 0.8 : 0)} className="text-muted-foreground">
{state.volume === 0 ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
</button>
<Slider
value={[state.volume * 100]}
max={100}
step={1}
onValueChange={([v]) => setVolume(v / 100)}
/>
</div>
{/* Audio output selector */}
{outputDevices.length > 1 && (
<div className="flex items-center gap-2 w-full max-w-sm mb-6">
<Speaker className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<select
value={currentOutputId || 'default'}
onChange={(e) => setOutputDevice(e.target.value)}
className="flex-1 text-sm bg-muted/50 border border-border rounded-md px-2 py-1.5 text-foreground focus:outline-none focus:ring-2 focus:ring-purple-500"
>
{outputDevices.map((d) => (
<option key={d.deviceId} value={d.deviceId}>
{d.label}
</option>
))}
</select>
</div>
)}
{/* Actions */}
<div className="flex gap-3 mb-8 flex-wrap justify-center">
<Button variant="outline" size="sm" onClick={() => setQueueOpen(true)}>
<ListMusic className="h-4 w-4 mr-1.5" />
Queue
</Button>
{!isRadio && <DownloadButton track={track} size="md" />}
{!isRadio && (
<Button variant="outline" size="sm" onClick={() => setPlaylistOpen(true)}>
<ListPlus className="h-4 w-4 mr-1.5" />
Add to Playlist
</Button>
)}
<Button variant="outline" size="sm" onClick={handleShare}>
<Share2 className="h-4 w-4 mr-1.5" />
Share
</Button>
</div>
{/* Lyrics (hidden for radio) */}
{!isRadio && (
loadingLyrics ? (
<p className="text-sm text-muted-foreground animate-pulse">Loading lyrics...</p>
) : syncedLyrics ? (
<SyncedLyrics
syncedLyrics={syncedLyrics}
currentTime={state.progress}
onSeek={seek}
/>
) : lyrics ? (
<div className="w-full max-w-sm">
<h3 className="text-sm font-semibold mb-2 text-muted-foreground">Lyrics</h3>
<pre className="text-sm leading-relaxed whitespace-pre-wrap text-muted-foreground font-sans">
{lyrics}
</pre>
</div>
) : null
)}
</div>
)}
</Drawer.Content>
</Drawer.Portal>
</Drawer.Root>
<PlaylistPicker open={playlistOpen} onOpenChange={setPlaylistOpen} />
<QueueView open={queueOpen} onClose={() => setQueueOpen(false)} />
</>
)
}

View File

@ -1,128 +0,0 @@
'use client'
import { useState } from 'react'
import { useMusicPlayer, isRadioTrack } from './music-provider'
import { FullScreenPlayer } from './full-screen-player'
import { QueueView } from './queue-view'
import { Slider } from '@/components/ui/slider'
import { Play, Pause, SkipBack, SkipForward, ListMusic, Shuffle, Radio } from 'lucide-react'
function formatTime(secs: number) {
if (!secs || !isFinite(secs)) return '0:00'
const m = Math.floor(secs / 60)
const s = Math.floor(secs % 60)
return `${m}:${s.toString().padStart(2, '0')}`
}
export function MiniPlayer() {
const { state, togglePlay, seek, nextTrack, prevTrack, setFullScreen, toggleShuffle } = useMusicPlayer()
const [queueOpen, setQueueOpen] = useState(false)
if (!state.currentTrack) return null
const track = state.currentTrack
const isRadio = isRadioTrack(track)
return (
<>
<div className="fixed bottom-0 inset-x-0 z-50 bg-card border-t border-border shadow-lg">
{/* Progress bar (thin, above the player) — disabled for radio */}
<div className="px-2">
{isRadio ? (
<div className="h-1 w-full bg-rose-500/40 rounded-full overflow-hidden">
<div className="h-full w-full bg-rose-500 animate-pulse" />
</div>
) : (
<Slider
value={[state.progress]}
max={state.duration || 1}
step={1}
onValueChange={([v]) => seek(v)}
className="h-1 [&_[data-slot=slider]]:h-1 [&_span[data-slot=scroll-bar]]:hidden"
/>
)}
</div>
<div className="flex items-center gap-3 px-4 py-2 h-14">
{/* Cover art - clickable to open fullscreen */}
<button
onClick={() => setFullScreen(true)}
className="flex-shrink-0 w-10 h-10 rounded-md overflow-hidden bg-muted"
>
{track.coverArt ? (
<img
src={`/api/music/cover/${track.coverArt}?size=80`}
alt={track.album}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-muted">
{isRadio && <Radio className="h-5 w-5 text-rose-400" />}
</div>
)}
</button>
{/* Track info - clickable to open fullscreen */}
<button
onClick={() => setFullScreen(true)}
className="flex-1 min-w-0 text-left"
>
<div className="text-sm font-medium truncate">{track.title}</div>
<div className="text-xs text-muted-foreground truncate">{track.artist}</div>
</button>
{/* Time / LIVE badge */}
{isRadio ? (
<span className="text-[10px] font-bold px-1.5 py-0.5 rounded bg-rose-500/20 text-rose-400 animate-pulse hidden sm:block">
LIVE
</span>
) : (
<span className="text-xs text-muted-foreground tabular-nums hidden sm:block">
{formatTime(state.progress)} / {formatTime(state.duration)}
</span>
)}
{/* Controls */}
<div className="flex items-center gap-1">
{!isRadio && (
<button
onClick={toggleShuffle}
className={`p-1.5 rounded-full transition-colors ${state.shuffleEnabled ? 'text-purple-400' : 'text-muted-foreground hover:bg-muted/50'}`}
title={state.shuffleEnabled ? 'Disable shuffle' : 'Enable shuffle'}
>
<Shuffle className="h-3.5 w-3.5" />
</button>
)}
<button
onClick={prevTrack}
className="p-1.5 hover:bg-muted/50 rounded-full transition-colors"
>
<SkipBack className="h-4 w-4" />
</button>
<button
onClick={togglePlay}
className="p-2 bg-primary text-primary-foreground rounded-full hover:bg-primary/90 transition-colors"
>
{state.isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4 ml-0.5" />}
</button>
<button
onClick={nextTrack}
className="p-1.5 hover:bg-muted/50 rounded-full transition-colors"
>
<SkipForward className="h-4 w-4" />
</button>
<button
onClick={() => setQueueOpen(true)}
className="p-1.5 hover:bg-muted/50 rounded-full transition-colors text-muted-foreground"
>
<ListMusic className="h-4 w-4" />
</button>
</div>
</div>
</div>
<FullScreenPlayer />
<QueueView open={queueOpen} onClose={() => setQueueOpen(false)} />
</>
)
}

View File

@ -1,459 +0,0 @@
'use client'
import React, { createContext, useContext, useReducer, useRef, useEffect, useCallback, useState } from 'react'
import { getTrackBlob } from '@/lib/offline-db'
import { precacheUpcoming } from '@/lib/precache'
interface TrackBase {
id: string
title: string
artist: string
album: string
albumId: string
duration: number
coverArt: string
}
export interface MusicTrack extends TrackBase {
type?: 'music'
}
export interface RadioTrack extends TrackBase {
type: 'radio'
streamUrl: string
}
export type Track = MusicTrack | RadioTrack
export function isRadioTrack(track: Track): track is RadioTrack {
return track.type === 'radio'
}
interface PlayerState {
currentTrack: Track | null
queue: Track[]
queueIndex: number
isPlaying: boolean
progress: number
duration: number
volume: number
isFullScreen: boolean
shuffleEnabled: boolean
originalQueue: Track[] | null
}
type PlayerAction =
| { type: 'PLAY_TRACK'; track: Track; queue?: Track[]; index?: number }
| { type: 'TOGGLE_PLAY' }
| { type: 'SET_PLAYING'; playing: boolean }
| { type: 'SET_PROGRESS'; progress: number }
| { type: 'SET_DURATION'; duration: number }
| { type: 'SET_VOLUME'; volume: number }
| { type: 'NEXT_TRACK' }
| { type: 'PREV_TRACK' }
| { type: 'SET_FULLSCREEN'; open: boolean }
| { type: 'ADD_TO_QUEUE'; track: Track }
| { type: 'ADD_ALL_TO_QUEUE'; tracks: Track[] }
| { type: 'REMOVE_FROM_QUEUE'; index: number }
| { type: 'MOVE_IN_QUEUE'; from: number; to: number }
| { type: 'JUMP_TO'; index: number }
| { type: 'CLEAR_QUEUE' }
| { type: 'TOGGLE_SHUFFLE' }
const initialState: PlayerState = {
currentTrack: null,
queue: [],
queueIndex: -1,
isPlaying: false,
progress: 0,
duration: 0,
volume: 0.8,
isFullScreen: false,
shuffleEnabled: false,
originalQueue: null,
}
function playerReducer(state: PlayerState, action: PlayerAction): PlayerState {
switch (action.type) {
case 'PLAY_TRACK': {
const queue = action.queue || [action.track]
const index = action.index ?? 0
return { ...state, currentTrack: action.track, queue, queueIndex: index, isPlaying: true, progress: 0, shuffleEnabled: false, originalQueue: null }
}
case 'TOGGLE_PLAY':
return { ...state, isPlaying: !state.isPlaying }
case 'SET_PLAYING':
return { ...state, isPlaying: action.playing }
case 'SET_PROGRESS':
return { ...state, progress: action.progress }
case 'SET_DURATION':
return { ...state, duration: action.duration }
case 'SET_VOLUME':
return { ...state, volume: action.volume }
case 'NEXT_TRACK': {
const next = state.queueIndex + 1
if (next >= state.queue.length) return { ...state, isPlaying: false }
return { ...state, currentTrack: state.queue[next], queueIndex: next, isPlaying: true, progress: 0 }
}
case 'PREV_TRACK': {
// If > 3s in, restart current track
if (state.progress > 3) return { ...state, progress: 0 }
const prev = state.queueIndex - 1
if (prev < 0) return { ...state, progress: 0 }
return { ...state, currentTrack: state.queue[prev], queueIndex: prev, isPlaying: true, progress: 0 }
}
case 'SET_FULLSCREEN':
return { ...state, isFullScreen: action.open }
case 'ADD_TO_QUEUE': {
// Insert after current track so it plays next
const insertAt = state.queueIndex + 1
const q2 = [...state.queue]
q2.splice(insertAt, 0, action.track)
// If shuffle is on, also add to originalQueue at end
const origQ = state.originalQueue ? [...state.originalQueue, action.track] : null
return { ...state, queue: q2, originalQueue: origQ }
}
case 'ADD_ALL_TO_QUEUE': {
const insertAt = state.queueIndex + 1
const q3 = [...state.queue]
q3.splice(insertAt, 0, ...action.tracks)
const origQ2 = state.originalQueue ? [...state.originalQueue, ...action.tracks] : null
return { ...state, queue: q3, originalQueue: origQ2 }
}
case 'REMOVE_FROM_QUEUE': {
const newQueue = [...state.queue]
newQueue.splice(action.index, 1)
let newIndex = state.queueIndex
if (action.index < state.queueIndex) {
newIndex--
} else if (action.index === state.queueIndex) {
// Removing current track: play next (or clamp)
const clamped = Math.min(newIndex, newQueue.length - 1)
if (clamped < 0) return { ...state, queue: [], queueIndex: -1, currentTrack: null, isPlaying: false }
return { ...state, queue: newQueue, queueIndex: clamped, currentTrack: newQueue[clamped], progress: 0 }
}
return { ...state, queue: newQueue, queueIndex: newIndex }
}
case 'MOVE_IN_QUEUE': {
const q = [...state.queue]
const [moved] = q.splice(action.from, 1)
q.splice(action.to, 0, moved)
// Track the currently-playing song's new position
let idx = state.queueIndex
if (action.from === idx) {
idx = action.to
} else {
if (action.from < idx) idx--
if (action.to <= idx) idx++
}
return { ...state, queue: q, queueIndex: idx }
}
case 'JUMP_TO': {
if (action.index < 0 || action.index >= state.queue.length) return state
return { ...state, currentTrack: state.queue[action.index], queueIndex: action.index, isPlaying: true, progress: 0 }
}
case 'CLEAR_QUEUE': {
// Keep current track, remove everything after it
if (state.queueIndex < 0 || !state.currentTrack) return { ...state, queue: [], queueIndex: -1 }
return { ...state, queue: [state.currentTrack], queueIndex: 0 }
}
case 'TOGGLE_SHUFFLE': {
if (state.shuffleEnabled) {
// OFF: restore original order, find current track in it
if (!state.originalQueue) return { ...state, shuffleEnabled: false }
const currentId = state.currentTrack?.id
const idx = state.originalQueue.findIndex((t) => t.id === currentId)
return { ...state, shuffleEnabled: false, queue: state.originalQueue, queueIndex: idx >= 0 ? idx : 0, originalQueue: null }
} else {
// ON: save original queue, Fisher-Yates shuffle items after current index
const shuffled = [...state.queue]
for (let i = shuffled.length - 1; i > state.queueIndex + 1; i--) {
const j = state.queueIndex + 1 + Math.floor(Math.random() * (i - state.queueIndex))
;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]
}
return { ...state, shuffleEnabled: true, originalQueue: [...state.queue], queue: shuffled }
}
}
default:
return state
}
}
export interface AudioOutputDevice {
deviceId: string
label: string
}
interface MusicContextValue {
state: PlayerState
playTrack: (track: Track, queue?: Track[], index?: number) => void
togglePlay: () => void
seek: (time: number) => void
setVolume: (volume: number) => void
nextTrack: () => void
prevTrack: () => void
setFullScreen: (open: boolean) => void
addToQueue: (track: Track) => void
addAllToQueue: (tracks: Track[]) => void
removeFromQueue: (index: number) => void
moveInQueue: (from: number, to: number) => void
jumpTo: (index: number) => void
clearQueue: () => void
toggleShuffle: () => void
outputDevices: AudioOutputDevice[]
currentOutputId: string
setOutputDevice: (deviceId: string) => void
}
const MusicContext = createContext<MusicContextValue | null>(null)
export function useMusicPlayer() {
const ctx = useContext(MusicContext)
if (!ctx) throw new Error('useMusicPlayer must be used within MusicProvider')
return ctx
}
export function MusicProvider({ children }: { children: React.ReactNode }) {
const [state, dispatch] = useReducer(playerReducer, initialState)
const audioRef = useRef<HTMLAudioElement | null>(null)
// Guard against pause events fired when changing audio.src during track transitions
const transitioningRef = useRef(false)
// Create audio element on mount (client only)
useEffect(() => {
const audio = new Audio()
audio.preload = 'auto'
audioRef.current = audio
const onTimeUpdate = () => dispatch({ type: 'SET_PROGRESS', progress: audio.currentTime })
const onDurationChange = () => dispatch({ type: 'SET_DURATION', duration: audio.duration || 0 })
const onEnded = () => {
transitioningRef.current = true
dispatch({ type: 'NEXT_TRACK' })
}
const onPause = () => {
// Ignore pause events during track transitions (browser fires pause when src changes)
if (transitioningRef.current) return
dispatch({ type: 'SET_PLAYING', playing: false })
}
const onPlay = () => dispatch({ type: 'SET_PLAYING', playing: true })
audio.addEventListener('timeupdate', onTimeUpdate)
audio.addEventListener('durationchange', onDurationChange)
audio.addEventListener('ended', onEnded)
audio.addEventListener('pause', onPause)
audio.addEventListener('play', onPlay)
return () => {
audio.removeEventListener('timeupdate', onTimeUpdate)
audio.removeEventListener('durationchange', onDurationChange)
audio.removeEventListener('ended', onEnded)
audio.removeEventListener('pause', onPause)
audio.removeEventListener('play', onPlay)
audio.pause()
audio.src = ''
}
}, [])
// Retry play with exponential backoff (handles mobile autoplay restrictions)
const playWithRetry = useCallback((audio: HTMLAudioElement, attempts = 3) => {
audio.play().catch((err) => {
if (attempts > 1) {
setTimeout(() => playWithRetry(audio, attempts - 1), 500)
} else {
console.warn('Autoplay blocked after retries:', err.message)
}
})
}, [])
// Track blob URL for cleanup
const blobUrlRef = useRef<string | null>(null)
// When currentTrack changes, update audio src (offline-first)
useEffect(() => {
const audio = audioRef.current
if (!audio || !state.currentTrack) return
const trackId = state.currentTrack.id
transitioningRef.current = true
// Revoke previous blob URL
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current)
blobUrlRef.current = null
}
// Radio streams: use streamUrl directly, skip offline/IndexedDB
if (isRadioTrack(state.currentTrack)) {
audio.src = state.currentTrack.streamUrl
playWithRetry(audio)
requestAnimationFrame(() => { transitioningRef.current = false })
return
}
// Music: try offline first, fall back to streaming
getTrackBlob(trackId).then((blob) => {
// Guard: track may have changed while we awaited
if (state.currentTrack?.id !== trackId) return
if (blob) {
const url = URL.createObjectURL(blob)
blobUrlRef.current = url
audio.src = url
} else {
audio.src = `/api/music/stream/${trackId}`
}
playWithRetry(audio)
requestAnimationFrame(() => { transitioningRef.current = false })
}).catch(() => {
// IndexedDB unavailable, fall back to streaming
if (state.currentTrack?.id !== trackId) return
audio.src = `/api/music/stream/${trackId}`
playWithRetry(audio)
requestAnimationFrame(() => { transitioningRef.current = false })
})
}, [state.currentTrack?.id, playWithRetry]) // eslint-disable-line react-hooks/exhaustive-deps
// Sync play/pause
useEffect(() => {
const audio = audioRef.current
if (!audio || !state.currentTrack) return
if (state.isPlaying) {
playWithRetry(audio)
} else {
audio.pause()
}
}, [state.isPlaying, state.currentTrack, playWithRetry])
// Sync volume
useEffect(() => {
if (audioRef.current) audioRef.current.volume = state.volume
}, [state.volume])
// MediaSession API
useEffect(() => {
if (!state.currentTrack || !('mediaSession' in navigator)) return
navigator.mediaSession.metadata = new MediaMetadata({
title: state.currentTrack.title,
artist: state.currentTrack.artist,
album: state.currentTrack.album,
artwork: state.currentTrack.coverArt
? [{ src: `/api/music/cover/${state.currentTrack.coverArt}?size=300`, sizes: '300x300', type: 'image/jpeg' }]
: [],
})
navigator.mediaSession.setActionHandler('play', () => dispatch({ type: 'SET_PLAYING', playing: true }))
navigator.mediaSession.setActionHandler('pause', () => dispatch({ type: 'SET_PLAYING', playing: false }))
navigator.mediaSession.setActionHandler('previoustrack', () => dispatch({ type: 'PREV_TRACK' }))
navigator.mediaSession.setActionHandler('nexttrack', () => dispatch({ type: 'NEXT_TRACK' }))
}, [state.currentTrack])
// Pre-cache next 3 tracks in queue when current track changes (skip for radio)
useEffect(() => {
if (state.queueIndex < 0 || state.queue.length <= state.queueIndex + 1) return
if (state.currentTrack && isRadioTrack(state.currentTrack)) return
const ac = new AbortController()
const delay = setTimeout(() => {
precacheUpcoming(state.queue, state.queueIndex, 3, ac.signal)
}, 2000)
return () => {
clearTimeout(delay)
ac.abort()
}
}, [state.queueIndex, state.queue])
// Keyboard shortcut: Space = play/pause (only when not in input)
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.code === 'Space' && !['INPUT', 'TEXTAREA', 'SELECT'].includes((e.target as HTMLElement).tagName)) {
e.preventDefault()
dispatch({ type: 'TOGGLE_PLAY' })
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [])
// Audio output device selection
const [outputDevices, setOutputDevices] = useState<AudioOutputDevice[]>([])
const [currentOutputId, setCurrentOutputId] = useState('')
useEffect(() => {
if (typeof navigator === 'undefined' || !navigator.mediaDevices?.enumerateDevices) return
const enumerate = () => {
navigator.mediaDevices.enumerateDevices().then((devices) => {
const outputs = devices
.filter((d) => d.kind === 'audiooutput')
.map((d) => ({
deviceId: d.deviceId,
label: d.label || (d.deviceId === 'default' ? 'Default' : `Device ${d.deviceId.slice(0, 6)}`),
}))
setOutputDevices(outputs)
}).catch(() => {})
}
enumerate()
navigator.mediaDevices.addEventListener('devicechange', enumerate)
return () => navigator.mediaDevices.removeEventListener('devicechange', enumerate)
}, [])
const setOutputDevice = useCallback((deviceId: string) => {
const audio = audioRef.current as HTMLAudioElement & { setSinkId?: (id: string) => Promise<void> }
if (!audio?.setSinkId) return
audio.setSinkId(deviceId).then(() => {
setCurrentOutputId(deviceId)
}).catch((err) => {
console.warn('Failed to set audio output:', err.message)
})
}, [])
const playTrack = useCallback((track: Track, queue?: Track[], index?: number) => {
dispatch({ type: 'PLAY_TRACK', track, queue, index })
}, [])
const togglePlay = useCallback(() => dispatch({ type: 'TOGGLE_PLAY' }), [])
const seek = useCallback((time: number) => {
if (audioRef.current) {
audioRef.current.currentTime = time
dispatch({ type: 'SET_PROGRESS', progress: time })
}
}, [])
const setVolume = useCallback((v: number) => dispatch({ type: 'SET_VOLUME', volume: v }), [])
const nextTrack = useCallback(() => dispatch({ type: 'NEXT_TRACK' }), [])
const prevTrack = useCallback(() => dispatch({ type: 'PREV_TRACK' }), [])
const setFullScreen = useCallback((open: boolean) => dispatch({ type: 'SET_FULLSCREEN', open }), [])
const addToQueue = useCallback((track: Track) => dispatch({ type: 'ADD_TO_QUEUE', track }), [])
const addAllToQueue = useCallback((tracks: Track[]) => dispatch({ type: 'ADD_ALL_TO_QUEUE', tracks }), [])
const removeFromQueue = useCallback((index: number) => dispatch({ type: 'REMOVE_FROM_QUEUE', index }), [])
const moveInQueue = useCallback((from: number, to: number) => dispatch({ type: 'MOVE_IN_QUEUE', from, to }), [])
const jumpTo = useCallback((index: number) => dispatch({ type: 'JUMP_TO', index }), [])
const clearQueue = useCallback(() => dispatch({ type: 'CLEAR_QUEUE' }), [])
const toggleShuffle = useCallback(() => dispatch({ type: 'TOGGLE_SHUFFLE' }), [])
return (
<MusicContext.Provider value={{
state,
playTrack,
togglePlay,
seek,
setVolume,
nextTrack,
prevTrack,
setFullScreen,
addToQueue,
addAllToQueue,
removeFromQueue,
moveInQueue,
jumpTo,
clearQueue,
toggleShuffle,
outputDevices,
currentOutputId,
setOutputDevice,
}}>
{children}
</MusicContext.Provider>
)
}

View File

@ -1,170 +0,0 @@
'use client'
import { useState, useEffect, useRef } from 'react'
import { Download, Loader2, Check, X, Globe } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
interface MBResult {
mbid: string
title: string
artist: string
album: string
year: string
duration: number
score: number
}
type DownloadState = 'idle' | 'downloading' | 'success' | 'error'
function formatDuration(secs: number) {
if (!secs) return ''
const m = Math.floor(secs / 60)
const s = Math.floor(secs % 60)
return `${m}:${s.toString().padStart(2, '0')}`
}
export function MusicBrainzResultsSection({ query }: { query: string }) {
const [results, setResults] = useState<MBResult[]>([])
const [loading, setLoading] = useState(false)
const [downloadStates, setDownloadStates] = useState<Record<string, DownloadState>>({})
const debounceRef = useRef<NodeJS.Timeout>(null)
useEffect(() => {
if (query.length < 2) {
setResults([])
return
}
setLoading(true)
// 500ms debounce (800ms total after Navidrome's 300ms input debounce)
debounceRef.current = setTimeout(async () => {
try {
const res = await fetch(`/api/music/musicbrainz?q=${encodeURIComponent(query)}`)
const d = await res.json()
setResults(d.results || [])
} catch {
setResults([])
}
setLoading(false)
}, 500)
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current)
}
}, [query])
const handleDownload = async (result: MBResult) => {
setDownloadStates((s) => ({ ...s, [result.mbid]: 'downloading' }))
try {
const res = await fetch('/api/music/slskd/auto-download', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ artist: result.artist, title: result.title }),
})
const d = await res.json()
if (d.success) {
setDownloadStates((s) => ({ ...s, [result.mbid]: 'success' }))
} else {
setDownloadStates((s) => ({ ...s, [result.mbid]: 'error' }))
setTimeout(() => {
setDownloadStates((s) => ({ ...s, [result.mbid]: 'idle' }))
}, 3000)
}
} catch {
setDownloadStates((s) => ({ ...s, [result.mbid]: 'error' }))
setTimeout(() => {
setDownloadStates((s) => ({ ...s, [result.mbid]: 'idle' }))
}, 3000)
}
}
if (loading && results.length === 0) {
return (
<div className="mt-6">
<h3 className="text-sm font-semibold text-muted-foreground flex items-center gap-2 mb-3">
<Globe className="h-4 w-4" />
Discover New Music
</h3>
<div className="flex justify-center py-6">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
</div>
)
}
if (results.length === 0) return null
return (
<div className="mt-6">
<h3 className="text-sm font-semibold text-muted-foreground flex items-center gap-2 mb-3">
<Globe className="h-4 w-4" />
Discover New Music
</h3>
<div className="border border-border rounded-lg divide-y divide-border overflow-hidden">
{results.map((r) => {
const state = downloadStates[r.mbid] || 'idle'
return (
<div key={r.mbid} className="flex items-center gap-3 px-4 py-3">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{r.title}</div>
<div className="text-xs text-muted-foreground truncate">
{r.artist}
{r.album && <> &middot; {r.album}</>}
{r.year && <> &middot; {r.year}</>}
</div>
</div>
<span className="text-xs text-muted-foreground tabular-nums hidden sm:block">
{formatDuration(r.duration)}
</span>
{r.score > 80 && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 hidden sm:inline-flex">
{r.score}%
</Badge>
)}
<Button
size="sm"
onClick={() => handleDownload(r)}
disabled={state === 'downloading' || state === 'success'}
className={
state === 'success'
? 'bg-green-600 hover:bg-green-600 text-white'
: state === 'error'
? 'bg-red-600 hover:bg-red-600 text-white'
: 'bg-yellow-600 hover:bg-yellow-700 text-white'
}
>
{state === 'downloading' && (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1" />
<span className="hidden sm:inline">Searching...</span>
</>
)}
{state === 'success' && (
<>
<Check className="h-3.5 w-3.5 mr-1" />
<span className="hidden sm:inline">Queued</span>
</>
)}
{state === 'error' && (
<>
<X className="h-3.5 w-3.5 mr-1" />
<span className="hidden sm:inline">Not found</span>
</>
)}
{state === 'idle' && (
<>
<Download className="h-3.5 w-3.5 sm:mr-1" />
<span className="hidden sm:inline">Soulseek</span>
</>
)}
</Button>
</div>
)
})}
</div>
</div>
)
}

View File

@ -1,170 +0,0 @@
'use client'
import { useState, useEffect } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Button } from '@/components/ui/button'
import { ListPlus, Plus, Loader2, CheckCircle } from 'lucide-react'
import { useMusicPlayer } from './music-provider'
interface Playlist {
id: string
name: string
songCount: number
coverArt: string
}
export function PlaylistPicker({
open,
onOpenChange,
}: {
open: boolean
onOpenChange: (open: boolean) => void
}) {
const { state } = useMusicPlayer()
const [playlists, setPlaylists] = useState<Playlist[]>([])
const [loading, setLoading] = useState(false)
const [adding, setAdding] = useState<string | null>(null)
const [added, setAdded] = useState<string | null>(null)
const [creating, setCreating] = useState(false)
const [newName, setNewName] = useState('')
useEffect(() => {
if (!open) return
setLoading(true)
setAdded(null)
fetch('/api/music/playlists')
.then((r) => r.json())
.then((d) => setPlaylists(d.playlists || []))
.catch(() => {})
.finally(() => setLoading(false))
}, [open])
const songId = state.currentTrack?.id
if (!songId) return null
const addToPlaylist = async (playlistId: string) => {
setAdding(playlistId)
try {
await fetch(`/api/music/playlist/${playlistId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ songId }),
})
setAdded(playlistId)
} catch {}
setAdding(null)
}
const createPlaylist = async () => {
if (!newName.trim()) return
setCreating(true)
try {
await fetch('/api/music/playlist/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName.trim(), songId }),
})
setAdded('new')
setNewName('')
} catch {}
setCreating(false)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ListPlus className="h-5 w-5" />
Add to Playlist
</DialogTitle>
<DialogDescription>
Add &ldquo;{state.currentTrack?.title}&rdquo; to an existing playlist or create a new one.
</DialogDescription>
</DialogHeader>
{loading ? (
<div className="flex justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<>
<ScrollArea className="max-h-[300px]">
<div className="space-y-1">
{playlists.map((p) => (
<button
key={p.id}
onClick={() => addToPlaylist(p.id)}
disabled={adding !== null}
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-muted/50 transition-colors text-left"
>
<div className="flex-shrink-0 w-10 h-10 rounded overflow-hidden bg-muted">
{p.coverArt ? (
<img
src={`/api/music/cover/${p.coverArt}?size=80`}
alt={p.name}
className="w-full h-full object-cover"
loading="lazy"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<ListPlus className="h-4 w-4 text-muted-foreground" />
</div>
)}
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-sm truncate">{p.name}</div>
<div className="text-xs text-muted-foreground">{p.songCount} songs</div>
</div>
{adding === p.id ? (
<Loader2 className="h-4 w-4 animate-spin flex-shrink-0" />
) : added === p.id ? (
<CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" />
) : null}
</button>
))}
{playlists.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">
No playlists yet. Create one below.
</p>
)}
</div>
</ScrollArea>
<div className="flex gap-2 pt-2 border-t border-border">
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="New playlist name..."
className="flex-1 px-3 py-2 text-sm rounded-md border border-border bg-background focus:outline-none focus:ring-2 focus:ring-ring"
onKeyDown={(e) => e.key === 'Enter' && createPlaylist()}
/>
<Button
size="sm"
onClick={createPlaylist}
disabled={!newName.trim() || creating}
>
{creating ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
</Button>
</div>
{added === 'new' && (
<p className="text-sm text-green-500 flex items-center gap-1">
<CheckCircle className="h-3.5 w-3.5" />
Playlist created with song added
</p>
)}
</>
)}
</DialogContent>
</Dialog>
)
}

View File

@ -1,240 +0,0 @@
'use client'
import { useState, useRef, useCallback, useEffect } from 'react'
import { useMusicPlayer } from './music-provider'
import { X, GripVertical, ArrowLeft, Search, Music } from 'lucide-react'
import { useRouter } from 'next/navigation'
interface QueueViewProps {
open: boolean
onClose: () => void
}
export function QueueView({ open, onClose }: QueueViewProps) {
const { state, removeFromQueue, moveInQueue, jumpTo, clearQueue } = useMusicPlayer()
const router = useRouter()
// Drag state
const [dragIndex, setDragIndex] = useState<number | null>(null)
const [overIndex, setOverIndex] = useState<number | null>(null)
const dragStartY = useRef(0)
const dragNodeRef = useRef<HTMLElement | null>(null)
const listRef = useRef<HTMLDivElement>(null)
const upcomingStart = state.queueIndex + 1
const upcoming = state.queue.slice(upcomingStart)
// Map upcoming array index to absolute queue index
const toAbsolute = (i: number) => upcomingStart + i
const handleDragStart = useCallback((e: React.MouseEvent | React.TouchEvent, index: number) => {
e.preventDefault()
const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY
dragStartY.current = clientY
setDragIndex(index)
setOverIndex(index)
dragNodeRef.current = (e.target as HTMLElement).closest('[data-queue-row]') as HTMLElement
}, [])
const handleDragMove = useCallback((e: MouseEvent | TouchEvent) => {
if (dragIndex === null || !listRef.current) return
const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY
// Find which row we're over
const rows = listRef.current.querySelectorAll<HTMLElement>('[data-queue-row]')
for (let i = 0; i < rows.length; i++) {
const rect = rows[i].getBoundingClientRect()
if (clientY >= rect.top && clientY <= rect.bottom) {
setOverIndex(i)
break
}
}
}, [dragIndex])
const handleDragEnd = useCallback(() => {
if (dragIndex !== null && overIndex !== null && dragIndex !== overIndex) {
moveInQueue(toAbsolute(dragIndex), toAbsolute(overIndex))
}
setDragIndex(null)
setOverIndex(null)
dragNodeRef.current = null
}, [dragIndex, overIndex, moveInQueue, upcomingStart]) // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (dragIndex === null) return
const onMove = (e: MouseEvent | TouchEvent) => handleDragMove(e)
const onEnd = () => handleDragEnd()
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onEnd)
window.addEventListener('touchmove', onMove, { passive: false })
window.addEventListener('touchend', onEnd)
return () => {
window.removeEventListener('mousemove', onMove)
window.removeEventListener('mouseup', onEnd)
window.removeEventListener('touchmove', onMove)
window.removeEventListener('touchend', onEnd)
}
}, [dragIndex, handleDragMove, handleDragEnd])
// Prevent body scroll when open
useEffect(() => {
if (open) {
document.body.style.overflow = 'hidden'
return () => { document.body.style.overflow = '' }
}
}, [open])
if (!open) return null
const currentTrack = state.currentTrack
const hasUpcoming = upcoming.length > 0
return (
<div className="fixed inset-0 z-[250] bg-background flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border flex-shrink-0">
<button onClick={onClose} className="p-1.5 hover:bg-muted/50 rounded-full transition-colors">
<ArrowLeft className="h-5 w-5" />
</button>
<h2 className="text-lg font-semibold">Queue</h2>
{hasUpcoming ? (
<button
onClick={clearQueue}
className="text-sm text-muted-foreground hover:text-foreground transition-colors px-2 py-1"
>
Clear
</button>
) : (
<div className="w-12" />
)}
</div>
<div className="flex-1 overflow-y-auto">
{/* Now Playing */}
{currentTrack && (
<div className="px-4 pt-4 pb-2">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">Now Playing</p>
<div className="flex items-center gap-3 px-3 py-2.5 rounded-lg bg-primary/10 border border-primary/20">
<div className="w-10 h-10 rounded-md overflow-hidden bg-muted flex-shrink-0">
{currentTrack.coverArt ? (
<img
src={`/api/music/cover/${currentTrack.coverArt}?size=80`}
alt={currentTrack.album}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-muted-foreground">
<Music className="h-5 w-5" />
</div>
)}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{currentTrack.title}</div>
<div className="text-xs text-muted-foreground truncate">{currentTrack.artist}</div>
</div>
</div>
</div>
)}
{/* Next Up */}
{hasUpcoming && (
<div className="px-4 pt-4 pb-2">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">Next Up</p>
<div ref={listRef}>
{upcoming.map((track, i) => {
const isDragging = dragIndex === i
const showIndicator = overIndex !== null && dragIndex !== null && overIndex !== dragIndex && overIndex === i
return (
<div key={`${track.id}-${i}`} data-queue-row>
{/* Drop indicator line */}
{showIndicator && dragIndex !== null && dragIndex > i && (
<div className="h-0.5 bg-primary rounded-full mx-3 -mb-0.5" />
)}
<div
className={`flex items-center gap-2 px-1 py-2 rounded-lg transition-colors ${
isDragging ? 'opacity-50 bg-muted/30' : 'hover:bg-muted/50'
}`}
style={{ willChange: isDragging ? 'transform' : 'auto' }}
>
{/* Drag handle */}
<button
className="p-1.5 touch-none cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground"
onMouseDown={(e) => handleDragStart(e, i)}
onTouchStart={(e) => handleDragStart(e, i)}
>
<GripVertical className="h-4 w-4" />
</button>
{/* Track info — tap to jump */}
<button
className="flex items-center gap-3 flex-1 min-w-0 text-left"
onClick={() => {
jumpTo(toAbsolute(i))
onClose()
}}
>
<div className="w-10 h-10 rounded-md overflow-hidden bg-muted flex-shrink-0">
{track.coverArt ? (
<img
src={`/api/music/cover/${track.coverArt}?size=80`}
alt={track.album}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-muted-foreground">
<Music className="h-5 w-5" />
</div>
)}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{track.title}</div>
<div className="text-xs text-muted-foreground truncate">{track.artist}</div>
</div>
</button>
{/* Remove button */}
<button
className="p-1.5 hover:bg-muted rounded-full transition-colors text-muted-foreground hover:text-foreground flex-shrink-0"
onClick={() => removeFromQueue(toAbsolute(i))}
>
<X className="h-4 w-4" />
</button>
</div>
{/* Drop indicator line (below) */}
{showIndicator && dragIndex !== null && dragIndex < i && (
<div className="h-0.5 bg-primary rounded-full mx-3 -mt-0.5" />
)}
</div>
)
})}
</div>
</div>
)}
{/* Empty state */}
{!currentTrack && !hasUpcoming && (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
<Music className="h-12 w-12 mb-4 opacity-40" />
<p className="text-lg font-medium mb-1">Queue is empty</p>
<p className="text-sm">Search for songs to start listening</p>
</div>
)}
{/* Add Songs link */}
<div className="px-4 py-6">
<button
onClick={() => {
onClose()
router.push('/music')
}}
className="flex items-center gap-2 text-sm text-primary hover:text-primary/80 transition-colors"
>
<Search className="h-4 w-4" />
Add Songs
</button>
</div>
</div>
</div>
)
}

View File

@ -1,123 +0,0 @@
'use client'
import { useState } from 'react'
import { useMusicPlayer, type Track } from './music-provider'
import { DownloadButton } from './download-button'
import { SwipeableRow } from './swipeable-row'
import { Play, Pause, ListPlus } from 'lucide-react'
function formatDuration(secs: number) {
if (!secs) return ''
const m = Math.floor(secs / 60)
const s = Math.floor(secs % 60)
return `${m}:${s.toString().padStart(2, '0')}`
}
export function SongRow({
song,
songs,
index,
showDownload = false,
}: {
song: Track
songs: Track[]
index: number
showDownload?: boolean
}) {
const { state, playTrack, togglePlay, addToQueue } = useMusicPlayer()
const isActive = state.currentTrack?.id === song.id
const isPlaying = isActive && state.isPlaying
const [confirmSwitch, setConfirmSwitch] = useState(false)
const handlePlay = () => {
if (isActive) {
togglePlay()
} else if (state.currentTrack && state.isPlaying) {
setConfirmSwitch(true)
} else {
playTrack(song, songs, index)
}
}
return (
<SwipeableRow onSwipeRight={() => addToQueue(song)}>
<div
className={`relative flex items-center gap-3 px-4 py-2.5 hover:bg-muted/50 transition-colors group ${
isActive ? 'bg-primary/5' : ''
}`}
>
{/* Confirm switch overlay */}
{confirmSwitch && (
<div className="absolute inset-0 z-10 flex items-center justify-center gap-2 bg-background/95 backdrop-blur-sm rounded px-4">
<span className="text-sm font-medium truncate mr-auto">Switch song, DJ Cutoff?</span>
<button
onClick={() => { playTrack(song, songs, index); setConfirmSwitch(false) }}
className="px-3 py-1.5 text-sm font-medium bg-purple-600 text-white rounded-md hover:bg-purple-700 transition-colors"
>
Switch
</button>
<button
onClick={() => setConfirmSwitch(false)}
className="px-3 py-1.5 text-sm font-medium bg-muted hover:bg-muted/80 rounded-md transition-colors"
>
Cancel
</button>
</div>
)}
{/* Play button / track number */}
<button
onClick={handlePlay}
className="flex-shrink-0 w-8 h-8 flex items-center justify-center rounded-full hover:bg-muted transition-colors"
>
{isPlaying ? (
<Pause className="h-4 w-4 text-primary" />
) : (
<Play className="h-4 w-4 text-primary ml-0.5" />
)}
</button>
{/* Cover art */}
<div className="flex-shrink-0 w-10 h-10 rounded overflow-hidden bg-muted">
{song.coverArt ? (
<img
src={`/api/music/cover/${song.coverArt}?size=80`}
alt={song.album}
className="w-full h-full object-cover"
loading="lazy"
/>
) : (
<div className="w-full h-full bg-muted" />
)}
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<div className={`text-sm font-medium truncate ${isActive ? 'text-primary' : ''}`}>
{song.title}
</div>
<div className="text-xs text-muted-foreground truncate">
{song.artist} &middot; {song.album}
</div>
</div>
{/* Duration */}
<span className="text-xs text-muted-foreground tabular-nums hidden sm:block">
{formatDuration(song.duration)}
</span>
{/* Download */}
{showDownload && <DownloadButton track={song} />}
{/* Add to queue (desktop hover) */}
<button
onClick={(e) => { e.stopPropagation(); addToQueue(song) }}
className="p-1.5 opacity-0 group-hover:opacity-100 hover:bg-muted/50 rounded transition-all"
title="Add to queue"
>
<ListPlus className="h-4 w-4 text-muted-foreground" />
</button>
</div>
</SwipeableRow>
)
}

View File

@ -1,72 +0,0 @@
'use client'
import { useRef, useState, useCallback, type ReactNode } from 'react'
import { ListPlus } from 'lucide-react'
const THRESHOLD = 60
export function SwipeableRow({
onSwipeRight,
children,
}: {
onSwipeRight: () => void
children: ReactNode
}) {
const touchStartX = useRef(0)
const [offset, setOffset] = useState(0)
const [swiping, setSwiping] = useState(false)
const [confirmed, setConfirmed] = useState(false)
const handleTouchStart = useCallback((e: React.TouchEvent) => {
touchStartX.current = e.touches[0].clientX
setSwiping(true)
}, [])
const handleTouchMove = useCallback((e: React.TouchEvent) => {
if (!swiping) return
const delta = e.touches[0].clientX - touchStartX.current
// Only allow right swipe, cap at 120px
setOffset(Math.max(0, Math.min(delta, 120)))
}, [swiping])
const handleTouchEnd = useCallback(() => {
if (offset > THRESHOLD) {
onSwipeRight()
setConfirmed(true)
setTimeout(() => setConfirmed(false), 600)
}
setOffset(0)
setSwiping(false)
}, [offset, onSwipeRight])
return (
<div className="relative overflow-hidden touch-pan-y">
{/* Green reveal strip behind */}
<div
className={`absolute inset-y-0 left-0 flex items-center gap-2 pl-4 transition-colors ${
confirmed ? 'bg-green-500' : offset > THRESHOLD ? 'bg-green-600' : 'bg-green-600/80'
}`}
style={{ width: Math.max(offset, confirmed ? 200 : 0) }}
>
<ListPlus className="h-4 w-4 text-white flex-shrink-0" />
<span className="text-sm text-white font-medium whitespace-nowrap">
{confirmed ? 'Added!' : 'Add to Queue'}
</span>
</div>
{/* Swipeable content */}
<div
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
className="relative bg-background"
style={{
transform: `translateX(${offset}px)`,
transition: swiping ? 'none' : 'transform 0.2s ease-out',
}}
>
{children}
</div>
</div>
)
}

View File

@ -1,98 +0,0 @@
'use client'
import { useMemo, useRef, useEffect } from 'react'
interface LyricLine {
time: number // seconds
text: string
}
function parseLRC(lrc: string): LyricLine[] {
const lines: LyricLine[] = []
for (const raw of lrc.split('\n')) {
const match = raw.match(/^\[(\d{2}):(\d{2})\.(\d{2,3})\]\s?(.*)$/)
if (!match) continue
const mins = parseInt(match[1], 10)
const secs = parseInt(match[2], 10)
const ms = parseInt(match[3].padEnd(3, '0'), 10)
const text = match[4].trim()
if (!text) continue
lines.push({ time: mins * 60 + secs + ms / 1000, text })
}
return lines.sort((a, b) => a.time - b.time)
}
export function SyncedLyrics({
syncedLyrics,
currentTime,
onSeek,
}: {
syncedLyrics: string
currentTime: number
onSeek?: (time: number) => void
}) {
const lines = useMemo(() => parseLRC(syncedLyrics), [syncedLyrics])
const containerRef = useRef<HTMLDivElement>(null)
const activeRef = useRef<HTMLButtonElement>(null)
// Find active line index
let activeIndex = -1
for (let i = lines.length - 1; i >= 0; i--) {
if (currentTime >= lines[i].time) {
activeIndex = i
break
}
}
// Auto-scroll to active line
useEffect(() => {
if (activeRef.current && containerRef.current) {
const container = containerRef.current
const el = activeRef.current
const containerRect = container.getBoundingClientRect()
const elRect = el.getBoundingClientRect()
// Center the active line in the visible area
const targetScroll = el.offsetTop - container.offsetTop - containerRect.height / 2 + elRect.height / 2
container.scrollTo({ top: targetScroll, behavior: 'smooth' })
}
}, [activeIndex])
if (lines.length === 0) return null
return (
<div className="w-full max-w-sm">
<h3 className="text-sm font-semibold mb-3 text-muted-foreground">Lyrics</h3>
<div
ref={containerRef}
className="max-h-[300px] overflow-y-auto scroll-smooth space-y-1 pr-2"
style={{ scrollbarWidth: 'thin' }}
>
{/* Top padding so first line can center */}
<div className="h-[120px]" />
{lines.map((line, i) => {
const isActive = i === activeIndex
const isPast = i < activeIndex
return (
<button
key={`${i}-${line.time}`}
ref={isActive ? activeRef : undefined}
onClick={() => onSeek?.(line.time)}
className={`block w-full text-left px-2 py-1.5 rounded-md transition-all duration-300 ${
isActive
? 'text-foreground text-lg font-semibold scale-[1.02]'
: isPast
? 'text-muted-foreground/40 text-sm'
: 'text-muted-foreground/60 text-sm'
} hover:bg-muted/30`}
>
{line.text}
</button>
)
})}
{/* Bottom padding so last line can center */}
<div className="h-[120px]" />
</div>
</div>
)
}

View File

@ -1,28 +0,0 @@
'use client'
import { useEffect } from 'react'
export function ServiceWorkerRegister() {
useEffect(() => {
if (!('serviceWorker' in navigator)) return
navigator.serviceWorker
.register('/sw.js')
.then((reg) => {
// Check for updates periodically (every 60s, matching version poll)
setInterval(() => reg.update().catch(() => {}), 60_000)
// Also check on visibility change
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
reg.update().catch(() => {})
}
})
})
.catch((err) => {
console.warn('SW registration failed:', err)
})
}, [])
return null
}

View File

@ -1,100 +0,0 @@
'use client'
import * as React from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { X } from 'lucide-react'
import { cn } from '@/lib/utils'
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogClose = DialogPrimitive.Close
const DialogPortal = DialogPrimitive.Portal
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
'fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute top-4 right-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
}
function DialogFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)} {...props} />
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
)
}
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@ -1,49 +0,0 @@
'use client'
import * as React from 'react'
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'
import { cn } from '@/lib/utils'
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn('relative overflow-hidden', className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = 'vertical',
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-bar"
orientation={orientation}
className={cn(
'flex touch-none p-px transition-colors select-none',
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent p-px',
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent p-px',
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="bg-border relative flex-1 rounded-full" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }

View File

@ -1,43 +0,0 @@
'use client'
import * as React from 'react'
import * as SliderPrimitive from '@radix-ui/react-slider'
import { cn } from '@/lib/utils'
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = value ?? defaultValue ?? [min]
return (
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
'relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<SliderPrimitive.Track className="bg-muted relative h-1.5 w-full grow overflow-hidden rounded-full">
<SliderPrimitive.Range className="bg-primary absolute h-full" />
</SliderPrimitive.Track>
{_values.map((_, i) => (
<SliderPrimitive.Thumb
key={i}
className="border-primary/50 bg-background block size-4 rounded-full border shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none"
/>
))}
</SliderPrimitive.Root>
)
}
export { Slider }

View File

@ -1,104 +0,0 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { RefreshCw, Download } from 'lucide-react'
const CHECK_INTERVAL = 60_000 // check every 60s
export function UpdateBanner() {
const [updateAvailable, setUpdateAvailable] = useState(false)
const [initialVersion, setInitialVersion] = useState<string | null>(null)
const [updating, setUpdating] = useState(false)
const checkVersion = useCallback(async () => {
try {
const res = await fetch('/api/version', { cache: 'no-store' })
const { version } = await res.json()
if (!version) return
if (initialVersion === null) {
setInitialVersion(version)
} else if (version !== initialVersion) {
setUpdateAvailable(true)
}
} catch {
// network error, skip
}
}, [initialVersion])
useEffect(() => {
checkVersion()
const id = setInterval(checkVersion, CHECK_INTERVAL)
return () => clearInterval(id)
}, [checkVersion])
// Also check on visibility change (user returns to tab)
useEffect(() => {
const onVisible = () => {
if (document.visibilityState === 'visible') checkVersion()
}
document.addEventListener('visibilitychange', onVisible)
return () => document.removeEventListener('visibilitychange', onVisible)
}, [checkVersion])
if (!updateAvailable) return null
const handleUpdate = async () => {
setUpdating(true)
try {
// 1. Clear all service worker caches
const keys = await caches.keys()
await Promise.all(keys.map((k) => caches.delete(k)))
// 2. If a new service worker is waiting, activate it
const reg = await navigator.serviceWorker?.getRegistration()
if (reg?.waiting) {
// Listen for the new SW to take control, then reload
navigator.serviceWorker.addEventListener('controllerchange', () => {
window.location.reload()
}, { once: true })
reg.waiting.postMessage({ type: 'SKIP_WAITING' })
// Fallback reload if controllerchange doesn't fire within 3s
setTimeout(() => window.location.reload(), 3000)
return
}
// 3. If there's an active SW but no waiting one, unregister and reload
if (reg) {
await reg.unregister()
}
} catch {
// If anything fails, just reload
}
window.location.reload()
}
return (
<div className="fixed inset-x-0 top-0 z-[100] bg-gradient-to-r from-purple-700 via-purple-600 to-indigo-600 text-white shadow-2xl animate-in slide-in-from-top duration-300">
<div className="mx-auto max-w-2xl px-4 py-4 flex flex-col items-center gap-3 sm:flex-row sm:justify-between">
<div className="flex items-center gap-3 text-center sm:text-left">
<div className="rounded-full bg-white/20 p-2 animate-pulse">
<Download className="h-5 w-5" />
</div>
<div>
<h2 className="text-base font-bold tracking-tight sm:text-lg">
Update Available
</h2>
<p className="text-xs text-purple-100 sm:text-sm">
A new version of Jefflix has been deployed
</p>
</div>
</div>
<button
onClick={handleUpdate}
disabled={updating}
className="flex items-center gap-2 rounded-full bg-white text-purple-700 font-bold px-6 py-2.5 text-sm shadow-lg hover:bg-purple-50 active:scale-95 transition-all cursor-pointer disabled:opacity-70 whitespace-nowrap"
>
<RefreshCw className={`h-4 w-4 ${updating ? 'animate-spin' : ''}`} />
{updating ? 'Updating…' : 'Update Now'}
</button>
</div>
</div>
)
}

View File

@ -2,46 +2,13 @@ services:
jefflix:
build: .
restart: unless-stopped
read_only: true
tmpfs:
- /tmp
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
volumes:
- /opt/infisical/entrypoint-wrapper.sh:/infisical-entrypoint.sh:ro
entrypoint: ["/infisical-entrypoint.sh"]
command: ["node", "server.js"]
environment:
- INFISICAL_CLIENT_ID=${INFISICAL_CLIENT_ID}
- INFISICAL_CLIENT_SECRET=${INFISICAL_CLIENT_SECRET}
- INFISICAL_PROJECT_SLUG=claude-ops
- INFISICAL_SECRET_PATH=/media
- INFISICAL_URL=http://infisical:8080
- SMTP_HOST=${SMTP_HOST:-mail.rmail.online}
- SMTP_PORT=${SMTP_PORT:-587}
- SMTP_USER=${SMTP_USER}
- SMTP_PASS=${SMTP_PASS}
- ADMIN_EMAIL=${ADMIN_EMAIL:-jeff@jeffemmett.com}
- THREADFIN_URL=https://threadfin.jefflix.lol
labels:
- "traefik.enable=true"
- "traefik.http.routers.jefflix-website.rule=Host(`jefflix.lol`) || Host(`www.jefflix.lol`)"
- "traefik.http.services.jefflix-website.loadbalancer.server.port=3000"
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3000/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
- "traefik.http.routers.jefflix.rule=Host(`jefflix.lol`) || Host(`www.jefflix.lol`)"
- "traefik.http.services.jefflix.loadbalancer.server.port=3000"
networks:
- traefik-public
- infisical-internal
networks:
traefik-public:
external: true
infisical-internal:
external: true
name: infisical_infisical-internal

View File

@ -1,29 +0,0 @@
import { createCanvas } from 'canvas';
import fs from 'fs';
// Generate emoji favicon PNGs
const sizes = [
{ name: 'icon-light-32x32.png', size: 32 },
{ name: 'icon-dark-32x32.png', size: 32 },
{ name: 'apple-icon.png', size: 180 },
];
for (const { name, size } of sizes) {
const canvas = createCanvas(size, size);
const ctx = canvas.getContext('2d');
// Transparent background
ctx.clearRect(0, 0, size, size);
// Draw emoji
const fontSize = size * 0.85;
ctx.font = `${fontSize}px "Segoe UI Emoji", "Apple Color Emoji", "Noto Color Emoji", sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('🤣', size / 2, size / 2 + size * 0.05);
// Save
const buffer = canvas.toBuffer('image/png');
fs.writeFileSync(`public/${name}`, buffer);
console.log(`Generated ${name}`);
}

View File

@ -1,21 +0,0 @@
services:
jfa-go:
image: hrfee/jfa-go
container_name: jfa-go
restart: unless-stopped
ports:
- "8056:8056"
volumes:
- ./jfa-go-data:/data
labels:
- "traefik.enable=true"
- "traefik.http.routers.jfa-go.rule=Host(`invite.jefflix.lol`)"
- "traefik.http.services.jfa-go.loadbalancer.server.port=8056"
networks:
- traefik-public
environment:
- TZ=Europe/Berlin
networks:
traefik-public:
external: true

View File

@ -1,268 +0,0 @@
/**
* ISO 3166-1 alpha-2 country codes mapped to [latitude, longitude] centroids.
* Coordinates represent approximate geographic centroids of each country.
*/
export const COUNTRY_CENTROIDS: Record<string, [number, number]> = {
AD: [42.5462, 1.6016], // Andorra
AE: [23.4241, 53.8478], // United Arab Emirates
AF: [33.9391, 67.7100], // Afghanistan
AG: [17.0608, -61.7964], // Antigua and Barbuda
AI: [18.2206, -63.0686], // Anguilla
AL: [41.1533, 20.1683], // Albania
AM: [40.0691, 45.0382], // Armenia
AO: [-11.2027, 17.8739], // Angola
AQ: [-90.0000, 0.0000], // Antarctica
AR: [-38.4161, -63.6167],// Argentina
AS: [-14.2710, -170.1322],// American Samoa
AT: [47.5162, 14.5501], // Austria
AU: [-25.2744, 133.7751],// Australia
AW: [12.5211, -69.9683], // Aruba
AX: [60.1785, 19.9156], // Aland Islands
AZ: [40.1431, 47.5769], // Azerbaijan
BA: [43.9159, 17.6791], // Bosnia and Herzegovina
BB: [13.1939, -59.5432], // Barbados
BD: [23.6850, 90.3563], // Bangladesh
BE: [50.5039, 4.4699], // Belgium
BF: [12.3641, -1.5275], // Burkina Faso
BG: [42.7339, 25.4858], // Bulgaria
BH: [26.0275, 50.5500], // Bahrain
BI: [-3.3731, 29.9189], // Burundi
BJ: [9.3077, 2.3158], // Benin
BL: [17.9000, -62.8333], // Saint Barthelemy
BM: [32.3213, -64.7572], // Bermuda
BN: [4.5353, 114.7277], // Brunei
BO: [-16.2902, -63.5887],// Bolivia
BQ: [12.1784, -68.2385], // Bonaire, Saint Eustatius and Saba
BR: [-14.2350, -51.9253],// Brazil
BS: [25.0343, -77.3963], // Bahamas
BT: [27.5142, 90.4336], // Bhutan
BV: [-54.4208, 3.3464], // Bouvet Island
BW: [-22.3285, 24.6849], // Botswana
BY: [53.7098, 27.9534], // Belarus
BZ: [17.1899, -88.4976], // Belize
CA: [56.1304, -106.3468],// Canada
CC: [-12.1642, 96.8710], // Cocos (Keeling) Islands
CD: [-4.0383, 21.7587], // Democratic Republic of the Congo
CF: [6.6111, 20.9394], // Central African Republic
CG: [-0.2280, 15.8277], // Republic of the Congo
CH: [46.8182, 8.2275], // Switzerland
CI: [7.5400, -5.5471], // Ivory Coast
CK: [-21.2368, -159.7777],// Cook Islands
CL: [-35.6751, -71.5430],// Chile
CM: [3.8480, 11.5021], // Cameroon
CN: [35.8617, 104.1954], // China
CO: [4.5709, -74.2973], // Colombia
CR: [9.7489, -83.7534], // Costa Rica
CU: [21.5218, -77.7812], // Cuba
CV: [16.5388, -23.0418], // Cape Verde
CW: [12.1696, -68.9900], // Curacao
CX: [-10.4475, 105.6904],// Christmas Island
CY: [35.1264, 33.4299], // Cyprus
CZ: [49.8175, 15.4730], // Czech Republic
DE: [51.1657, 10.4515], // Germany
DJ: [11.8251, 42.5903], // Djibouti
DK: [56.2639, 9.5018], // Denmark
DM: [15.4150, -61.3710], // Dominica
DO: [18.7357, -70.1627], // Dominican Republic
DZ: [28.0339, 1.6596], // Algeria
EC: [-1.8312, -78.1834], // Ecuador
EE: [58.5953, 25.0136], // Estonia
EG: [26.8206, 30.8025], // Egypt
EH: [24.2155, -12.8858], // Western Sahara
ER: [15.1794, 39.7823], // Eritrea
ES: [40.4637, -3.7492], // Spain
ET: [9.1450, 40.4897], // Ethiopia
FI: [61.9241, 25.7482], // Finland
FJ: [-16.5782, 179.4144],// Fiji
FK: [-51.7963, -59.5236],// Falkland Islands
FM: [7.4256, 150.5508], // Micronesia
FO: [61.8926, -6.9118], // Faroe Islands
FR: [46.2276, 2.2137], // France
GA: [-0.8037, 11.6094], // Gabon
GB: [55.3781, -3.4360], // United Kingdom
GD: [12.1165, -61.6790], // Grenada
GE: [42.3154, 43.3569], // Georgia
GF: [3.9339, -53.1258], // French Guiana
GG: [49.4657, -2.5853], // Guernsey
GH: [7.9465, -1.0232], // Ghana
GI: [36.1408, -5.3536], // Gibraltar
GL: [71.7069, -42.6043], // Greenland
GM: [13.4432, -15.3101], // Gambia
GN: [9.9456, -11.2874], // Guinea
GP: [16.9950, -62.0670], // Guadeloupe
GQ: [1.6508, 10.2679], // Equatorial Guinea
GR: [39.0742, 21.8243], // Greece
GS: [-54.4296, -36.5879],// South Georgia and the South Sandwich Islands
GT: [15.7835, -90.2308], // Guatemala
GU: [13.4443, 144.7937], // Guam
GW: [11.8037, -15.1804], // Guinea-Bissau
GY: [4.8604, -58.9302], // Guyana
HK: [22.3193, 114.1694], // Hong Kong
HM: [-53.0818, 73.5042], // Heard Island and McDonald Islands
HN: [15.1999, -86.2419], // Honduras
HR: [45.1000, 15.2000], // Croatia
HT: [18.9712, -72.2852], // Haiti
HU: [47.1625, 19.5033], // Hungary
ID: [-0.7893, 113.9213], // Indonesia
IE: [53.4129, -8.2439], // Ireland
IL: [31.0461, 34.8516], // Israel
IM: [54.2361, -4.5481], // Isle of Man
IN: [20.5937, 78.9629], // India
IO: [-6.3432, 71.8765], // British Indian Ocean Territory
IQ: [33.2232, 43.6793], // Iraq
IR: [32.4279, 53.6880], // Iran
IS: [64.9631, -19.0208], // Iceland
IT: [41.8719, 12.5674], // Italy
JE: [49.2144, -2.1312], // Jersey
JM: [18.1096, -77.2975], // Jamaica
JO: [30.5852, 36.2384], // Jordan
JP: [36.2048, 138.2529], // Japan
KE: [-0.0236, 37.9062], // Kenya
KG: [41.2044, 74.7661], // Kyrgyzstan
KH: [12.5657, 104.9910], // Cambodia
KI: [-3.3704, -168.7340],// Kiribati
KM: [-11.8750, 43.8722], // Comoros
KN: [17.3578, -62.7830], // Saint Kitts and Nevis
KP: [40.3399, 127.5101], // North Korea
KR: [35.9078, 127.7669], // South Korea
KW: [29.3117, 47.4818], // Kuwait
KY: [19.3133, -81.2546], // Cayman Islands
KZ: [48.0196, 66.9237], // Kazakhstan
LA: [19.8563, 102.4955], // Laos
LB: [33.8547, 35.8623], // Lebanon
LC: [13.9094, -60.9789], // Saint Lucia
LI: [47.1660, 9.5554], // Liechtenstein
LK: [7.8731, 80.7718], // Sri Lanka
LR: [6.4281, -9.4295], // Liberia
LS: [-29.6100, 28.2336], // Lesotho
LT: [55.1694, 23.8813], // Lithuania
LU: [49.8153, 6.1296], // Luxembourg
LV: [56.8796, 24.6032], // Latvia
LY: [26.3351, 17.2283], // Libya
MA: [31.7917, -7.0926], // Morocco
MC: [43.7384, 7.4246], // Monaco
MD: [47.4116, 28.3699], // Moldova
ME: [42.7087, 19.3744], // Montenegro
MF: [18.0708, -63.0501], // Saint Martin
MG: [-18.7669, 46.8691], // Madagascar
MH: [7.1315, 171.1845], // Marshall Islands
MK: [41.6086, 21.7453], // North Macedonia
ML: [17.5707, -3.9962], // Mali
MM: [21.9162, 95.9560], // Myanmar
MN: [46.8625, 103.8467], // Mongolia
MO: [22.1987, 113.5439], // Macao
MP: [17.3308, 145.3847], // Northern Mariana Islands
MQ: [14.6415, -61.0242], // Martinique
MR: [21.0079, -10.9408], // Mauritania
MS: [16.7425, -62.1874], // Montserrat
MT: [35.9375, 14.3754], // Malta
MU: [-20.3484, 57.5522], // Mauritius
MV: [3.2028, 73.2207], // Maldives
MW: [-13.2543, 34.3015], // Malawi
MX: [23.6345, -102.5528],// Mexico
MY: [4.2105, 101.9758], // Malaysia
MZ: [-18.6657, 35.5296], // Mozambique
NA: [-22.9576, 18.4904], // Namibia
NC: [-20.9043, 165.6180],// New Caledonia
NE: [17.6078, 8.0817], // Niger
NF: [-29.0408, 167.9547],// Norfolk Island
NG: [9.0820, 8.6753], // Nigeria
NI: [12.8654, -85.2072], // Nicaragua
NL: [52.1326, 5.2913], // Netherlands
NO: [60.4720, 8.4689], // Norway
NP: [28.3949, 84.1240], // Nepal
NR: [-0.5228, 166.9315], // Nauru
NU: [-19.0544, -169.8672],// Niue
NZ: [-40.9006, 174.8860],// New Zealand
OM: [21.4735, 55.9754], // Oman
PA: [8.5380, -80.7821], // Panama
PE: [-9.1900, -75.0152], // Peru
PF: [-17.6797, -149.4068],// French Polynesia
PG: [-6.3150, 143.9555], // Papua New Guinea
PH: [12.8797, 121.7740], // Philippines
PK: [30.3753, 69.3451], // Pakistan
PL: [51.9194, 19.1451], // Poland
PM: [46.8852, -56.3159], // Saint Pierre and Miquelon
PN: [-24.7036, -127.4392],// Pitcairn
PR: [18.2208, -66.5901], // Puerto Rico
PS: [31.9522, 35.2332], // Palestinian Territory
PT: [39.3999, -8.2245], // Portugal
PW: [7.5150, 134.5825], // Palau
PY: [-23.4425, -58.4438],// Paraguay
QA: [25.3548, 51.1839], // Qatar
RE: [-21.1151, 55.5364], // Reunion
RO: [45.9432, 24.9668], // Romania
RS: [44.0165, 21.0059], // Serbia
RU: [61.5240, 105.3188], // Russia
RW: [-1.9403, 29.8739], // Rwanda
SA: [23.8859, 45.0792], // Saudi Arabia
SB: [-9.6457, 160.1562], // Solomon Islands
SC: [-4.6796, 55.4920], // Seychelles
SD: [12.8628, 30.2176], // Sudan
SE: [60.1282, 18.6435], // Sweden
SG: [1.3521, 103.8198], // Singapore
SH: [-24.1435, -10.0307],// Saint Helena
SI: [46.1512, 14.9955], // Slovenia
SJ: [77.5536, 23.6703], // Svalbard and Jan Mayen
SK: [48.6690, 19.6990], // Slovakia
SL: [8.4606, -11.7799], // Sierra Leone
SM: [43.9424, 12.4578], // San Marino
SN: [14.4974, -14.4524], // Senegal
SO: [5.1521, 46.1996], // Somalia
SR: [3.9193, -56.0278], // Suriname
SS: [4.8594, 31.5713], // South Sudan
ST: [0.1864, 6.6131], // Sao Tome and Principe
SV: [13.7942, -88.8965], // El Salvador
SX: [18.0425, -63.0548], // Sint Maarten
SY: [34.8021, 38.9968], // Syria
SZ: [-26.5225, 31.4659], // Eswatini (Swaziland)
TC: [21.6940, -71.7979], // Turks and Caicos Islands
TD: [15.4542, 18.7322], // Chad
TF: [-49.2804, 69.3486], // French Southern Territories
TG: [8.6195, 0.8248], // Togo
TH: [15.8700, 100.9925], // Thailand
TJ: [38.8610, 71.2761], // Tajikistan
TK: [-9.2000, -171.8484],// Tokelau
TL: [-8.8742, 125.7275], // Timor-Leste
TM: [38.9697, 59.5563], // Turkmenistan
TN: [33.8869, 9.5375], // Tunisia
TO: [-21.1790, -175.1982],// Tonga
TR: [38.9637, 35.2433], // Turkey
TT: [10.6918, -61.2225], // Trinidad and Tobago
TV: [-7.1095, 177.6493], // Tuvalu
TW: [23.6978, 120.9605], // Taiwan
TZ: [-6.3690, 34.8888], // Tanzania
UA: [48.3794, 31.1656], // Ukraine
UG: [1.3733, 32.2903], // Uganda
UM: [19.2823, 166.6470], // United States Minor Outlying Islands
US: [37.0902, -95.7129], // United States
UY: [-32.5228, -55.7658],// Uruguay
UZ: [41.3775, 64.5853], // Uzbekistan
VA: [41.9029, 12.4534], // Vatican City
VC: [12.9843, -61.2872], // Saint Vincent and the Grenadines
VE: [6.4238, -66.5897], // Venezuela
VG: [18.4207, -64.6400], // British Virgin Islands
VI: [18.3358, -64.8963], // U.S. Virgin Islands
VN: [14.0583, 108.2772], // Vietnam
VU: [-15.3767, 166.9592],// Vanuatu
WF: [-13.7687, -177.1561],// Wallis and Futuna
WS: [-13.7590, -172.1046],// Samoa
XK: [42.6026, 20.9030], // Kosovo
YE: [15.5527, 48.5164], // Yemen
YT: [-12.8275, 45.1662], // Mayotte
ZA: [-30.5595, 22.9375], // South Africa
ZM: [-13.1339, 27.8493], // Zambia
ZW: [-19.0154, 29.1549], // Zimbabwe
};
/**
* Returns the [latitude, longitude] centroid for a given ISO 3166-1 alpha-2
* country code, or null if the code is not recognised.
*
* The lookup is case-insensitive.
*/
export function countryToLatLng(code: string): [number, number] | null {
if (!code) return null;
const normalised = code.trim().toUpperCase();
return COUNTRY_CENTROIDS[normalised] ?? null;
}

View File

@ -1,47 +0,0 @@
import crypto from 'crypto'
const NAVIDROME_URL = process.env.NAVIDROME_URL || 'https://music.jefflix.lol'
const NAVIDROME_USER = process.env.NAVIDROME_USER || ''
const NAVIDROME_PASS = process.env.NAVIDROME_PASS || ''
function subsonicAuth() {
const salt = crypto.randomBytes(6).toString('hex')
const token = crypto.createHash('md5').update(NAVIDROME_PASS + salt).digest('hex')
return { u: NAVIDROME_USER, t: token, s: salt, v: '1.16.1', c: 'jefflix', f: 'json' }
}
function buildUrl(path: string, params: Record<string, string> = {}) {
const auth = subsonicAuth()
const url = new URL(`/rest/${path}`, NAVIDROME_URL)
for (const [k, v] of Object.entries({ ...auth, ...params })) {
url.searchParams.set(k, v)
}
return url.toString()
}
/** JSON response from Subsonic API (parsed) */
export async function navidromeGet<T = Record<string, unknown>>(
path: string,
params: Record<string, string> = {}
): Promise<T> {
const url = buildUrl(path, params)
const res = await fetch(url, { cache: 'no-store' })
if (!res.ok) throw new Error(`Navidrome ${path} returned ${res.status}`)
const json = await res.json()
const sub = json['subsonic-response']
if (sub?.status !== 'ok') {
throw new Error(sub?.error?.message || `Navidrome error on ${path}`)
}
return sub as T
}
/** Raw binary response (for streaming audio, cover art) */
export async function navidromeFetch(
path: string,
params: Record<string, string> = {}
): Promise<Response> {
const url = buildUrl(path, params)
const res = await fetch(url, { cache: 'no-store' })
if (!res.ok) throw new Error(`Navidrome ${path} returned ${res.status}`)
return res
}

View File

@ -1,163 +0,0 @@
/**
* IndexedDB wrapper for offline audio storage.
* Two object stores:
* - 'audio-blobs': trackId Blob (the audio file)
* - 'track-meta': trackId Track metadata (for listing without loading blobs)
*/
import type { Track } from '@/components/music/music-provider'
const DB_NAME = 'soulsync-offline'
const DB_VERSION = 1
const AUDIO_STORE = 'audio-blobs'
const META_STORE = 'track-meta'
function openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION)
req.onupgradeneeded = () => {
const db = req.result
if (!db.objectStoreNames.contains(AUDIO_STORE)) {
db.createObjectStore(AUDIO_STORE)
}
if (!db.objectStoreNames.contains(META_STORE)) {
db.createObjectStore(META_STORE)
}
}
req.onsuccess = () => resolve(req.result)
req.onerror = () => reject(req.error)
})
}
function tx(
db: IDBDatabase,
stores: string | string[],
mode: IDBTransactionMode = 'readonly'
): IDBTransaction {
return db.transaction(stores, mode)
}
/** Save an audio blob and track metadata */
export async function saveTrack(trackId: string, blob: Blob, meta: Track): Promise<void> {
const db = await openDB()
const t = tx(db, [AUDIO_STORE, META_STORE], 'readwrite')
t.objectStore(AUDIO_STORE).put(blob, trackId)
t.objectStore(META_STORE).put(meta, trackId)
return new Promise((resolve, reject) => {
t.oncomplete = () => { db.close(); resolve() }
t.onerror = () => { db.close(); reject(t.error) }
})
}
/** Get the audio blob for a track */
export async function getTrackBlob(trackId: string): Promise<Blob | undefined> {
const db = await openDB()
const t = tx(db, AUDIO_STORE)
const req = t.objectStore(AUDIO_STORE).get(trackId)
return new Promise((resolve, reject) => {
req.onsuccess = () => { db.close(); resolve(req.result) }
req.onerror = () => { db.close(); reject(req.error) }
})
}
/** Check if a track is stored offline */
export async function hasTrack(trackId: string): Promise<boolean> {
const db = await openDB()
const t = tx(db, META_STORE)
const req = t.objectStore(META_STORE).count(trackId)
return new Promise((resolve, reject) => {
req.onsuccess = () => { db.close(); resolve(req.result > 0) }
req.onerror = () => { db.close(); reject(req.error) }
})
}
/** Save only the audio blob (no metadata — used for pre-caching) */
export async function saveBlob(trackId: string, blob: Blob): Promise<void> {
const db = await openDB()
const t = tx(db, AUDIO_STORE, 'readwrite')
t.objectStore(AUDIO_STORE).put(blob, trackId)
return new Promise((resolve, reject) => {
t.oncomplete = () => { db.close(); resolve() }
t.onerror = () => { db.close(); reject(t.error) }
})
}
/** Check if an audio blob exists (regardless of metadata) */
export async function hasBlob(trackId: string): Promise<boolean> {
const db = await openDB()
const t = tx(db, AUDIO_STORE)
const req = t.objectStore(AUDIO_STORE).count(trackId)
return new Promise((resolve, reject) => {
req.onsuccess = () => { db.close(); resolve(req.result > 0) }
req.onerror = () => { db.close(); reject(req.error) }
})
}
/** Remove a track from offline storage */
export async function removeTrack(trackId: string): Promise<void> {
const db = await openDB()
const t = tx(db, [AUDIO_STORE, META_STORE], 'readwrite')
t.objectStore(AUDIO_STORE).delete(trackId)
t.objectStore(META_STORE).delete(trackId)
return new Promise((resolve, reject) => {
t.oncomplete = () => { db.close(); resolve() }
t.onerror = () => { db.close(); reject(t.error) }
})
}
/** List all offline tracks (metadata only) */
export async function listOfflineTracks(): Promise<Track[]> {
const db = await openDB()
const t = tx(db, META_STORE)
const req = t.objectStore(META_STORE).getAll()
return new Promise((resolve, reject) => {
req.onsuccess = () => { db.close(); resolve(req.result) }
req.onerror = () => { db.close(); reject(req.error) }
})
}
/** Get all offline track IDs (for fast Set building) */
export async function listOfflineIds(): Promise<string[]> {
const db = await openDB()
const t = tx(db, META_STORE)
const req = t.objectStore(META_STORE).getAllKeys()
return new Promise((resolve, reject) => {
req.onsuccess = () => { db.close(); resolve(req.result as string[]) }
req.onerror = () => { db.close(); reject(req.error) }
})
}
/** Get total storage used (sum of all blob sizes in bytes) */
export async function getTotalSize(): Promise<number> {
const db = await openDB()
const t = tx(db, AUDIO_STORE)
const store = t.objectStore(AUDIO_STORE)
const req = store.openCursor()
let total = 0
return new Promise((resolve, reject) => {
req.onsuccess = () => {
const cursor = req.result
if (cursor) {
const blob = cursor.value as Blob
total += blob.size
cursor.continue()
} else {
db.close()
resolve(total)
}
}
req.onerror = () => { db.close(); reject(req.error) }
})
}
/** Remove all offline data */
export async function clearAll(): Promise<void> {
const db = await openDB()
const t = tx(db, [AUDIO_STORE, META_STORE], 'readwrite')
t.objectStore(AUDIO_STORE).clear()
t.objectStore(META_STORE).clear()
return new Promise((resolve, reject) => {
t.oncomplete = () => { db.close(); resolve() }
t.onerror = () => { db.close(); reject(t.error) }
})
}

View File

@ -1,44 +0,0 @@
import { hasBlob, saveBlob } from '@/lib/offline-db'
/**
* Pre-cache a track's audio blob into IndexedDB.
* Only stores the blob (no metadata) so pre-cached tracks don't appear
* in the offline library, but the player still finds them via getTrackBlob().
* Best-effort errors are silently swallowed.
*/
export async function precacheTrack(
trackId: string,
signal?: AbortSignal
): Promise<void> {
if (await hasBlob(trackId)) return
if (signal?.aborted) return
const res = await fetch(`/api/music/stream/${trackId}`, { signal })
if (!res.ok) return
const blob = await res.blob()
if (signal?.aborted) return
await saveBlob(trackId, blob)
}
/**
* Pre-cache the next N tracks in a queue sequentially.
* Sequential to avoid flooding bandwidth on mobile connections.
*/
export async function precacheUpcoming(
queue: { id: string }[],
currentIndex: number,
count: number,
signal: AbortSignal
): Promise<void> {
const upcoming = queue.slice(currentIndex + 1, currentIndex + 1 + count)
for (const track of upcoming) {
if (signal.aborted) return
try {
await precacheTrack(track.id, signal)
} catch {
// best-effort — continue with next track
}
}
}

View File

@ -1,56 +0,0 @@
export interface RadioPlace {
id: string
title: string
country: string
lat: number
lng: number
size: number
}
export interface RadioChannel {
id: string
title: string
placeTitle: string
country: string
website?: string
}
export interface RadioSearchResult {
stations: Array<{
id: string
title: string
placeId: string
placeTitle: string
country: string
}>
places: Array<{
id: string
title: string
country: string
}>
}
export async function getPlaces(): Promise<RadioPlace[]> {
const res = await fetch('/api/radio/places')
if (!res.ok) throw new Error('Failed to load radio places')
return res.json()
}
export async function getChannels(placeId: string): Promise<RadioChannel[]> {
const res = await fetch(`/api/radio/channels/${placeId}`)
if (!res.ok) throw new Error('Failed to load channels')
return res.json()
}
export async function resolveStreamUrl(channelId: string): Promise<string> {
const res = await fetch(`/api/radio/stream/${channelId}`)
if (!res.ok) throw new Error('Failed to resolve stream')
const data = await res.json()
return data.url
}
export async function searchRadio(q: string): Promise<RadioSearchResult> {
const res = await fetch(`/api/radio/search?q=${encodeURIComponent(q)}`)
if (!res.ok) throw new Error('Search failed')
return res.json()
}

View File

@ -1,90 +0,0 @@
export interface SlskdRawFile {
filename: string
size: number
bitRate: number
length: number
}
export interface SlskdRawResponse {
username: string
files: SlskdRawFile[]
freeUploadSlots: number
speed: number
}
export interface DedupedFile {
displayName: string
filename: string
size: number
bitRate: number
length: number
bestPeer: {
username: string
freeSlots: number
speed: number
}
peerCount: number
}
function normalizeName(filename: string): string {
// Strip path separators (Windows backslash or Unix forward slash)
const basename = filename.replace(/^.*[\\\/]/, '')
// Strip extension
const noExt = basename.replace(/\.[^.]+$/, '')
return noExt.toLowerCase().trim()
}
function prettyName(filename: string): string {
return filename.replace(/^.*[\\\/]/, '').replace(/\.[^.]+$/, '')
}
export function extractBestFiles(responses: SlskdRawResponse[], limit = 30): DedupedFile[] {
const groups = new Map<string, { file: SlskdRawFile; peer: SlskdRawResponse; displayName: string }[]>()
for (const peer of responses) {
if (!peer.files?.length) continue
for (const file of peer.files) {
const key = normalizeName(file.filename)
if (!key) continue
const entry = { file, peer, displayName: prettyName(file.filename) }
const existing = groups.get(key)
if (existing) {
existing.push(entry)
} else {
groups.set(key, [entry])
}
}
}
const deduped: DedupedFile[] = []
for (const [, entries] of groups) {
// Pick best peer: prefer freeUploadSlots > 0, then highest speed
entries.sort((a, b) => {
const aFree = a.peer.freeUploadSlots > 0 ? 1 : 0
const bFree = b.peer.freeUploadSlots > 0 ? 1 : 0
if (aFree !== bFree) return bFree - aFree
return b.peer.speed - a.peer.speed
})
const best = entries[0]
deduped.push({
displayName: best.displayName,
filename: best.file.filename,
size: best.file.size,
bitRate: best.file.bitRate,
length: best.file.length,
bestPeer: {
username: best.peer.username,
freeSlots: best.peer.freeUploadSlots,
speed: best.peer.speed,
},
peerCount: entries.length,
})
}
// Sort by highest bitRate first
deduped.sort((a, b) => (b.bitRate || 0) - (a.bitRate || 0))
return deduped.slice(0, limit)
}

View File

@ -1,19 +0,0 @@
const SLSKD_URL = process.env.SLSKD_URL || 'https://slskd.jefflix.lol'
const SLSKD_API_KEY = process.env.SLSKD_API_KEY || ''
export async function slskdFetch(
path: string,
options: RequestInit = {}
): Promise<Response> {
const url = `${SLSKD_URL}/api/v0${path}`
const res = await fetch(url, {
...options,
headers: {
'X-API-Key': SLSKD_API_KEY,
'Content-Type': 'application/json',
...options.headers,
},
cache: 'no-store',
})
return res
}

View File

@ -1,195 +0,0 @@
'use client'
import React, { createContext, useContext, useCallback, useEffect, useRef, useState } from 'react'
import type { Track } from '@/components/music/music-provider'
import {
saveTrack,
removeTrack as removeFromDB,
listOfflineIds,
getTotalSize,
clearAll as clearDB,
listOfflineTracks,
} from '@/lib/offline-db'
type DownloadStatus = 'idle' | 'queued' | 'downloading'
interface OfflineContextValue {
offlineIds: Set<string>
queue: Track[]
activeDownloadId: string | null
storageUsed: number
download: (track: Track) => void
remove: (trackId: string) => void
clearAll: () => void
getStatus: (trackId: string) => DownloadStatus
sync: () => Promise<void>
offlineTracks: Track[]
loading: boolean
}
const OfflineContext = createContext<OfflineContextValue | null>(null)
export function useOffline() {
const ctx = useContext(OfflineContext)
if (!ctx) throw new Error('useOffline must be used within OfflineProvider')
return ctx
}
export function OfflineProvider({ children }: { children: React.ReactNode }) {
const [offlineIds, setOfflineIds] = useState<Set<string>>(new Set())
const [offlineTracks, setOfflineTracks] = useState<Track[]>([])
const [queue, setQueue] = useState<Track[]>([])
const [activeDownloadId, setActiveDownloadId] = useState<string | null>(null)
const [storageUsed, setStorageUsed] = useState(0)
const [loading, setLoading] = useState(true)
const processingRef = useRef(false)
// Mirror queue in a ref for access in async loops
const queueRef = useRef<Track[]>([])
const refreshLocal = useCallback(async () => {
const [ids, tracks, size] = await Promise.all([
listOfflineIds(),
listOfflineTracks(),
getTotalSize(),
])
setOfflineIds(new Set(ids))
setOfflineTracks(tracks)
setStorageUsed(size)
}, [])
const updateQueue = useCallback((updater: (prev: Track[]) => Track[]) => {
setQueue((prev) => {
const next = updater(prev)
queueRef.current = next
return next
})
}, [])
const processQueue = useCallback(async () => {
if (processingRef.current) return
processingRef.current = true
while (queueRef.current.length > 0) {
const nextTrack = queueRef.current[0]
setActiveDownloadId(nextTrack.id)
try {
const res = await fetch(`/api/music/stream/${nextTrack.id}`)
if (!res.ok) throw new Error(`Stream failed: ${res.status}`)
const blob = await res.blob()
await saveTrack(nextTrack.id, blob, nextTrack)
// Sync to server playlist (non-critical)
await fetch('/api/music/offline', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ songId: nextTrack.id }),
}).catch(() => {})
updateQueue((prev) => prev.filter((t) => t.id !== nextTrack.id))
await refreshLocal()
} catch (err) {
console.error(`Failed to download ${nextTrack.id}:`, err)
updateQueue((prev) => prev.filter((t) => t.id !== nextTrack.id))
}
}
setActiveDownloadId(null)
processingRef.current = false
}, [refreshLocal, updateQueue])
const sync = useCallback(async () => {
try {
setLoading(true)
const res = await fetch('/api/music/offline')
if (!res.ok) return
const { songs } = await res.json() as { songs: Track[] }
const localIds = await listOfflineIds()
const localSet = new Set(localIds)
const serverIds = new Set(songs.map((s: Track) => s.id))
// Download songs on server but not local
const toDownload = songs.filter((s: Track) => !localSet.has(s.id))
if (toDownload.length > 0) {
updateQueue((prev) => {
const existingIds = new Set(prev.map((t) => t.id))
return [...prev, ...toDownload.filter((t: Track) => !existingIds.has(t.id))]
})
}
// Remove local songs deleted from server
for (const localId of localIds) {
if (!serverIds.has(localId)) {
await removeFromDB(localId)
}
}
await refreshLocal()
} catch (err) {
console.error('Offline sync error:', err)
} finally {
setLoading(false)
}
}, [refreshLocal, updateQueue])
// Initial load + sync
useEffect(() => {
refreshLocal().then(() => sync())
}, [refreshLocal, sync])
// Process queue when items are added
useEffect(() => {
if (queue.length > 0 && !processingRef.current) {
processQueue()
}
}, [queue, processQueue])
const download = useCallback((track: Track) => {
updateQueue((prev) => {
if (prev.some((t) => t.id === track.id)) return prev
return [...prev, track]
})
}, [updateQueue])
const remove = useCallback(async (trackId: string) => {
await removeFromDB(trackId)
await fetch('/api/music/offline', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ songId: trackId }),
}).catch(() => {})
await refreshLocal()
}, [refreshLocal])
const clearAllOffline = useCallback(async () => {
await clearDB()
await refreshLocal()
}, [refreshLocal])
const getStatus = useCallback((trackId: string): DownloadStatus => {
if (offlineIds.has(trackId)) return 'idle'
if (activeDownloadId === trackId) return 'downloading'
if (queue.some((t) => t.id === trackId)) return 'queued'
return 'idle'
}, [offlineIds, activeDownloadId, queue])
return (
<OfflineContext.Provider value={{
offlineIds,
queue,
activeDownloadId,
storageUsed,
download,
remove,
clearAll: clearAllOffline,
getStatus,
sync,
offlineTracks,
loading,
}}>
{children}
</OfflineContext.Provider>
)
}

View File

@ -1,128 +0,0 @@
import WebSocket from 'ws'
interface ThreadfinConfig {
url: string
user: string
pass: string
}
interface XepgEntry {
'tvg-id': string
'x-active': boolean
[key: string]: unknown
}
type EpgMapping = Record<string, XepgEntry>
interface ActivateResult {
activated: string[]
notFound: string[]
}
function getConfig(): ThreadfinConfig {
const url = process.env.THREADFIN_URL
const user = process.env.THREADFIN_USER
const pass = process.env.THREADFIN_PASS
if (!url || !user || !pass) {
throw new Error('THREADFIN_URL, THREADFIN_USER, THREADFIN_PASS must be set')
}
return { url: url.replace(/\/$/, ''), user, pass }
}
async function login(): Promise<string> {
const { url, user, pass } = getConfig()
const res = await fetch(`${url}/api/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cmd: 'login', username: user, password: pass }),
})
if (!res.ok) throw new Error(`Threadfin login failed: ${res.status}`)
const data = await res.json()
if (!data.token) throw new Error('Threadfin login returned no token')
return data.token
}
export async function activateChannels(
channelIds: string[],
): Promise<ActivateResult> {
const { url } = getConfig()
const token = await login()
const wsUrl = url.replace(/^http/, 'ws') + `/data/?Token=${token}`
return new Promise<ActivateResult>((resolve, reject) => {
const ws = new WebSocket(wsUrl)
const timeout = setTimeout(() => {
ws.close()
reject(new Error('Threadfin WebSocket timed out after 30s'))
}, 30_000)
let currentToken = token
ws.on('error', (err) => {
clearTimeout(timeout)
reject(err)
})
ws.on('message', (raw) => {
try {
const msg = JSON.parse(raw.toString())
// Update token if rotated
if (msg.token) currentToken = msg.token
// Initial data response contains the xepg map
if (msg.xepg) {
const epgMapping: EpgMapping = msg.xepg
const activated: string[] = []
const idSet = new Set(channelIds)
const foundIds = new Set<string>()
// Find and activate matching channels
for (const [key, entry] of Object.entries(epgMapping)) {
const tvgId = entry['tvg-id']
if (tvgId && idSet.has(tvgId)) {
entry['x-active'] = true
activated.push(tvgId)
foundIds.add(tvgId)
}
}
const notFound = channelIds.filter((id) => !foundIds.has(id))
if (activated.length === 0) {
clearTimeout(timeout)
ws.close()
resolve({ activated, notFound })
return
}
// Must send the ENTIRE map back
const saveMsg = JSON.stringify({
cmd: 'saveEpgMapping',
epgMapping,
token: currentToken,
})
ws.send(saveMsg, (err) => {
clearTimeout(timeout)
if (err) {
ws.close()
reject(err)
return
}
// Give Threadfin a moment to process, then close
setTimeout(() => {
ws.close()
resolve({ activated, notFound })
}, 1000)
})
}
} catch (err) {
clearTimeout(timeout)
ws.close()
reject(err)
}
})
})
}

View File

@ -1,58 +0,0 @@
import { createHmac } from 'crypto'
interface ApprovePayload {
channels: { id: string; name: string }[]
email: string
exp: number
}
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000
function getSecret(): string {
const secret = process.env.TOKEN_SECRET
if (!secret) throw new Error('TOKEN_SECRET env var is not set')
return secret
}
export function createApproveToken(
channels: { id: string; name: string }[],
email: string,
): string {
const payload: ApprovePayload = {
channels,
email,
exp: Date.now() + SEVEN_DAYS_MS,
}
const data = Buffer.from(JSON.stringify(payload)).toString('base64url')
const sig = createHmac('sha256', getSecret()).update(data).digest('hex')
return `${data}.${sig}`
}
export function verifyApproveToken(token: string): ApprovePayload | null {
const parts = token.split('.')
if (parts.length !== 2) return null
const [data, sig] = parts
const expected = createHmac('sha256', getSecret()).update(data).digest('hex')
// Constant-time comparison
if (sig.length !== expected.length) return null
let mismatch = 0
for (let i = 0; i < sig.length; i++) {
mismatch |= sig.charCodeAt(i) ^ expected.charCodeAt(i)
}
if (mismatch !== 0) return null
try {
const payload: ApprovePayload = JSON.parse(
Buffer.from(data, 'base64url').toString('utf-8'),
)
if (!payload.exp || !payload.channels || !payload.email) return null
if (Date.now() > payload.exp) return null
return payload
} catch {
return null
}
}

View File

@ -1,25 +0,0 @@
import { NextResponse } from "next/server"
import type { NextRequest } from "next/server"
export function middleware(request: NextRequest) {
const hasAccess = request.cookies.get("jefflix-access")?.value === "granted"
if (!hasAccess) {
return NextResponse.redirect(new URL("/gate", request.url))
}
return NextResponse.next()
}
export const config = {
matcher: [
/*
* Match all paths except:
* - /gate (the code entry page)
* - /api/verify-code (the verification endpoint)
* - /_next (Next.js internals)
* - /favicon.ico, /icon*, /apple-icon*, /og-image* (static assets)
*/
"/((?!gate|api/verify-code|api/version|_next|favicon\\.ico|icon|apple-icon|og-image|manifest\\.json|sw\\.js|textures/).*)",
],
}

View File

@ -7,18 +7,6 @@ const nextConfig = {
unoptimized: true,
},
output: 'standalone',
env: {
BUILD_ID: process.env.BUILD_ID || new Date().toISOString(),
},
async redirects() {
return [
{
source: '/invite',
destination: 'https://invite.jefflix.lol',
permanent: true,
},
]
},
}
export default nextConfig

4013
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -37,6 +37,7 @@
"@radix-ui/react-toggle": "1.1.1",
"@radix-ui/react-toggle-group": "1.1.1",
"@radix-ui/react-tooltip": "1.1.6",
"@vercel/analytics": "latest",
"autoprefixer": "^10.4.20",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@ -47,11 +48,9 @@
"lucide-react": "^0.454.0",
"next": "16.0.10",
"next-themes": "^0.4.6",
"nodemailer": "^8.0.1",
"react": "19.2.0",
"react-day-picker": "9.8.0",
"react-dom": "19.2.0",
"react-globe.gl": "^2.37.1",
"react-hook-form": "^7.60.0",
"react-resizable-panels": "^2.1.7",
"recharts": "2.15.4",
@ -59,16 +58,13 @@
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7",
"vaul": "^1.1.2",
"ws": "^8.20.0",
"zod": "3.25.76"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.9",
"@types/node": "^22",
"@types/nodemailer": "^7.0.9",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/ws": "^8.18.1",
"postcss": "^8.5",
"tailwindcss": "^4.1.9",
"tw-animate-css": "1.3.3",

File diff suppressed because it is too large Load Diff

View File

@ -1,3 +1,26 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<text y="0.9em" font-size="90">🤣</text>
<svg width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg">
<style>
@media (prefers-color-scheme: light) {
.background { fill: black; }
.foreground { fill: white; }
}
@media (prefers-color-scheme: dark) {
.background { fill: white; }
.foreground { fill: black; }
}
</style>
<g clip-path="url(#clip0_7960_43945)">
<rect class="background" width="180" height="180" rx="37" />
<g style="transform: scale(95%); transform-origin: center">
<path class="foreground"
d="M101.141 53H136.632C151.023 53 162.689 64.6662 162.689 79.0573V112.904H148.112V79.0573C148.112 78.7105 148.098 78.3662 148.072 78.0251L112.581 112.898C112.701 112.902 112.821 112.904 112.941 112.904H148.112V126.672H112.941C98.5504 126.672 86.5638 114.891 86.5638 100.5V66.7434H101.141V100.5C101.141 101.15 101.191 101.792 101.289 102.422L137.56 66.7816C137.255 66.7563 136.945 66.7434 136.632 66.7434H101.141V53Z" />
<path class="foreground"
d="M65.2926 124.136L14 66.7372H34.6355L64.7495 100.436V66.7372H80.1365V118.47C80.1365 126.278 70.4953 129.958 65.2926 124.136Z" />
</g>
</g>
<defs>
<clipPath id="clip0_7960_43945">
<rect width="180" height="180" fill="white" />
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 115 B

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -1,49 +0,0 @@
{
"name": "Jefflix",
"short_name": "Jefflix",
"description": "Self-hosted media streaming — movies, TV, music, and world radio",
"start_url": "/",
"display": "standalone",
"background_color": "#0a0a0a",
"theme_color": "#0a0a0a",
"orientation": "any",
"scope": "/",
"icons": [
{
"src": "/icon-light-32x32.png",
"sizes": "32x32",
"type": "image/png"
},
{
"src": "/apple-icon.png",
"sizes": "180x180",
"type": "image/png"
},
{
"src": "/icon.svg",
"sizes": "any",
"type": "image/svg+xml"
}
],
"shortcuts": [
{
"name": "Music",
"short_name": "Music",
"url": "/music",
"icons": [{ "src": "/icon.svg", "sizes": "any" }]
},
{
"name": "World Radio",
"short_name": "Radio",
"url": "/radio",
"icons": [{ "src": "/icon.svg", "sizes": "any" }]
},
{
"name": "Offline Library",
"short_name": "Offline",
"url": "/offline",
"icons": [{ "src": "/icon.svg", "sizes": "any" }]
}
],
"categories": ["entertainment", "music"]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

View File

@ -1,116 +0,0 @@
/// <reference lib="webworker" />
const CACHE_NAME = 'soulsync-shell-v2'
const DB_NAME = 'soulsync-offline'
const AUDIO_STORE = 'audio-blobs'
// App shell files to cache for offline UI access
const SHELL_FILES = [
'/',
'/music',
'/radio',
'/offline',
]
// Install: pre-cache app shell
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) =>
cache.addAll(SHELL_FILES).catch(() => {
// Non-critical if some pages fail to cache
})
)
)
self.skipWaiting()
})
// Activate: clean old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE_NAME && k.startsWith('soulsync-')).map((k) => caches.delete(k)))
)
)
self.clients.claim()
})
/**
* Open IndexedDB from the service worker to serve cached audio
*/
function openDB() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, 1)
req.onupgradeneeded = () => {
const db = req.result
if (!db.objectStoreNames.contains(AUDIO_STORE)) {
db.createObjectStore(AUDIO_STORE)
}
if (!db.objectStoreNames.contains('track-meta')) {
db.createObjectStore('track-meta')
}
}
req.onsuccess = () => resolve(req.result)
req.onerror = () => reject(req.error)
})
}
function getFromDB(trackId) {
return openDB().then(
(db) =>
new Promise((resolve, reject) => {
const tx = db.transaction(AUDIO_STORE, 'readonly')
const req = tx.objectStore(AUDIO_STORE).get(trackId)
req.onsuccess = () => {
db.close()
resolve(req.result)
}
req.onerror = () => {
db.close()
reject(req.error)
}
})
)
}
// Listen for SKIP_WAITING message from the app to activate a waiting SW
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting()
}
})
// Fetch: intercept /api/music/stream/ requests to serve from IndexedDB
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url)
// Intercept stream requests
const streamMatch = url.pathname.match(/^\/api\/music\/stream\/(.+)$/)
if (streamMatch) {
const trackId = streamMatch[1]
event.respondWith(
getFromDB(trackId).then((blob) => {
if (blob) {
return new Response(blob, {
headers: {
'Content-Type': blob.type || 'audio/mpeg',
'Content-Length': String(blob.size),
},
})
}
// Not cached, fetch from network
return fetch(event.request)
}).catch(() => fetch(event.request))
)
return
}
// For navigation requests: try network first, fall back to cache
if (event.request.mode === 'navigate') {
event.respondWith(
fetch(event.request).catch(() =>
caches.match(event.request).then((cached) => cached || caches.match('/'))
)
)
return
}
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

View File

@ -1,76 +0,0 @@
services:
# slskd - Soulseek client with REST API
slskd:
image: slskd/slskd:latest
container_name: slskd
restart: unless-stopped
environment:
- SLSKD_REMOTE_CONFIGURATION=true
- SLSKD_SHARED_DIR=/shared
- SLSKD_DOWNLOADS_DIR=/downloads
- SLSKD_NO_HTTPS=true
- TZ=Europe/Berlin
volumes:
- ./config/slskd:/app
- ./downloads:/downloads
# Share some of your music library back (important - avoid bans!)
- /mnt/hetzner-media/media/music:/shared:ro
# Transfer folder - completed downloads move here for import
- ./transfer:/transfer
ports:
- "50300:50300" # Soulseek listen port
- "50300:50300/udp"
networks:
- soulsync-network
- traefik-public
labels:
- "traefik.enable=true"
- "traefik.http.routers.slskd.rule=Host(`soulseek.jefflix.lol`) || Host(`slskd.jefflix.lol`)"
- "traefik.http.routers.slskd.entrypoints=web"
- "traefik.http.services.slskd.loadbalancer.server.port=5030"
- "traefik.docker.network=traefik-public"
# SoulSync - Spotify/Tidal to Soulseek sync
soulsync:
image: boulderbadgedad/soulsync:latest
container_name: soulsync
restart: unless-stopped
environment:
- TZ=Europe/Berlin
- PUID=1000
- PGID=1000
- PYTHONPATH=/app
- FLASK_ENV=production
volumes:
- ./config/soulsync:/app/data
- ./config/soulsync/logs:/app/logs
- ./config/soulsync/config:/app/config
- ./config/soulsync/storage:/app/storage
# Access to existing music library for comparison
- /mnt/hetzner-media/media/music:/music:ro
# Downloads from slskd
- ./downloads:/downloads
# Transfer folder for completed/processed files
- ./transfer:/transfer
depends_on:
- slskd
networks:
- soulsync-network
- traefik-public
- media-network
extra_hosts:
- "host.docker.internal:host-gateway"
labels:
- "traefik.enable=true"
- "traefik.http.routers.soulsync.rule=Host(`soulsync.jefflix.lol`)"
- "traefik.http.routers.soulsync.entrypoints=web"
- "traefik.http.services.soulsync.loadbalancer.server.port=8008"
- "traefik.docker.network=traefik-public"
networks:
soulsync-network:
driver: bridge
traefik-public:
external: true
media-network:
external: true

View File

@ -1,10 +1,6 @@
{
"compilerOptions": {
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"target": "ES6",
"skipLibCheck": true,
@ -15,7 +11,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"jsx": "preserve",
"incremental": true,
"plugins": [
{
@ -23,19 +19,9 @@
}
],
"paths": {
"@/*": [
"./*"
]
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}