From 6eaa9b464e5369d7157c425f153148f12428df9e Mon Sep 17 00:00:00 2001 From: Paul Bailey Date: Sun, 7 Dec 2025 22:35:41 +0000 Subject: [PATCH] Add web-based admin panel for library management, playlists, and track organization --- .vscode/settings.json | 2 + public/app.js | 298 +++++++++++++++++++++++++++++++ public/index.html | 407 ++++++++++++++++++++++++++++++++++++++++++ src/server.js | 3 + 4 files changed, 710 insertions(+) create mode 100644 .vscode/settings.json create mode 100644 public/app.js create mode 100644 public/index.html diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..7a73a41 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,2 @@ +{ +} \ No newline at end of file diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..e0ca093 --- /dev/null +++ b/public/app.js @@ -0,0 +1,298 @@ +// API Base URL +const API_BASE = window.location.origin; + +// Load dashboard stats on page load +document.addEventListener('DOMContentLoaded', () => { + loadDashboard(); + updateSubsonicUrl(); +}); + +// Switch between tabs +function switchTab(tabName) { + // Hide all tabs + document.querySelectorAll('.tab-content').forEach(tab => { + tab.classList.remove('active'); + }); + document.querySelectorAll('.tab').forEach(tab => { + tab.classList.remove('active'); + }); + + // Show selected tab + document.getElementById(tabName).classList.add('active'); + event.target.classList.add('active'); + + // Load data for the tab + if (tabName === 'tracks') loadTracks(); + if (tabName === 'albums') loadAlbums(); + if (tabName === 'playlists') loadPlaylists(); +} + +// Load dashboard statistics +async function loadDashboard() { + try { + const [tracksRes, albumsRes, playlistsRes] = await Promise.all([ + fetch(`${API_BASE}/api/tracks`), + fetch(`${API_BASE}/api/albums`), + fetch(`${API_BASE}/api/playlists`) + ]); + + const tracks = await tracksRes.json(); + const albums = await albumsRes.json(); + const playlists = await playlistsRes.json(); + + const singles = tracks.tracks.filter(t => t.isSingle).length; + + document.getElementById('totalTracks').textContent = tracks.tracks.length; + document.getElementById('totalAlbums').textContent = albums.albums.length; + document.getElementById('totalSingles').textContent = singles; + document.getElementById('totalPlaylists').textContent = playlists.playlists.length; + } catch (error) { + console.error('Error loading dashboard:', error); + } +} + +// Load all tracks +async function loadTracks() { + const trackList = document.getElementById('trackList'); + trackList.innerHTML = '
  • Loading tracks...
  • '; + + try { + const res = await fetch(`${API_BASE}/api/tracks`); + const data = await res.json(); + + if (data.tracks.length === 0) { + trackList.innerHTML = '
  • No tracks found. Scan your library first.
  • '; + return; + } + + trackList.innerHTML = data.tracks.map(track => ` +
  • +
    +
    + ${track.title} + + ${track.isSingle ? 'Single' : 'Album'} + +
    +
    ${track.artist} • ${track.album}
    +
    +
    + +
    +
  • + `).join(''); + } catch (error) { + trackList.innerHTML = '
  • Error loading tracks
  • '; + console.error('Error loading tracks:', error); + } +} + +// Search tracks +function searchTracks() { + const searchTerm = document.getElementById('searchTracks').value.toLowerCase(); + const items = document.querySelectorAll('.track-item'); + + items.forEach(item => { + const text = item.textContent.toLowerCase(); + item.style.display = text.includes(searchTerm) ? 'flex' : 'none'; + }); +} + +// Toggle single/album status +async function toggleSingle(trackId, isSingle) { + try { + const res = await fetch(`${API_BASE}/api/tracks/${trackId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ isSingle }) + }); + + if (res.ok) { + loadTracks(); + loadDashboard(); + } + } catch (error) { + console.error('Error updating track:', error); + alert('Failed to update track'); + } +} + +// Load albums +async function loadAlbums() { + const albumList = document.getElementById('albumList'); + albumList.innerHTML = '
  • Loading albums...
  • '; + + try { + const res = await fetch(`${API_BASE}/api/albums`); + const data = await res.json(); + + if (data.albums.length === 0) { + albumList.innerHTML = '
  • No albums found
  • '; + return; + } + + albumList.innerHTML = data.albums.map(album => ` +
  • +
    +
    ${album.name}
    +
    ${album.artist} • ${album.trackCount} tracks
    +
    +
  • + `).join(''); + } catch (error) { + albumList.innerHTML = '
  • Error loading albums
  • '; + console.error('Error loading albums:', error); + } +} + +// Load playlists +async function loadPlaylists() { + const playlistList = document.getElementById('playlistList'); + playlistList.innerHTML = '
  • Loading playlists...
  • '; + + try { + const res = await fetch(`${API_BASE}/api/playlists`); + const data = await res.json(); + + if (data.playlists.length === 0) { + playlistList.innerHTML = '
  • No playlists yet. Create one!
  • '; + return; + } + + playlistList.innerHTML = data.playlists.map(playlist => ` +
  • +
    +
    ${playlist.name}
    +
    ${playlist.trackCount} tracks
    +
    +
    + +
    +
  • + `).join(''); + } catch (error) { + playlistList.innerHTML = '
  • Error loading playlists
  • '; + console.error('Error loading playlists:', error); + } +} + +// Show create playlist modal +function showCreatePlaylist() { + document.getElementById('createPlaylistModal').classList.add('active'); +} + +// Close modal +function closeModal() { + document.getElementById('createPlaylistModal').classList.remove('active'); + document.getElementById('playlistName').value = ''; + document.getElementById('playlistDescription').value = ''; +} + +// Create playlist +async function createPlaylist() { + const name = document.getElementById('playlistName').value; + const description = document.getElementById('playlistDescription').value; + + if (!name) { + alert('Please enter a playlist name'); + return; + } + + try { + const res = await fetch(`${API_BASE}/api/playlists`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, description }) + }); + + if (res.ok) { + closeModal(); + loadPlaylists(); + loadDashboard(); + } + } catch (error) { + console.error('Error creating playlist:', error); + alert('Failed to create playlist'); + } +} + +// Delete playlist +async function deletePlaylist(playlistId) { + if (!confirm('Are you sure you want to delete this playlist?')) { + return; + } + + try { + const res = await fetch(`${API_BASE}/api/playlists/${playlistId}`, { + method: 'DELETE' + }); + + if (res.ok) { + loadPlaylists(); + loadDashboard(); + } + } catch (error) { + console.error('Error deleting playlist:', error); + alert('Failed to delete playlist'); + } +} + +// Scan library +async function scanLibrary() { + const scanStatus = document.getElementById('scanStatus'); + scanStatus.style.display = 'block'; + + try { + // Note: This would need a scan endpoint on the server + alert('Library scanning is currently done via CLI: npm run scan\n\nWe can add a web-based scan button if needed!'); + scanStatus.style.display = 'none'; + } catch (error) { + console.error('Error scanning library:', error); + alert('Failed to scan library'); + scanStatus.style.display = 'none'; + } +} + +// Upload audio file +async function uploadAudio() { + const fileInput = document.getElementById('uploadFile'); + const file = fileInput.files[0]; + + if (!file) return; + + const isSingle = confirm('Is this a single track? (Click Cancel if it\'s part of an album)'); + + const formData = new FormData(); + formData.append('audio', file); + formData.append('isSingle', isSingle); + + try { + const res = await fetch(`${API_BASE}/api/upload`, { + method: 'POST', + body: formData + }); + + if (res.ok) { + alert('File uploaded successfully!'); + loadDashboard(); + if (document.getElementById('tracks').classList.contains('active')) { + loadTracks(); + } + } else { + alert('Failed to upload file'); + } + } catch (error) { + console.error('Error uploading file:', error); + alert('Failed to upload file'); + } + + fileInput.value = ''; +} + +// Update Subsonic URL +function updateSubsonicUrl() { + const url = `${window.location.protocol}//${window.location.hostname}:${window.location.port}/rest`; + document.getElementById('subsonicUrl').value = url; +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..8490012 --- /dev/null +++ b/public/index.html @@ -0,0 +1,407 @@ + + + + + + CarPlay Audio Server - Admin + + + +
    +
    +

    🎵 CarPlay Audio Server

    +
    +
    + +
    +
    + + + + + +
    + + +
    +
    +
    +

    Total Tracks

    +
    -
    +
    +
    +

    Albums

    +
    -
    +
    +
    +

    Singles

    +
    -
    +
    +
    +

    Playlists

    +
    -
    +
    +
    + +
    +

    Quick Actions

    +
    + + + + +
    +
    + + +
    + + +
    +
    +

    All Tracks

    +
    + +
    +
      +
    • Loading tracks...
    • +
    +
    +
    + + +
    +
    +

    Albums

    +
      +
    • Loading albums...
    • +
    +
    +
    + + +
    +
    +

    Playlists

    + +
      +
    • Loading playlists...
    • +
    +
    +
    + + +
    +
    +

    Library Settings

    +
    + + + Audio files location on server +
    + +
    + +
    +

    Subsonic API

    +
    + + +
    +
    + + +
    +
    + + +
    + ⚠️ Change default credentials in src/routes/subsonic.js +
    +
    +
    + + + + + + + diff --git a/src/server.js b/src/server.js index c1eb8c4..6548892 100644 --- a/src/server.js +++ b/src/server.js @@ -31,6 +31,9 @@ app.use('/api/upload', uploadRouter); // Subsonic API app.use('/rest', subsonicRouter); +// Serve static files +app.use(express.static('public')); + // Serve audio files app.use('/audio', express.static(path.join(process.cwd(), 'audio_library'))); app.use('/artwork', express.static(path.join(process.cwd(), 'artwork')));