mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-10 07:58:39 -05:00
- Use JSON.stringify instead of escapeHtml for onclick parameters - Prevents JavaScript syntax errors when track names contain quotes - Fixes button doing nothing when clicked
1815 lines
76 KiB
HTML
1815 lines
76 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Allstarr Dashboard</title>
|
|
<style>
|
|
:root {
|
|
--bg-primary: #0d1117;
|
|
--bg-secondary: #161b22;
|
|
--bg-tertiary: #21262d;
|
|
--text-primary: #f0f6fc;
|
|
--text-secondary: #8b949e;
|
|
--accent: #58a6ff;
|
|
--accent-hover: #79c0ff;
|
|
--success: #3fb950;
|
|
--warning: #d29922;
|
|
--error: #f85149;
|
|
--border: #30363d;
|
|
}
|
|
|
|
* {
|
|
box-sizing: border-box;
|
|
margin: 0;
|
|
padding: 0;
|
|
}
|
|
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
|
background: var(--bg-primary);
|
|
color: var(--text-primary);
|
|
line-height: 1.5;
|
|
min-height: 100vh;
|
|
}
|
|
|
|
.container {
|
|
max-width: 1200px;
|
|
margin: 0 auto;
|
|
padding: 20px;
|
|
}
|
|
|
|
header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 20px 0;
|
|
border-bottom: 1px solid var(--border);
|
|
margin-bottom: 30px;
|
|
}
|
|
|
|
h1 {
|
|
font-size: 1.8rem;
|
|
font-weight: 600;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
}
|
|
|
|
h1 .version {
|
|
font-size: 0.8rem;
|
|
color: var(--text-secondary);
|
|
font-weight: normal;
|
|
}
|
|
|
|
.status-badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 4px 12px;
|
|
border-radius: 20px;
|
|
font-size: 0.85rem;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.status-badge.success { background: rgba(63, 185, 80, 0.2); color: var(--success); }
|
|
.status-badge.warning { background: rgba(210, 153, 34, 0.2); color: var(--warning); }
|
|
.status-badge.error { background: rgba(248, 81, 73, 0.2); color: var(--error); }
|
|
|
|
.status-dot {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
background: currentColor;
|
|
}
|
|
|
|
.card {
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
padding: 20px;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.card h2 {
|
|
font-size: 1.1rem;
|
|
font-weight: 600;
|
|
margin-bottom: 16px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
}
|
|
|
|
.card h2 .actions {
|
|
display: flex;
|
|
gap: 8px;
|
|
}
|
|
|
|
.grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
|
gap: 20px;
|
|
}
|
|
|
|
.stat-row {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
padding: 8px 0;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
|
|
.stat-row:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.stat-label {
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.stat-value {
|
|
font-weight: 500;
|
|
}
|
|
|
|
.stat-value.success { color: var(--success); }
|
|
.stat-value.warning { color: var(--warning); }
|
|
.stat-value.error { color: var(--error); }
|
|
|
|
button {
|
|
background: var(--bg-tertiary);
|
|
color: var(--text-primary);
|
|
border: 1px solid var(--border);
|
|
padding: 8px 16px;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
font-size: 0.9rem;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
button:hover {
|
|
background: var(--border);
|
|
}
|
|
|
|
button.primary {
|
|
background: var(--accent);
|
|
border-color: var(--accent);
|
|
color: white;
|
|
}
|
|
|
|
button.primary:hover {
|
|
background: var(--accent-hover);
|
|
}
|
|
|
|
button.danger {
|
|
background: rgba(248, 81, 73, 0.15);
|
|
border-color: var(--error);
|
|
color: var(--error);
|
|
}
|
|
|
|
button.danger:hover {
|
|
background: rgba(248, 81, 73, 0.3);
|
|
}
|
|
|
|
.playlist-table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
}
|
|
|
|
.playlist-table th,
|
|
.playlist-table td {
|
|
padding: 12px;
|
|
text-align: left;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
|
|
.playlist-table th {
|
|
color: var(--text-secondary);
|
|
font-weight: 500;
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.playlist-table tr:hover td {
|
|
background: var(--bg-tertiary);
|
|
}
|
|
|
|
.playlist-table .track-count {
|
|
font-family: monospace;
|
|
color: var(--accent);
|
|
}
|
|
|
|
.playlist-table .cache-age {
|
|
color: var(--text-secondary);
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.input-group {
|
|
display: flex;
|
|
gap: 8px;
|
|
margin-top: 16px;
|
|
}
|
|
|
|
input, select {
|
|
background: var(--bg-tertiary);
|
|
border: 1px solid var(--border);
|
|
color: var(--text-primary);
|
|
padding: 8px 12px;
|
|
border-radius: 6px;
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
input:focus, select:focus {
|
|
outline: none;
|
|
border-color: var(--accent);
|
|
}
|
|
|
|
input::placeholder {
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.form-row {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr 120px auto;
|
|
gap: 8px;
|
|
align-items: end;
|
|
}
|
|
|
|
.form-row label {
|
|
display: block;
|
|
font-size: 0.8rem;
|
|
color: var(--text-secondary);
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.config-section {
|
|
margin-bottom: 24px;
|
|
}
|
|
|
|
.config-section h3 {
|
|
font-size: 0.95rem;
|
|
color: var(--text-secondary);
|
|
margin-bottom: 12px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
|
|
.config-item {
|
|
display: grid;
|
|
grid-template-columns: 200px 1fr auto;
|
|
gap: 16px;
|
|
align-items: center;
|
|
padding: 12px;
|
|
background: var(--bg-tertiary);
|
|
border-radius: 6px;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.config-item .label {
|
|
font-weight: 500;
|
|
}
|
|
|
|
.config-item .value {
|
|
font-family: monospace;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.toast {
|
|
position: fixed;
|
|
bottom: 20px;
|
|
right: 20px;
|
|
padding: 12px 20px;
|
|
border-radius: 8px;
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border);
|
|
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
|
z-index: 1000;
|
|
animation: slideIn 0.3s ease;
|
|
}
|
|
|
|
.toast.success { border-color: var(--success); }
|
|
.toast.error { border-color: var(--error); }
|
|
|
|
@keyframes slideIn {
|
|
from { transform: translateX(100%); opacity: 0; }
|
|
to { transform: translateX(0); opacity: 1; }
|
|
}
|
|
|
|
.restart-overlay {
|
|
display: none;
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
background: var(--bg-primary);
|
|
z-index: 9999;
|
|
justify-content: center;
|
|
align-items: center;
|
|
flex-direction: column;
|
|
gap: 20px;
|
|
}
|
|
|
|
.restart-overlay.active {
|
|
display: flex;
|
|
}
|
|
|
|
.restart-overlay .spinner-large {
|
|
width: 48px;
|
|
height: 48px;
|
|
border: 3px solid var(--border);
|
|
border-top-color: var(--accent);
|
|
border-radius: 50%;
|
|
animation: spin 1s linear infinite;
|
|
}
|
|
|
|
.restart-overlay h2 {
|
|
color: var(--text-primary);
|
|
font-size: 1.5rem;
|
|
}
|
|
|
|
.restart-overlay p {
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.restart-banner {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
background: var(--warning);
|
|
color: var(--bg-primary);
|
|
padding: 12px 20px;
|
|
text-align: center;
|
|
font-weight: 500;
|
|
z-index: 9998;
|
|
display: none;
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
|
}
|
|
|
|
.restart-banner.active {
|
|
display: block;
|
|
}
|
|
|
|
.restart-banner button {
|
|
margin-left: 16px;
|
|
background: var(--bg-primary);
|
|
color: var(--text-primary);
|
|
border: none;
|
|
padding: 6px 16px;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.restart-banner button:hover {
|
|
background: var(--bg-secondary);
|
|
}
|
|
|
|
.modal {
|
|
display: none;
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
background: rgba(0,0,0,0.7);
|
|
z-index: 1000;
|
|
justify-content: center;
|
|
align-items: center;
|
|
}
|
|
|
|
.modal.active {
|
|
display: flex;
|
|
}
|
|
|
|
.modal-content {
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border);
|
|
border-radius: 12px;
|
|
padding: 24px;
|
|
max-width: 500px;
|
|
width: 90%;
|
|
max-height: 80vh;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.modal-content h3 {
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.modal-content .form-group {
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.modal-content .form-group label {
|
|
display: block;
|
|
margin-bottom: 6px;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.modal-content .form-group input,
|
|
.modal-content .form-group select {
|
|
width: 100%;
|
|
}
|
|
|
|
.modal-actions {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
gap: 8px;
|
|
margin-top: 24px;
|
|
}
|
|
|
|
.tabs {
|
|
display: flex;
|
|
border-bottom: 1px solid var(--border);
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.tab {
|
|
padding: 12px 20px;
|
|
cursor: pointer;
|
|
color: var(--text-secondary);
|
|
border-bottom: 2px solid transparent;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.tab:hover {
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.tab.active {
|
|
color: var(--accent);
|
|
border-bottom-color: var(--accent);
|
|
}
|
|
|
|
.tab-content {
|
|
display: none;
|
|
}
|
|
|
|
.tab-content.active {
|
|
display: block;
|
|
}
|
|
|
|
.tracks-list {
|
|
max-height: 400px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.track-item {
|
|
display: grid;
|
|
grid-template-columns: 40px 1fr auto;
|
|
gap: 12px;
|
|
align-items: center;
|
|
padding: 8px;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
|
|
.track-item:hover {
|
|
background: var(--bg-tertiary);
|
|
}
|
|
|
|
.track-position {
|
|
color: var(--text-secondary);
|
|
font-size: 0.85rem;
|
|
text-align: center;
|
|
}
|
|
|
|
.track-info h4 {
|
|
font-weight: 500;
|
|
font-size: 0.95rem;
|
|
}
|
|
|
|
.track-info .artists {
|
|
color: var(--text-secondary);
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.track-meta {
|
|
text-align: right;
|
|
color: var(--text-secondary);
|
|
font-size: 0.8rem;
|
|
}
|
|
|
|
.loading {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 40px;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.spinner {
|
|
width: 24px;
|
|
height: 24px;
|
|
border: 2px solid var(--border);
|
|
border-top-color: var(--accent);
|
|
border-radius: 50%;
|
|
animation: spin 1s linear infinite;
|
|
margin-right: 12px;
|
|
}
|
|
|
|
@keyframes spin {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<!-- Restart Required Banner -->
|
|
<div class="restart-banner" id="restart-banner">
|
|
⚠️ Configuration changed. Restart required to apply changes.
|
|
<button onclick="restartContainer()">Restart Now</button>
|
|
<button onclick="dismissRestartBanner()" style="background: transparent; border: 1px solid var(--bg-primary);">Dismiss</button>
|
|
</div>
|
|
|
|
<div class="container">
|
|
<header>
|
|
<h1>
|
|
Allstarr <span class="version" id="version">v1.0.0</span>
|
|
</h1>
|
|
<div id="status-indicator">
|
|
<span class="status-badge" id="spotify-status">
|
|
<span class="status-dot"></span>
|
|
<span>Loading...</span>
|
|
</span>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="tabs">
|
|
<div class="tab active" data-tab="dashboard">Dashboard</div>
|
|
<div class="tab" data-tab="jellyfin-playlists">Link Playlists</div>
|
|
<div class="tab" data-tab="playlists">Active Playlists</div>
|
|
<div class="tab" data-tab="config">Configuration</div>
|
|
</div>
|
|
|
|
<!-- Dashboard Tab -->
|
|
<div class="tab-content active" id="tab-dashboard">
|
|
<div class="grid">
|
|
<div class="card">
|
|
<h2>Spotify API</h2>
|
|
<div class="stat-row">
|
|
<span class="stat-label">Status</span>
|
|
<span class="stat-value" id="spotify-auth-status">Loading...</span>
|
|
</div>
|
|
<div class="stat-row">
|
|
<span class="stat-label">User</span>
|
|
<span class="stat-value" id="spotify-user">-</span>
|
|
</div>
|
|
<div class="stat-row">
|
|
<span class="stat-label">Cookie Age</span>
|
|
<span class="stat-value" id="spotify-cookie-age">-</span>
|
|
</div>
|
|
<div class="stat-row">
|
|
<span class="stat-label">Cache Duration</span>
|
|
<span class="stat-value" id="cache-duration">-</span>
|
|
</div>
|
|
<div class="stat-row">
|
|
<span class="stat-label">ISRC Matching</span>
|
|
<span class="stat-value" id="isrc-matching">-</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>Jellyfin</h2>
|
|
<div class="stat-row">
|
|
<span class="stat-label">Backend</span>
|
|
<span class="stat-value" id="backend-type">-</span>
|
|
</div>
|
|
<div class="stat-row">
|
|
<span class="stat-label">URL</span>
|
|
<span class="stat-value" id="jellyfin-url">-</span>
|
|
</div>
|
|
<div class="stat-row">
|
|
<span class="stat-label">Playlists</span>
|
|
<span class="stat-value" id="playlist-count">-</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>
|
|
Quick Actions
|
|
</h2>
|
|
<div style="display: flex; gap: 12px; flex-wrap: wrap;">
|
|
<button class="primary" onclick="refreshPlaylists()">Refresh All Playlists</button>
|
|
<button onclick="clearCache()">Clear Cache</button>
|
|
<button onclick="openAddPlaylist()">Add Playlist</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Link Playlists Tab -->
|
|
<div class="tab-content" id="tab-jellyfin-playlists">
|
|
<div class="card">
|
|
<h2>
|
|
Link Jellyfin Playlists to Spotify
|
|
<div class="actions">
|
|
<button onclick="fetchJellyfinPlaylists()">Refresh</button>
|
|
</div>
|
|
</h2>
|
|
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
|
Connect your Jellyfin playlists to Spotify playlists. Allstarr will automatically fill in missing tracks from Spotify using your preferred music service (SquidWTF/Deezer/Qobuz).
|
|
<br><strong>Tip:</strong> Use the sp_dc cookie method for best results - it's simpler and more reliable.
|
|
</p>
|
|
|
|
<div style="display: flex; gap: 16px; margin-bottom: 16px; flex-wrap: wrap;">
|
|
<div class="form-group" style="margin: 0; flex: 1; min-width: 200px;">
|
|
<label style="display: block; margin-bottom: 4px; color: var(--text-secondary); font-size: 0.85rem;">User</label>
|
|
<select id="jellyfin-user-select" onchange="fetchJellyfinPlaylists()" style="width: 100%; padding: 8px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 6px; color: var(--text-primary);">
|
|
<option value="">All Users</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<table class="playlist-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Name</th>
|
|
<th>Local</th>
|
|
<th>External</th>
|
|
<th>Linked Spotify ID</th>
|
|
<th>Status</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="jellyfin-playlist-table-body">
|
|
<tr>
|
|
<td colspan="6" class="loading">
|
|
<span class="spinner"></span> Loading Jellyfin playlists...
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Active Playlists Tab -->
|
|
<div class="tab-content" id="tab-playlists">
|
|
<div class="card">
|
|
<h2>
|
|
Active Spotify Playlists
|
|
<div class="actions">
|
|
<button onclick="matchAllPlaylists()">Match All Tracks</button>
|
|
<button onclick="refreshPlaylists()">Refresh All</button>
|
|
</div>
|
|
</h2>
|
|
<p style="color: var(--text-secondary); margin-bottom: 12px;">
|
|
These are the Spotify playlists currently being monitored and filled with tracks from your music service.
|
|
</p>
|
|
<table class="playlist-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Name</th>
|
|
<th>Spotify ID</th>
|
|
<th>Total</th>
|
|
<th>Local/External</th>
|
|
<th>Cache Age</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="playlist-table-body">
|
|
<tr>
|
|
<td colspan="6" class="loading">
|
|
<span class="spinner"></span> Loading playlists...
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Configuration Tab -->
|
|
<div class="tab-content" id="tab-config">
|
|
<div class="card">
|
|
<h2>Spotify API Settings</h2>
|
|
<div class="config-section">
|
|
<div class="config-item">
|
|
<span class="label">API Enabled</span>
|
|
<span class="value" id="config-spotify-enabled">-</span>
|
|
<button onclick="openEditSetting('SPOTIFY_API_ENABLED', 'Spotify API Enabled', 'toggle')">Edit</button>
|
|
</div>
|
|
<div class="config-item">
|
|
<span class="label">Session Cookie (sp_dc)</span>
|
|
<span class="value" id="config-spotify-cookie">-</span>
|
|
<button onclick="openEditSetting('SPOTIFY_API_SESSION_COOKIE', 'Spotify Session Cookie', 'password', 'Get from browser dev tools while logged into Spotify. Cookie typically lasts ~1 year.')">Update</button>
|
|
</div>
|
|
<div class="config-item" style="grid-template-columns: 200px 1fr;">
|
|
<span class="label">Cookie Age</span>
|
|
<span class="value" id="config-cookie-age">-</span>
|
|
</div>
|
|
<div class="config-item">
|
|
<span class="label">Cache Duration</span>
|
|
<span class="value" id="config-cache-duration">-</span>
|
|
<button onclick="openEditSetting('SPOTIFY_API_CACHE_DURATION_MINUTES', 'Cache Duration (minutes)', 'number')">Edit</button>
|
|
</div>
|
|
<div class="config-item">
|
|
<span class="label">ISRC Matching</span>
|
|
<span class="value" id="config-isrc-matching">-</span>
|
|
<button onclick="openEditSetting('SPOTIFY_API_PREFER_ISRC_MATCHING', 'Prefer ISRC Matching', 'toggle')">Edit</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>Deezer Settings</h2>
|
|
<div class="config-section">
|
|
<div class="config-item">
|
|
<span class="label">ARL Token</span>
|
|
<span class="value" id="config-deezer-arl">-</span>
|
|
<button onclick="openEditSetting('DEEZER_ARL', 'Deezer ARL Token', 'password', 'Get from browser cookies while logged into Deezer')">Update</button>
|
|
</div>
|
|
<div class="config-item">
|
|
<span class="label">Quality</span>
|
|
<span class="value" id="config-deezer-quality">-</span>
|
|
<button onclick="openEditSetting('DEEZER_QUALITY', 'Deezer Quality', 'select', '', ['FLAC', 'MP3_320', 'MP3_128'])">Edit</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>SquidWTF / Tidal Settings</h2>
|
|
<div class="config-section">
|
|
<div class="config-item">
|
|
<span class="label">Quality</span>
|
|
<span class="value" id="config-squid-quality">-</span>
|
|
<button onclick="openEditSetting('SQUIDWTF_QUALITY', 'SquidWTF Quality', 'select', '', ['LOSSLESS', 'HIGH', 'LOW'])">Edit</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>Qobuz Settings</h2>
|
|
<div class="config-section">
|
|
<div class="config-item">
|
|
<span class="label">User Auth Token</span>
|
|
<span class="value" id="config-qobuz-token">-</span>
|
|
<button onclick="openEditSetting('QOBUZ_USER_AUTH_TOKEN', 'Qobuz User Auth Token', 'password', 'Get from browser while logged into Qobuz')">Update</button>
|
|
</div>
|
|
<div class="config-item">
|
|
<span class="label">Quality</span>
|
|
<span class="value" id="config-qobuz-quality">-</span>
|
|
<button onclick="openEditSetting('QOBUZ_QUALITY', 'Qobuz Quality', 'select', '', ['FLAC_24_192', 'FLAC_24_96', 'FLAC_16_44', 'MP3_320'])">Edit</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>Jellyfin Settings</h2>
|
|
<div class="config-section">
|
|
<div class="config-item">
|
|
<span class="label">URL</span>
|
|
<span class="value" id="config-jellyfin-url">-</span>
|
|
<button onclick="openEditSetting('JELLYFIN_URL', 'Jellyfin URL', 'text')">Edit</button>
|
|
</div>
|
|
<div class="config-item">
|
|
<span class="label">API Key</span>
|
|
<span class="value" id="config-jellyfin-api-key">-</span>
|
|
<button onclick="openEditSetting('JELLYFIN_API_KEY', 'Jellyfin API Key', 'password')">Update</button>
|
|
</div>
|
|
<div class="config-item">
|
|
<span class="label">User ID</span>
|
|
<span class="value" id="config-jellyfin-user-id">-</span>
|
|
<button onclick="openEditSetting('JELLYFIN_USER_ID', 'Jellyfin User ID', 'text', 'Required for playlist operations. Get from Jellyfin user profile URL: userId=...')">Edit</button>
|
|
</div>
|
|
<div class="config-item">
|
|
<span class="label">Library ID</span>
|
|
<span class="value" id="config-jellyfin-library-id">-</span>
|
|
<button onclick="openEditSetting('JELLYFIN_LIBRARY_ID', 'Jellyfin Library ID', 'text')">Edit</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>Sync Schedule</h2>
|
|
<div class="config-section">
|
|
<div class="config-item">
|
|
<span class="label">Sync Start Time</span>
|
|
<span class="value" id="config-sync-time">-</span>
|
|
<button onclick="openEditSetting('SPOTIFY_IMPORT_SYNC_START_HOUR', 'Sync Start Hour (0-23)', 'number')">Edit</button>
|
|
</div>
|
|
<div class="config-item">
|
|
<span class="label">Sync Window</span>
|
|
<span class="value" id="config-sync-window">-</span>
|
|
<button onclick="openEditSetting('SPOTIFY_IMPORT_SYNC_WINDOW_HOURS', 'Sync Window (hours)', 'number')">Edit</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card" style="background: rgba(248, 81, 73, 0.1); border-color: var(--error);">
|
|
<h2 style="color: var(--error);">Danger Zone</h2>
|
|
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
|
These actions can affect your data. Use with caution.
|
|
</p>
|
|
<div style="display: flex; gap: 12px; flex-wrap: wrap;">
|
|
<button class="danger" onclick="clearCache()">Clear All Cache</button>
|
|
<button class="danger" onclick="restartContainer()">Restart Container</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Add Playlist Modal -->
|
|
<div class="modal" id="add-playlist-modal">
|
|
<div class="modal-content">
|
|
<h3>Add Playlist</h3>
|
|
<div class="form-group">
|
|
<label>Playlist Name</label>
|
|
<input type="text" id="new-playlist-name" placeholder="e.g., Release Radar">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Spotify Playlist ID</label>
|
|
<input type="text" id="new-playlist-id" placeholder="Get from Spotify Import plugin">
|
|
</div>
|
|
<div class="modal-actions">
|
|
<button onclick="closeModal('add-playlist-modal')">Cancel</button>
|
|
<button class="primary" onclick="addPlaylist()">Add Playlist</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Edit Setting Modal -->
|
|
<div class="modal" id="edit-setting-modal">
|
|
<div class="modal-content">
|
|
<h3 id="edit-setting-title">Edit Setting</h3>
|
|
<p id="edit-setting-help" style="color: var(--text-secondary); margin-bottom: 16px; display: none;"></p>
|
|
<div class="form-group">
|
|
<label id="edit-setting-label">Value</label>
|
|
<div id="edit-setting-input-container">
|
|
<input type="text" id="edit-setting-value" placeholder="Enter value">
|
|
</div>
|
|
</div>
|
|
<div class="modal-actions">
|
|
<button onclick="closeModal('edit-setting-modal')">Cancel</button>
|
|
<button class="primary" onclick="saveEditSetting()">Save</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Track List Modal -->
|
|
<div class="modal" id="tracks-modal">
|
|
<div class="modal-content" style="max-width: 700px;">
|
|
<h3 id="tracks-modal-title">Playlist Tracks</h3>
|
|
<div class="tracks-list" id="tracks-list">
|
|
<div class="loading">
|
|
<span class="spinner"></span> Loading tracks...
|
|
</div>
|
|
</div>
|
|
<div class="modal-actions">
|
|
<button onclick="closeModal('tracks-modal')">Close</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Manual Track Mapping Modal -->
|
|
<div class="modal" id="manual-map-modal">
|
|
<div class="modal-content" style="max-width: 600px;">
|
|
<h3>Map Track to Local File</h3>
|
|
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
|
This track is currently using an external provider. Search for and select the local Jellyfin track to use instead.
|
|
</p>
|
|
<div class="form-group">
|
|
<label>Spotify Track (Position <span id="map-position"></span>)</label>
|
|
<div style="background: var(--bg-primary); padding: 12px; border-radius: 8px; margin-bottom: 16px;">
|
|
<strong id="map-spotify-title"></strong><br>
|
|
<span style="color: var(--text-secondary);" id="map-spotify-artist"></span>
|
|
</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Search Jellyfin Tracks</label>
|
|
<input type="text" id="map-search-query" placeholder="Search by title or artist..." oninput="searchJellyfinTracks()">
|
|
</div>
|
|
<div id="map-search-results" style="max-height: 300px; overflow-y: auto; margin-top: 12px;">
|
|
<p style="text-align: center; color: var(--text-secondary); padding: 20px;">
|
|
Type to search for local tracks...
|
|
</p>
|
|
</div>
|
|
<input type="hidden" id="map-playlist-name">
|
|
<input type="hidden" id="map-spotify-id">
|
|
<input type="hidden" id="map-selected-jellyfin-id">
|
|
<div class="modal-actions">
|
|
<button onclick="closeModal('manual-map-modal')">Cancel</button>
|
|
<button class="primary" onclick="saveManualMapping()" id="map-save-btn" disabled>Save Mapping</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Link Playlist Modal -->
|
|
<div class="modal" id="link-playlist-modal">
|
|
<div class="modal-content">
|
|
<h3>Link to Spotify Playlist</h3>
|
|
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
|
Enter the Spotify playlist ID or URL. Allstarr will automatically download missing tracks from your configured music service.
|
|
</p>
|
|
<div class="form-group">
|
|
<label>Jellyfin Playlist</label>
|
|
<input type="text" id="link-jellyfin-name" readonly style="background: var(--bg-primary);">
|
|
<input type="hidden" id="link-jellyfin-id">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Spotify Playlist ID or URL</label>
|
|
<input type="text" id="link-spotify-id" placeholder="37i9dQZF1DXcBWIGoYBM5M or spotify:playlist:... or full URL">
|
|
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
|
|
Accepts: <code>37i9dQZF1DXcBWIGoYBM5M</code>, <code>spotify:playlist:37i9dQZF1DXcBWIGoYBM5M</code>, or full Spotify URL
|
|
</small>
|
|
</div>
|
|
<div class="modal-actions">
|
|
<button onclick="closeModal('link-playlist-modal')">Cancel</button>
|
|
<button class="primary" onclick="linkPlaylist()">Link Playlist</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Restart Overlay -->
|
|
<div class="restart-overlay" id="restart-overlay">
|
|
<div class="spinner-large"></div>
|
|
<h2>Restarting Container</h2>
|
|
<p id="restart-status">Applying configuration changes...</p>
|
|
</div>
|
|
|
|
<script>
|
|
// Current edit setting state
|
|
let currentEditKey = null;
|
|
let currentEditType = null;
|
|
let currentEditOptions = null;
|
|
|
|
// Track if we've already initialized the cookie date to prevent infinite loop
|
|
let cookieDateInitialized = false;
|
|
|
|
// Track if restart is required
|
|
let restartRequired = false;
|
|
|
|
function showRestartBanner() {
|
|
restartRequired = true;
|
|
document.getElementById('restart-banner').classList.add('active');
|
|
}
|
|
|
|
function dismissRestartBanner() {
|
|
document.getElementById('restart-banner').classList.remove('active');
|
|
}
|
|
|
|
// Tab switching with URL hash support
|
|
function switchTab(tabName) {
|
|
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
|
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
|
|
|
const tab = document.querySelector(`.tab[data-tab="${tabName}"]`);
|
|
const content = document.getElementById('tab-' + tabName);
|
|
|
|
if (tab && content) {
|
|
tab.classList.add('active');
|
|
content.classList.add('active');
|
|
window.location.hash = tabName;
|
|
}
|
|
}
|
|
|
|
document.querySelectorAll('.tab').forEach(tab => {
|
|
tab.addEventListener('click', () => {
|
|
switchTab(tab.dataset.tab);
|
|
});
|
|
});
|
|
|
|
// Restore tab from URL hash on page load
|
|
window.addEventListener('load', () => {
|
|
const hash = window.location.hash.substring(1);
|
|
if (hash) {
|
|
switchTab(hash);
|
|
}
|
|
});
|
|
|
|
// Toast notification
|
|
function showToast(message, type = 'success') {
|
|
const toast = document.createElement('div');
|
|
toast.className = 'toast ' + type;
|
|
toast.textContent = message;
|
|
document.body.appendChild(toast);
|
|
setTimeout(() => toast.remove(), 3000);
|
|
}
|
|
|
|
// Modal helpers
|
|
function openModal(id) {
|
|
document.getElementById(id).classList.add('active');
|
|
}
|
|
|
|
function closeModal(id) {
|
|
document.getElementById(id).classList.remove('active');
|
|
}
|
|
|
|
// Close modals on backdrop click
|
|
document.querySelectorAll('.modal').forEach(modal => {
|
|
modal.addEventListener('click', e => {
|
|
if (e.target === modal) closeModal(modal.id);
|
|
});
|
|
});
|
|
|
|
// Format cookie age with color coding
|
|
function formatCookieAge(setDateStr, hasCookie = false) {
|
|
if (!setDateStr) {
|
|
if (hasCookie) {
|
|
return { text: 'Unknown age', class: 'warning', detail: 'Cookie date not tracked', needsInit: true };
|
|
}
|
|
return { text: 'No cookie', class: '', detail: '', needsInit: false };
|
|
}
|
|
|
|
const setDate = new Date(setDateStr);
|
|
const now = new Date();
|
|
const diffMs = now - setDate;
|
|
const daysAgo = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
|
const monthsAgo = daysAgo / 30;
|
|
|
|
let status = 'success'; // green: < 6 months
|
|
if (monthsAgo >= 10) status = 'error'; // red: > 10 months
|
|
else if (monthsAgo >= 6) status = 'warning'; // yellow: 6-10 months
|
|
|
|
let text;
|
|
if (daysAgo === 0) text = 'Set today';
|
|
else if (daysAgo === 1) text = 'Set yesterday';
|
|
else if (daysAgo < 30) text = `Set ${daysAgo} days ago`;
|
|
else if (daysAgo < 60) text = 'Set ~1 month ago';
|
|
else text = `Set ~${Math.floor(monthsAgo)} months ago`;
|
|
|
|
const remaining = 12 - monthsAgo;
|
|
let detail;
|
|
if (remaining > 6) detail = 'Cookie typically lasts ~1 year';
|
|
else if (remaining > 2) detail = `~${Math.floor(remaining)} months until expiration`;
|
|
else if (remaining > 0) detail = 'Cookie may expire soon!';
|
|
else detail = 'Cookie may have expired - update if having issues';
|
|
|
|
return { text, class: status, detail, needsInit: false };
|
|
}
|
|
|
|
// Initialize cookie date if cookie exists but date is not set
|
|
async function initCookieDate() {
|
|
if (cookieDateInitialized) {
|
|
console.log('Cookie date already initialized, skipping');
|
|
return;
|
|
}
|
|
|
|
cookieDateInitialized = true;
|
|
|
|
try {
|
|
const res = await fetch('/api/admin/config/init-cookie-date', { method: 'POST' });
|
|
if (res.ok) {
|
|
console.log('Cookie date initialized successfully - restart container to apply');
|
|
showToast('Cookie date set. Restart container to apply changes.', 'success');
|
|
} else {
|
|
const data = await res.json();
|
|
console.log('Cookie date init response:', data);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to init cookie date:', error);
|
|
cookieDateInitialized = false; // Allow retry on error
|
|
}
|
|
}
|
|
|
|
// API calls
|
|
async function fetchStatus() {
|
|
try {
|
|
const res = await fetch('/api/admin/status');
|
|
const data = await res.json();
|
|
|
|
document.getElementById('version').textContent = 'v' + data.version;
|
|
document.getElementById('backend-type').textContent = data.backendType;
|
|
document.getElementById('jellyfin-url').textContent = data.jellyfinUrl || '-';
|
|
document.getElementById('playlist-count').textContent = data.spotifyImport.playlistCount;
|
|
document.getElementById('cache-duration').textContent = data.spotify.cacheDurationMinutes + ' min';
|
|
document.getElementById('isrc-matching').textContent = data.spotify.preferIsrcMatching ? 'Enabled' : 'Disabled';
|
|
document.getElementById('spotify-user').textContent = data.spotify.user || '-';
|
|
|
|
// Update status badge and cookie age
|
|
const statusBadge = document.getElementById('spotify-status');
|
|
const authStatus = document.getElementById('spotify-auth-status');
|
|
const cookieAgeEl = document.getElementById('spotify-cookie-age');
|
|
|
|
if (data.spotify.authStatus === 'configured') {
|
|
statusBadge.className = 'status-badge success';
|
|
statusBadge.innerHTML = '<span class="status-dot"></span>Spotify Ready';
|
|
authStatus.textContent = 'Cookie Set';
|
|
authStatus.className = 'stat-value success';
|
|
} else if (data.spotify.authStatus === 'missing_cookie') {
|
|
statusBadge.className = 'status-badge warning';
|
|
statusBadge.innerHTML = '<span class="status-dot"></span>Cookie Missing';
|
|
authStatus.textContent = 'No Cookie';
|
|
authStatus.className = 'stat-value warning';
|
|
} else {
|
|
statusBadge.className = 'status-badge';
|
|
statusBadge.innerHTML = '<span class="status-dot"></span>Not Configured';
|
|
authStatus.textContent = 'Not Configured';
|
|
authStatus.className = 'stat-value';
|
|
}
|
|
|
|
// Update cookie age display
|
|
if (cookieAgeEl) {
|
|
const hasCookie = data.spotify.hasCookie;
|
|
const age = formatCookieAge(data.spotify.cookieSetDate, hasCookie);
|
|
cookieAgeEl.innerHTML = `<span class="${age.class}">${age.text}</span><br><small style="color:var(--text-secondary)">${age.detail}</small>`;
|
|
|
|
// Auto-init cookie date if cookie exists but date is not set
|
|
if (age.needsInit) {
|
|
console.log('Cookie exists but date not set, initializing...');
|
|
initCookieDate();
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to fetch status:', error);
|
|
showToast('Failed to fetch status', 'error');
|
|
}
|
|
}
|
|
|
|
async function fetchPlaylists() {
|
|
try {
|
|
const res = await fetch('/api/admin/playlists');
|
|
const data = await res.json();
|
|
|
|
const tbody = document.getElementById('playlist-table-body');
|
|
|
|
if (data.playlists.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:var(--text-secondary);padding:40px;">No playlists configured. Link playlists from the Jellyfin Playlists tab.</td></tr>';
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = data.playlists.map(p => {
|
|
// Show local tracks and missing tracks
|
|
const localCount = p.localTracks || 0;
|
|
const missingCount = p.externalTracks || 0;
|
|
const localExternal = `${localCount} local / ${missingCount} missing`;
|
|
|
|
return `
|
|
<tr>
|
|
<td><strong>${escapeHtml(p.name)}</strong></td>
|
|
<td style="font-family:monospace;font-size:0.85rem;color:var(--text-secondary);">${p.id || '-'}</td>
|
|
<td class="track-count">${p.trackCount || 0}</td>
|
|
<td class="track-count">${localExternal}</td>
|
|
<td class="cache-age">${p.cacheAge || '-'}</td>
|
|
<td>
|
|
<button onclick="matchPlaylistTracks('${escapeJs(p.name)}')">Match Tracks</button>
|
|
<button onclick="viewTracks('${escapeJs(p.name)}')">View</button>
|
|
<button class="danger" onclick="removePlaylist('${escapeJs(p.name)}')">Remove</button>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
}).join('');
|
|
} catch (error) {
|
|
console.error('Failed to fetch playlists:', error);
|
|
showToast('Failed to fetch playlists', 'error');
|
|
}
|
|
}
|
|
|
|
async function fetchConfig() {
|
|
try {
|
|
const res = await fetch('/api/admin/config');
|
|
const data = await res.json();
|
|
|
|
// Spotify API settings
|
|
document.getElementById('config-spotify-enabled').textContent = data.spotifyApi.enabled ? 'Yes' : 'No';
|
|
document.getElementById('config-spotify-cookie').textContent = data.spotifyApi.sessionCookie;
|
|
document.getElementById('config-cache-duration').textContent = data.spotifyApi.cacheDurationMinutes + ' minutes';
|
|
document.getElementById('config-isrc-matching').textContent = data.spotifyApi.preferIsrcMatching ? 'Enabled' : 'Disabled';
|
|
|
|
// Cookie age in config tab
|
|
const configCookieAge = document.getElementById('config-cookie-age');
|
|
if (configCookieAge) {
|
|
const hasCookie = data.spotifyApi.sessionCookie && data.spotifyApi.sessionCookie !== '(not set)';
|
|
const age = formatCookieAge(data.spotifyApi.sessionCookieSetDate, hasCookie);
|
|
configCookieAge.innerHTML = `<span class="${age.class}">${age.text}</span> - ${age.detail}`;
|
|
}
|
|
|
|
// Deezer settings
|
|
document.getElementById('config-deezer-arl').textContent = data.deezer.arl || '(not set)';
|
|
document.getElementById('config-deezer-quality').textContent = data.deezer.quality;
|
|
|
|
// SquidWTF settings
|
|
document.getElementById('config-squid-quality').textContent = data.squidWtf.quality;
|
|
|
|
// Qobuz settings
|
|
document.getElementById('config-qobuz-token').textContent = data.qobuz.userAuthToken || '(not set)';
|
|
document.getElementById('config-qobuz-quality').textContent = data.qobuz.quality || 'FLAC';
|
|
|
|
// Jellyfin settings
|
|
document.getElementById('config-jellyfin-url').textContent = data.jellyfin.url || '-';
|
|
document.getElementById('config-jellyfin-api-key').textContent = data.jellyfin.apiKey;
|
|
document.getElementById('config-jellyfin-user-id').textContent = data.jellyfin.userId || '(not set)';
|
|
document.getElementById('config-jellyfin-library-id').textContent = data.jellyfin.libraryId || '-';
|
|
|
|
// Sync settings
|
|
const syncHour = data.spotifyImport.syncStartHour;
|
|
const syncMin = data.spotifyImport.syncStartMinute;
|
|
document.getElementById('config-sync-time').textContent = `${String(syncHour).padStart(2, '0')}:${String(syncMin).padStart(2, '0')}`;
|
|
document.getElementById('config-sync-window').textContent = data.spotifyImport.syncWindowHours + ' hours';
|
|
} catch (error) {
|
|
console.error('Failed to fetch config:', error);
|
|
}
|
|
}
|
|
|
|
async function fetchJellyfinUsers() {
|
|
try {
|
|
const res = await fetch('/api/admin/jellyfin/users');
|
|
if (!res.ok) return;
|
|
const data = await res.json();
|
|
const select = document.getElementById('jellyfin-user-select');
|
|
select.innerHTML = '<option value="">All Users</option>' +
|
|
data.users.map(u => `<option value="${u.id}">${escapeHtml(u.name)}</option>`).join('');
|
|
} catch (error) {
|
|
console.error('Failed to fetch users:', error);
|
|
}
|
|
}
|
|
|
|
|
|
async function fetchJellyfinPlaylists() {
|
|
const tbody = document.getElementById('jellyfin-playlist-table-body');
|
|
tbody.innerHTML = '<tr><td colspan="6" class="loading"><span class="spinner"></span> Loading Jellyfin playlists...</td></tr>';
|
|
|
|
try {
|
|
// Build URL with optional user filter
|
|
const userId = document.getElementById('jellyfin-user-select').value;
|
|
|
|
let url = '/api/admin/jellyfin/playlists';
|
|
if (userId) url += '?userId=' + encodeURIComponent(userId);
|
|
|
|
const res = await fetch(url);
|
|
|
|
if (!res.ok) {
|
|
const errorData = await res.json();
|
|
tbody.innerHTML = `<tr><td colspan="6" style="text-align:center;color:var(--error);padding:40px;">${errorData.error || 'Failed to fetch playlists'}</td></tr>`;
|
|
return;
|
|
}
|
|
|
|
const data = await res.json();
|
|
|
|
if (data.playlists.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--text-secondary);padding:40px;">No playlists found in Jellyfin</td></tr>';
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = data.playlists.map(p => {
|
|
const statusBadge = p.isConfigured
|
|
? '<span class="status-badge success"><span class="status-dot"></span>Linked</span>'
|
|
: '<span class="status-badge"><span class="status-dot"></span>Not Linked</span>';
|
|
|
|
const actionButton = p.isConfigured
|
|
? `<button class="danger" onclick="unlinkPlaylist('${escapeJs(p.name)}')">Unlink</button>`
|
|
: `<button class="primary" onclick="openLinkPlaylist('${escapeJs(p.id)}', '${escapeJs(p.name)}')">Link to Spotify</button>`;
|
|
|
|
const localCount = p.localTracks || 0;
|
|
const externalCount = p.externalTracks || 0;
|
|
const externalAvail = p.externalAvailable || 0;
|
|
|
|
return `
|
|
<tr data-playlist-id="${escapeHtml(p.id)}">
|
|
<td><strong>${escapeHtml(p.name)}</strong></td>
|
|
<td class="track-count">${localCount}</td>
|
|
<td class="track-count">${externalCount > 0 ? `${externalAvail}/${externalCount}` : '-'}</td>
|
|
<td style="font-family:monospace;font-size:0.85rem;color:var(--text-secondary);">${p.linkedSpotifyId || '-'}</td>
|
|
<td>${statusBadge}</td>
|
|
<td>${actionButton}</td>
|
|
</tr>
|
|
`;
|
|
}).join('');
|
|
} catch (error) {
|
|
console.error('Failed to fetch Jellyfin playlists:', error);
|
|
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--error);padding:40px;">Failed to fetch playlists</td></tr>';
|
|
}
|
|
}
|
|
|
|
function openLinkPlaylist(jellyfinId, name) {
|
|
document.getElementById('link-jellyfin-id').value = jellyfinId;
|
|
document.getElementById('link-jellyfin-name').value = name;
|
|
document.getElementById('link-spotify-id').value = '';
|
|
openModal('link-playlist-modal');
|
|
}
|
|
|
|
async function linkPlaylist() {
|
|
const jellyfinId = document.getElementById('link-jellyfin-id').value;
|
|
const name = document.getElementById('link-jellyfin-name').value;
|
|
const spotifyId = document.getElementById('link-spotify-id').value.trim();
|
|
|
|
if (!spotifyId) {
|
|
showToast('Spotify Playlist ID is required', 'error');
|
|
return;
|
|
}
|
|
|
|
// Extract ID from various Spotify formats:
|
|
// - spotify:playlist:37i9dQZF1DXcBWIGoYBM5M
|
|
// - https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M
|
|
// - 37i9dQZF1DXcBWIGoYBM5M
|
|
let cleanSpotifyId = spotifyId;
|
|
|
|
// Handle spotify: URI format
|
|
if (spotifyId.startsWith('spotify:playlist:')) {
|
|
cleanSpotifyId = spotifyId.replace('spotify:playlist:', '');
|
|
}
|
|
// Handle URL format
|
|
else if (spotifyId.includes('spotify.com/playlist/')) {
|
|
const match = spotifyId.match(/playlist\/([a-zA-Z0-9]+)/);
|
|
if (match) cleanSpotifyId = match[1];
|
|
}
|
|
// Remove any query parameters or trailing slashes
|
|
cleanSpotifyId = cleanSpotifyId.split('?')[0].split('#')[0].replace(/\/$/, '');
|
|
|
|
try {
|
|
const res = await fetch(`/api/admin/jellyfin/playlists/${encodeURIComponent(jellyfinId)}/link`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name, spotifyPlaylistId: cleanSpotifyId })
|
|
});
|
|
|
|
const data = await res.json();
|
|
|
|
if (res.ok) {
|
|
showToast('Playlist linked!', 'success');
|
|
showRestartBanner();
|
|
closeModal('link-playlist-modal');
|
|
|
|
// Update UI state without refetching all playlists
|
|
const playlistsTable = document.getElementById('jellyfinPlaylistsTable');
|
|
if (playlistsTable) {
|
|
const rows = playlistsTable.querySelectorAll('tr');
|
|
rows.forEach(row => {
|
|
if (row.dataset.playlistId === jellyfinId) {
|
|
const actionCell = row.querySelector('td:last-child');
|
|
if (actionCell) {
|
|
actionCell.innerHTML = `<button class="danger" onclick="unlinkPlaylist('${escapeJs(name)}')">Unlink</button>`;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
fetchPlaylists(); // Only refresh the Active Playlists tab
|
|
} else {
|
|
showToast(data.error || 'Failed to link playlist', 'error');
|
|
}
|
|
} catch (error) {
|
|
showToast('Failed to link playlist', 'error');
|
|
}
|
|
}
|
|
|
|
async function unlinkPlaylist(name) {
|
|
if (!confirm(`Unlink playlist "${name}"? This will stop filling in missing tracks.`)) return;
|
|
|
|
try {
|
|
const res = await fetch(`/api/admin/jellyfin/playlists/${encodeURIComponent(name)}/unlink`, {
|
|
method: 'DELETE'
|
|
});
|
|
|
|
const data = await res.json();
|
|
|
|
if (res.ok) {
|
|
showToast('Playlist unlinked.', 'success');
|
|
showRestartBanner();
|
|
|
|
// Update UI state without refetching all playlists
|
|
const playlistsTable = document.getElementById('jellyfinPlaylistsTable');
|
|
if (playlistsTable) {
|
|
const rows = playlistsTable.querySelectorAll('tr');
|
|
rows.forEach(row => {
|
|
const nameCell = row.querySelector('td:first-child');
|
|
if (nameCell && nameCell.textContent === name) {
|
|
const actionCell = row.querySelector('td:last-child');
|
|
if (actionCell) {
|
|
const playlistId = row.dataset.playlistId;
|
|
actionCell.innerHTML = `<button class="primary" onclick="openLinkPlaylist('${escapeJs(playlistId)}', '${escapeJs(name)}')">Link to Spotify</button>`;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
fetchPlaylists(); // Only refresh the Active Playlists tab
|
|
} else {
|
|
showToast(data.error || 'Failed to unlink playlist', 'error');
|
|
}
|
|
} catch (error) {
|
|
showToast('Failed to unlink playlist', 'error');
|
|
}
|
|
}
|
|
|
|
async function refreshPlaylists() {
|
|
try {
|
|
showToast('Refreshing playlists...', 'success');
|
|
const res = await fetch('/api/admin/playlists/refresh', { method: 'POST' });
|
|
const data = await res.json();
|
|
showToast(data.message, 'success');
|
|
setTimeout(fetchPlaylists, 2000);
|
|
} catch (error) {
|
|
showToast('Failed to refresh playlists', 'error');
|
|
}
|
|
}
|
|
|
|
async function matchPlaylistTracks(name) {
|
|
try {
|
|
showToast(`Matching tracks for ${name}...`, 'success');
|
|
const res = await fetch(`/api/admin/playlists/${encodeURIComponent(name)}/match`, { method: 'POST' });
|
|
const data = await res.json();
|
|
|
|
if (res.ok) {
|
|
showToast(`✓ ${data.message}`, 'success');
|
|
// Refresh the playlists table after a delay to show updated counts
|
|
setTimeout(fetchPlaylists, 2000);
|
|
} else {
|
|
showToast(data.error || 'Failed to match tracks', 'error');
|
|
}
|
|
} catch (error) {
|
|
showToast('Failed to match tracks', 'error');
|
|
}
|
|
}
|
|
|
|
async function matchAllPlaylists() {
|
|
if (!confirm('Match tracks for ALL playlists? This may take a few minutes.')) return;
|
|
|
|
try {
|
|
showToast('Matching tracks for all playlists...', 'success');
|
|
const res = await fetch('/api/admin/playlists/match-all', { method: 'POST' });
|
|
const data = await res.json();
|
|
|
|
if (res.ok) {
|
|
showToast(`✓ ${data.message}`, 'success');
|
|
// Refresh the playlists table after a delay to show updated counts
|
|
setTimeout(fetchPlaylists, 3000);
|
|
} else {
|
|
showToast(data.error || 'Failed to match tracks', 'error');
|
|
}
|
|
} catch (error) {
|
|
showToast('Failed to match tracks', 'error');
|
|
}
|
|
}
|
|
|
|
async function clearCache() {
|
|
if (!confirm('Clear all cached playlist data?')) return;
|
|
|
|
try {
|
|
const res = await fetch('/api/admin/cache/clear', { method: 'POST' });
|
|
const data = await res.json();
|
|
showToast(data.message, 'success');
|
|
fetchPlaylists();
|
|
} catch (error) {
|
|
showToast('Failed to clear cache', 'error');
|
|
}
|
|
}
|
|
|
|
async function restartContainer() {
|
|
if (!confirm('Restart the container to apply configuration changes?\n\nThe dashboard will be temporarily unavailable.')) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const res = await fetch('/api/admin/restart', { method: 'POST' });
|
|
const data = await res.json();
|
|
|
|
if (res.ok) {
|
|
// Show the restart overlay
|
|
document.getElementById('restart-overlay').classList.add('active');
|
|
document.getElementById('restart-status').textContent = 'Stopping container...';
|
|
|
|
// Wait a bit then start checking if the server is back
|
|
setTimeout(() => {
|
|
document.getElementById('restart-status').textContent = 'Waiting for server to come back...';
|
|
checkServerAndReload();
|
|
}, 3000);
|
|
} else {
|
|
showToast(data.message || data.error || 'Failed to restart', 'error');
|
|
}
|
|
} catch (error) {
|
|
showToast('Failed to restart container', 'error');
|
|
}
|
|
}
|
|
|
|
async function checkServerAndReload() {
|
|
let attempts = 0;
|
|
const maxAttempts = 60; // Try for 60 seconds
|
|
|
|
const checkHealth = async () => {
|
|
try {
|
|
const res = await fetch('/api/admin/status', {
|
|
method: 'GET',
|
|
cache: 'no-store'
|
|
});
|
|
if (res.ok) {
|
|
document.getElementById('restart-status').textContent = 'Server is back! Reloading...';
|
|
dismissRestartBanner();
|
|
setTimeout(() => window.location.reload(), 500);
|
|
return;
|
|
}
|
|
} catch (e) {
|
|
// Server still restarting
|
|
}
|
|
|
|
attempts++;
|
|
document.getElementById('restart-status').textContent = `Waiting for server to come back... (${attempts}s)`;
|
|
|
|
if (attempts < maxAttempts) {
|
|
setTimeout(checkHealth, 1000);
|
|
} else {
|
|
document.getElementById('restart-overlay').classList.remove('active');
|
|
showToast('Server may still be restarting. Please refresh manually.', 'warning');
|
|
}
|
|
};
|
|
|
|
checkHealth();
|
|
}
|
|
|
|
function openAddPlaylist() {
|
|
document.getElementById('new-playlist-name').value = '';
|
|
document.getElementById('new-playlist-id').value = '';
|
|
openModal('add-playlist-modal');
|
|
}
|
|
|
|
async function addPlaylist() {
|
|
const name = document.getElementById('new-playlist-name').value.trim();
|
|
const id = document.getElementById('new-playlist-id').value.trim();
|
|
|
|
if (!name || !id) {
|
|
showToast('Name and ID are required', 'error');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const res = await fetch('/api/admin/playlists', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name, spotifyId: id })
|
|
});
|
|
|
|
const data = await res.json();
|
|
|
|
if (res.ok) {
|
|
showToast('Playlist added.', 'success');
|
|
showRestartBanner();
|
|
closeModal('add-playlist-modal');
|
|
} else {
|
|
showToast(data.error || 'Failed to add playlist', 'error');
|
|
}
|
|
} catch (error) {
|
|
showToast('Failed to add playlist', 'error');
|
|
}
|
|
}
|
|
|
|
async function removePlaylist(name) {
|
|
if (!confirm(`Remove playlist "${name}"?`)) return;
|
|
|
|
try {
|
|
const res = await fetch('/api/admin/playlists/' + encodeURIComponent(name), {
|
|
method: 'DELETE'
|
|
});
|
|
|
|
const data = await res.json();
|
|
|
|
if (res.ok) {
|
|
showToast('Playlist removed.', 'success');
|
|
showRestartBanner();
|
|
fetchPlaylists();
|
|
} else {
|
|
showToast(data.error || 'Failed to remove playlist', 'error');
|
|
}
|
|
} catch (error) {
|
|
showToast('Failed to remove playlist', 'error');
|
|
}
|
|
}
|
|
|
|
async function viewTracks(name) {
|
|
document.getElementById('tracks-modal-title').textContent = name + ' - Tracks';
|
|
document.getElementById('tracks-list').innerHTML = '<div class="loading"><span class="spinner"></span> Loading tracks...</div>';
|
|
openModal('tracks-modal');
|
|
|
|
try {
|
|
const res = await fetch('/api/admin/playlists/' + encodeURIComponent(name) + '/tracks');
|
|
const data = await res.json();
|
|
|
|
if (data.tracks.length === 0) {
|
|
document.getElementById('tracks-list').innerHTML = '<p style="text-align:center;color:var(--text-secondary);padding:40px;">No tracks found</p>';
|
|
return;
|
|
}
|
|
|
|
document.getElementById('tracks-list').innerHTML = data.tracks.map(t => {
|
|
let statusBadge = '';
|
|
let mapButton = '';
|
|
|
|
if (t.isLocal === true) {
|
|
statusBadge = '<span class="status-badge success" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;"><span class="status-dot"></span>Local</span>';
|
|
} else if (t.isLocal === false) {
|
|
statusBadge = '<span class="status-badge warning" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;"><span class="status-dot"></span>External</span>';
|
|
// Add manual map button for external tracks
|
|
// Use JSON.stringify to properly escape strings for JavaScript
|
|
const escapedName = JSON.stringify(name);
|
|
const escapedTitle = JSON.stringify(t.title);
|
|
const escapedArtist = JSON.stringify(t.artists[0] || '');
|
|
const escapedSpotifyId = JSON.stringify(t.spotifyId);
|
|
mapButton = `<button class="small" onclick="openManualMap(${escapedName}, ${t.position}, ${escapedTitle}, ${escapedArtist}, ${escapedSpotifyId})" style="margin-left:8px;font-size:0.75rem;padding:4px 8px;">Map to Local</button>`;
|
|
}
|
|
|
|
return `
|
|
<div class="track-item">
|
|
<span class="track-position">${t.position + 1}</span>
|
|
<div class="track-info">
|
|
<h4>${escapeHtml(t.title)}${statusBadge}${mapButton}</h4>
|
|
<span class="artists">${escapeHtml(t.artists.join(', '))}</span>
|
|
</div>
|
|
<div class="track-meta">
|
|
${t.album ? escapeHtml(t.album) : ''}
|
|
${t.isrc ? '<br><small>ISRC: ' + t.isrc + '</small>' : ''}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
} catch (error) {
|
|
document.getElementById('tracks-list').innerHTML = '<p style="text-align:center;color:var(--error);padding:40px;">Failed to load tracks</p>';
|
|
}
|
|
}
|
|
|
|
// Generic edit setting modal
|
|
function openEditSetting(envKey, label, inputType, helpText = '', options = []) {
|
|
currentEditKey = envKey;
|
|
currentEditType = inputType;
|
|
currentEditOptions = options;
|
|
|
|
document.getElementById('edit-setting-title').textContent = 'Edit ' + label;
|
|
document.getElementById('edit-setting-label').textContent = label;
|
|
|
|
const helpEl = document.getElementById('edit-setting-help');
|
|
if (helpText) {
|
|
helpEl.textContent = helpText;
|
|
helpEl.style.display = 'block';
|
|
} else {
|
|
helpEl.style.display = 'none';
|
|
}
|
|
|
|
const container = document.getElementById('edit-setting-input-container');
|
|
|
|
if (inputType === 'toggle') {
|
|
container.innerHTML = `
|
|
<select id="edit-setting-value">
|
|
<option value="true">Enabled</option>
|
|
<option value="false">Disabled</option>
|
|
</select>
|
|
`;
|
|
} else if (inputType === 'select') {
|
|
container.innerHTML = `
|
|
<select id="edit-setting-value">
|
|
${options.map(opt => `<option value="${opt}">${opt}</option>`).join('')}
|
|
</select>
|
|
`;
|
|
} else if (inputType === 'password') {
|
|
container.innerHTML = `<input type="password" id="edit-setting-value" placeholder="Enter new value" autocomplete="off">`;
|
|
} else if (inputType === 'number') {
|
|
container.innerHTML = `<input type="number" id="edit-setting-value" placeholder="Enter value">`;
|
|
} else {
|
|
container.innerHTML = `<input type="text" id="edit-setting-value" placeholder="Enter value">`;
|
|
}
|
|
|
|
openModal('edit-setting-modal');
|
|
}
|
|
|
|
async function saveEditSetting() {
|
|
const value = document.getElementById('edit-setting-value').value.trim();
|
|
|
|
if (!value && currentEditType !== 'toggle') {
|
|
showToast('Value is required', 'error');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const res = await fetch('/api/admin/config', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ updates: { [currentEditKey]: value } })
|
|
});
|
|
|
|
const data = await res.json();
|
|
|
|
if (res.ok) {
|
|
showToast('Setting updated.', 'success');
|
|
showRestartBanner();
|
|
closeModal('edit-setting-modal');
|
|
fetchConfig();
|
|
fetchStatus();
|
|
} else {
|
|
showToast(data.error || 'Failed to update setting', 'error');
|
|
}
|
|
} catch (error) {
|
|
showToast('Failed to update setting', 'error');
|
|
}
|
|
}
|
|
|
|
function escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
// Manual track mapping
|
|
let searchTimeout = null;
|
|
|
|
function openManualMap(playlistName, position, title, artist, spotifyId) {
|
|
document.getElementById('map-playlist-name').value = playlistName;
|
|
document.getElementById('map-position').textContent = position + 1;
|
|
document.getElementById('map-spotify-title').textContent = title;
|
|
document.getElementById('map-spotify-artist').textContent = artist;
|
|
document.getElementById('map-spotify-id').value = spotifyId;
|
|
document.getElementById('map-search-query').value = '';
|
|
document.getElementById('map-selected-jellyfin-id').value = '';
|
|
document.getElementById('map-save-btn').disabled = true;
|
|
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--text-secondary); padding: 20px;">Type to search for local tracks...</p>';
|
|
|
|
openModal('manual-map-modal');
|
|
}
|
|
|
|
async function searchJellyfinTracks() {
|
|
const query = document.getElementById('map-search-query').value.trim();
|
|
|
|
if (!query) {
|
|
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--text-secondary); padding: 20px;">Type to search for local tracks...</p>';
|
|
return;
|
|
}
|
|
|
|
// Debounce search
|
|
clearTimeout(searchTimeout);
|
|
searchTimeout = setTimeout(async () => {
|
|
document.getElementById('map-search-results').innerHTML = '<div class="loading"><span class="spinner"></span> Searching...</div>';
|
|
|
|
try {
|
|
const res = await fetch('/api/admin/jellyfin/search?query=' + encodeURIComponent(query));
|
|
const data = await res.json();
|
|
|
|
if (data.tracks.length === 0) {
|
|
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--text-secondary); padding: 20px;">No tracks found</p>';
|
|
return;
|
|
}
|
|
|
|
document.getElementById('map-search-results').innerHTML = data.tracks.map(t => `
|
|
<div class="track-item" style="cursor: pointer; border: 2px solid transparent;" onclick="selectJellyfinTrack('${t.id}', this)">
|
|
<div class="track-info">
|
|
<h4>${escapeHtml(t.title)}</h4>
|
|
<span class="artists">${escapeHtml(t.artist)}</span>
|
|
</div>
|
|
<div class="track-meta">
|
|
${t.album ? escapeHtml(t.album) : ''}
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
} catch (error) {
|
|
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--error); padding: 20px;">Search failed</p>';
|
|
}
|
|
}, 300);
|
|
}
|
|
|
|
function selectJellyfinTrack(jellyfinId, element) {
|
|
// Remove selection from all tracks
|
|
document.querySelectorAll('#map-search-results .track-item').forEach(el => {
|
|
el.style.border = '2px solid transparent';
|
|
});
|
|
|
|
// Highlight selected track
|
|
element.style.border = '2px solid var(--primary)';
|
|
|
|
// Store selected ID and enable save button
|
|
document.getElementById('map-selected-jellyfin-id').value = jellyfinId;
|
|
document.getElementById('map-save-btn').disabled = false;
|
|
}
|
|
|
|
async function saveManualMapping() {
|
|
const playlistName = document.getElementById('map-playlist-name').value;
|
|
const spotifyId = document.getElementById('map-spotify-id').value;
|
|
const jellyfinId = document.getElementById('map-selected-jellyfin-id').value;
|
|
|
|
if (!jellyfinId) {
|
|
showToast('Please select a track', 'error');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const res = await fetch('/api/admin/playlists/' + encodeURIComponent(playlistName) + '/map', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ spotifyId, jellyfinId })
|
|
});
|
|
|
|
const data = await res.json();
|
|
|
|
if (res.ok) {
|
|
showToast('Track mapped successfully! Refresh the playlist to see changes.', 'success');
|
|
closeModal('manual-map-modal');
|
|
// Refresh the tracks view
|
|
viewTracks(playlistName);
|
|
} else {
|
|
showToast(data.error || 'Failed to save mapping', 'error');
|
|
}
|
|
} catch (error) {
|
|
showToast('Failed to save mapping', 'error');
|
|
}
|
|
}
|
|
|
|
function escapeJs(text) {
|
|
if (!text) return '';
|
|
return text.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '\\"');
|
|
}
|
|
|
|
// Initial load
|
|
fetchStatus();
|
|
fetchPlaylists();
|
|
fetchJellyfinUsers();
|
|
fetchJellyfinPlaylists();
|
|
fetchConfig();
|
|
|
|
// Auto-refresh every 30 seconds
|
|
setInterval(() => {
|
|
fetchStatus();
|
|
fetchPlaylists();
|
|
}, 30000);
|
|
</script>
|
|
</body>
|
|
</html>
|