Web UI: podcasts, radio, shares, bookmarks pages; admin podcast/jukebox management; per-user Subsonic permission editor

This commit is contained in:
2026-08-23 00:34:58 -05:00
parent 2e222bd193
commit f1fdd8d2c2
21 changed files with 688 additions and 9 deletions
+41
View File
@@ -0,0 +1,41 @@
{{define "admin_jukebox"}}{{template "header" .}}
{{if not .Data.Available}}
<div class="alert alert-warning">
The jukebox is not available: no <code>mpv</code> binary was found on this server. Install mpv and restart VibeSonic to enable server-side jukebox playback.
</div>
{{else}}
<div class="card" style="max-width: 500px;">
<div class="card-body">
<h2 class="h6">Status</h2>
{{if .Data.Error}}
<p class="text-danger">{{.Data.Error}}</p>
{{else}}
<table class="table table-sm vs-table mb-3">
<tbody>
<tr><td>Playing</td><td>{{.Data.Status.Playing}}</td></tr>
<tr><td>Current index</td><td>{{.Data.Status.CurrentIndex}}</td></tr>
<tr><td>Position</td><td>{{.Data.Status.PositionSecs}}s</td></tr>
<tr><td>Gain</td><td>{{.Data.Status.Gain}}</td></tr>
<tr><td>Playlist length</td><td>{{len .Data.Status.SongIDs}}</td></tr>
</tbody>
</table>
{{end}}
<form method="post" action="/admin/jukebox/start" class="d-inline">
{{template "csrf_field" .CSRFToken}}
<button type="submit" class="btn btn-sm btn-outline-primary">Start</button>
</form>
<form method="post" action="/admin/jukebox/stop" class="d-inline">
{{template "csrf_field" .CSRFToken}}
<button type="submit" class="btn btn-sm btn-outline-secondary">Stop</button>
</form>
<form method="post" action="/admin/jukebox/clear" class="d-inline">
{{template "csrf_field" .CSRFToken}}
<button type="submit" class="btn btn-sm btn-outline-danger">Clear</button>
</form>
<p class="text-secondary small mt-3 mb-0">
Build a jukebox playlist from a Subsonic client (e.g. DSub, Symfonium) using the <code>jukeboxControl</code> API — this page is for server status and emergency stop/clear only.
</p>
</div>
</div>
{{end}}
{{template "footer" .}}{{end}}
+61
View File
@@ -0,0 +1,61 @@
{{define "admin_podcasts"}}{{template "header" .}}
<div class="card mb-4" style="max-width: 600px;">
<div class="card-body">
<h2 class="h6">Add channel</h2>
<form method="post" action="/admin/podcasts">
{{template "csrf_field" .CSRFToken}}
<div class="input-group input-group-sm">
<input type="text" name="url" class="form-control" placeholder="https://example.com/feed.xml" required>
<button type="submit" class="btn btn-primary">Add</button>
</div>
</form>
</div>
</div>
{{range .Data}}
<div class="card mb-3">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<div>
<h2 class="h6 mb-0">{{.Channel.Title}} <span class="badge text-bg-secondary">{{.Channel.Status}}</span></h2>
<div class="text-secondary small">{{.Channel.URL}}</div>
{{if .Channel.ErrorMessage}}<div class="text-danger small">{{.Channel.ErrorMessage}}</div>{{end}}
</div>
<div>
<form method="post" action="/admin/podcasts/{{.Channel.ID}}/refresh" class="d-inline">
{{template "csrf_field" $.CSRFToken}}
<button type="submit" class="btn btn-sm btn-outline-primary">Refresh</button>
</form>
<form method="post" action="/admin/podcasts/{{.Channel.ID}}/delete" class="d-inline" onsubmit="return confirm('Delete this channel and its episodes?');">
{{template "csrf_field" $.CSRFToken}}
<button type="submit" class="btn btn-sm btn-outline-danger">Delete</button>
</form>
</div>
</div>
<table class="table table-sm vs-table mt-2 mb-0">
<tbody>
{{range .Episodes}}
<tr>
<td>{{.Title}}</td>
<td class="text-secondary small">{{.PublishDate}}</td>
<td>{{.Status}}</td>
<td class="text-end">
{{if ne .Status "completed"}}
<form method="post" action="/admin/podcasts/episodes/{{.ID}}/download" class="d-inline">
{{template "csrf_field" $.CSRFToken}}
<button type="submit" class="btn btn-sm btn-outline-primary">Download</button>
</form>
{{end}}
<form method="post" action="/admin/podcasts/episodes/{{.ID}}/delete" class="d-inline">
{{template "csrf_field" $.CSRFToken}}
<button type="submit" class="btn btn-sm btn-outline-danger">Delete</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{end}}
{{template "footer" .}}{{end}}
+56
View File
@@ -0,0 +1,56 @@
{{define "admin_user_permissions"}}{{template "header" .}}
<div class="card" style="max-width: 500px;">
<div class="card-body">
<h2 class="h6">{{.Data.Username}}</h2>
<form method="post" action="/admin/users/{{.Data.ID}}/permissions">
{{template "csrf_field" .CSRFToken}}
<div class="form-check">
<input class="form-check-input" type="checkbox" name="settings" value="1" id="p_settings" {{if .Data.Permissions.Settings}}checked{{end}}>
<label class="form-check-label" for="p_settings">Settings</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="stream" value="1" id="p_stream" {{if .Data.Permissions.Stream}}checked{{end}}>
<label class="form-check-label" for="p_stream">Stream</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="download" value="1" id="p_download" {{if .Data.Permissions.Download}}checked{{end}}>
<label class="form-check-label" for="p_download">Download</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="upload" value="1" id="p_upload" {{if .Data.Permissions.Upload}}checked{{end}}>
<label class="form-check-label" for="p_upload">Upload</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="playlist" value="1" id="p_playlist" {{if .Data.Permissions.Playlist}}checked{{end}}>
<label class="form-check-label" for="p_playlist">Playlist</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="cover_art" value="1" id="p_cover_art" {{if .Data.Permissions.CoverArt}}checked{{end}}>
<label class="form-check-label" for="p_cover_art">Cover Art</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="comment" value="1" id="p_comment" {{if .Data.Permissions.Comment}}checked{{end}}>
<label class="form-check-label" for="p_comment">Comment</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="podcast" value="1" id="p_podcast" {{if .Data.Permissions.Podcast}}checked{{end}}>
<label class="form-check-label" for="p_podcast">Podcast</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="share" value="1" id="p_share" {{if .Data.Permissions.Share}}checked{{end}}>
<label class="form-check-label" for="p_share">Share</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="jukebox" value="1" id="p_jukebox" {{if .Data.Permissions.Jukebox}}checked{{end}}>
<label class="form-check-label" for="p_jukebox">Jukebox</label>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" name="video_conversion" value="1" id="p_video" {{if .Data.Permissions.VideoConversion}}checked{{end}}>
<label class="form-check-label" for="p_video">Video Conversion</label>
</div>
<button type="submit" class="btn btn-primary btn-sm">Save</button>
<a href="/admin/users" class="btn btn-sm btn-outline-secondary">Cancel</a>
</form>
</div>
</div>
{{template "footer" .}}{{end}}
+1
View File
@@ -27,6 +27,7 @@
<td>{{if .IsAdmin}}Admin{{else}}User{{end}}</td>
<td>{{if .Disabled}}<span class="text-danger">Disabled</span>{{else}}<span class="text-success">Active</span>{{end}}</td>
<td class="text-end">
<a href="/admin/users/{{.ID}}/permissions" class="btn btn-sm btn-outline-secondary">Permissions</a>
<form method="post" action="/admin/users/{{.ID}}/toggle-disabled" class="d-inline">
{{template "csrf_field" $.CSRFToken}}
<button type="submit" class="btn btn-sm btn-outline-secondary">{{if .Disabled}}Enable{{else}}Disable{{end}}</button>
+4
View File
@@ -7,6 +7,10 @@
<button type="button" class="btn vs-star-btn {{if .Data.Starred}}active{{end}}" data-star-toggle data-item-type="album" data-item-id="{{.Data.Album.ID}}">
{{if .Data.Starred}}<i class="bi bi-star-fill"></i>{{else}}<i class="bi bi-star"></i>{{end}} Favorite
</button>
<form method="post" action="/albums/{{.Data.Album.ID}}/share" class="d-inline">
{{template "csrf_field" .CSRFToken}}
<button type="submit" class="btn btn-sm btn-outline-secondary"><i class="bi bi-share"></i> Share</button>
</form>
</div>
</div>
{{template "song_rows" .Data.Songs}}
+24
View File
@@ -0,0 +1,24 @@
{{define "bookmarks"}}{{template "header" .}}
{{if .Data}}
<table class="table vs-table table-sm">
<thead><tr><th>Song</th><th>Position</th><th>Comment</th><th></th></tr></thead>
<tbody>
{{range .Data}}
<tr>
<td>{{.Song.Title}} <span class="text-secondary small">— {{.Song.ArtistName}}</span></td>
<td>{{fmtDuration (millisToSec .Bookmark.PositionMs)}}</td>
<td class="text-secondary small">{{.Bookmark.Comment}}</td>
<td class="text-end">
<form method="post" action="/bookmarks/{{.Bookmark.SongID}}/delete">
{{template "csrf_field" $.CSRFToken}}
<button type="submit" class="btn btn-sm btn-outline-danger">Remove</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="text-secondary">No bookmarks yet.</p>
{{end}}
{{template "footer" .}}{{end}}
+6
View File
@@ -69,6 +69,10 @@
<li class="nav-item"><a class="nav-link {{if eq .Active "playlists"}}active{{end}}" href="/playlists"><i class="bi bi-list-ul"></i> Playlists</a></li>
<li class="nav-item"><a class="nav-link {{if eq .Active "favorites"}}active{{end}}" href="/favorites"><i class="bi bi-star"></i> Favorites</a></li>
<li class="nav-item"><a class="nav-link {{if eq .Active "recent"}}active{{end}}" href="/recent"><i class="bi bi-clock-history"></i> Recently Played</a></li>
<li class="nav-item"><a class="nav-link {{if eq .Active "podcasts"}}active{{end}}" href="/podcasts"><i class="bi bi-broadcast"></i> Podcasts</a></li>
<li class="nav-item"><a class="nav-link {{if eq .Active "radio"}}active{{end}}" href="/radio"><i class="bi bi-soundwave"></i> Radio</a></li>
<li class="nav-item"><a class="nav-link {{if eq .Active "shares"}}active{{end}}" href="/shares"><i class="bi bi-share"></i> Shares</a></li>
<li class="nav-item"><a class="nav-link {{if eq .Active "bookmarks"}}active{{end}}" href="/bookmarks"><i class="bi bi-bookmark"></i> Bookmarks</a></li>
{{if and .User .User.IsAdmin}}
<li class="vs-nav-label">Admin</li>
@@ -76,6 +80,8 @@
<li class="nav-item"><a class="nav-link {{if eq .Active "admin_folders"}}active{{end}}" href="/admin/folders"><i class="bi bi-folder2-open"></i> Music Folders</a></li>
<li class="nav-item"><a class="nav-link {{if eq .Active "admin_scanner"}}active{{end}}" href="/admin/scanner"><i class="bi bi-arrow-repeat"></i> Scanner</a></li>
<li class="nav-item"><a class="nav-link {{if eq .Active "admin_settings"}}active{{end}}" href="/admin/settings"><i class="bi bi-sliders"></i> System Settings</a></li>
<li class="nav-item"><a class="nav-link {{if eq .Active "admin_podcasts"}}active{{end}}" href="/admin/podcasts"><i class="bi bi-broadcast"></i> Podcasts</a></li>
<li class="nav-item"><a class="nav-link {{if eq .Active "admin_jukebox"}}active{{end}}" href="/admin/jukebox"><i class="bi bi-cpu"></i> Jukebox</a></li>
{{end}}
</ul>
<div class="mt-auto px-3 py-2 text-secondary small">
+31
View File
@@ -0,0 +1,31 @@
{{define "podcasts"}}{{template "header" .}}
{{if not .Data}}<p class="text-secondary">No podcasts yet. Ask an administrator to add a channel.</p>{{end}}
{{range .Data}}
<div class="card mb-3">
<div class="card-body">
<h2 class="h6">{{.Channel.Title}}</h2>
<p class="text-secondary small">{{.Channel.Description}}</p>
<table class="table table-sm vs-table mb-0">
<tbody>
{{range .Episodes}}
<tr>
<td>{{.Title}}</td>
<td class="text-secondary small">{{.PublishDate}}</td>
<td>{{.Status}}</td>
<td class="text-end">
{{if eq .Status "completed"}}<span class="text-success"><i class="bi bi-check-circle"></i> Downloaded</span>
{{else}}
<form method="post" action="/podcasts/{{.ID}}/download">
{{template "csrf_field" $.CSRFToken}}
<button type="submit" class="btn btn-sm btn-outline-primary">Download</button>
</form>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{end}}
{{template "footer" .}}{{end}}
+42
View File
@@ -0,0 +1,42 @@
{{define "radio"}}{{template "header" .}}
{{if and .User .User.IsAdmin}}
<div class="card mb-4" style="max-width: 600px;">
<div class="card-body">
<h2 class="h6">Add station</h2>
<form method="post" action="/radio">
{{template "csrf_field" .CSRFToken}}
<div class="row g-2">
<div class="col-4"><input type="text" name="name" class="form-control form-control-sm" placeholder="Name" required></div>
<div class="col-4"><input type="text" name="stream_url" class="form-control form-control-sm" placeholder="Stream URL" required></div>
<div class="col-4"><input type="text" name="homepage_url" class="form-control form-control-sm" placeholder="Homepage (optional)"></div>
</div>
<button type="submit" class="btn btn-primary btn-sm mt-2">Add station</button>
</form>
</div>
</div>
{{end}}
{{if .Data}}
<div class="list-group">
{{range .Data}}
<div class="list-group-item d-flex justify-content-between align-items-center">
<div>
<div class="fw-semibold">{{.Name}}</div>
<div class="text-secondary small">{{.StreamURL}}</div>
</div>
<div>
<a class="btn btn-sm btn-outline-primary" href="{{.StreamURL}}"><i class="bi bi-play-fill"></i> Play</a>
{{if and $.User $.User.IsAdmin}}
<form method="post" action="/radio/{{.ID}}/delete" class="d-inline">
{{template "csrf_field" $.CSRFToken}}
<button type="submit" class="btn btn-sm btn-outline-danger"><i class="bi bi-trash"></i></button>
</form>
{{end}}
</div>
</div>
{{end}}
</div>
{{else}}
<p class="text-secondary">No internet radio stations yet.</p>
{{end}}
{{template "footer" .}}{{end}}
+25
View File
@@ -0,0 +1,25 @@
{{define "shares"}}{{template "header" .}}
{{if .Data}}
<table class="table vs-table table-sm">
<thead><tr><th>Description</th><th>Owner</th><th>Visits</th><th>Link</th><th></th></tr></thead>
<tbody>
{{range .Data}}
<tr>
<td>{{if .Description}}{{.Description}}{{else}}<span class="text-secondary">(no description)</span>{{end}}</td>
<td class="text-secondary small">{{.OwnerID}}</td>
<td>{{.VisitCount}}</td>
<td><a href="/share/{{.Token}}" target="_blank">/share/{{.Token}}</a></td>
<td class="text-end">
<form method="post" action="/shares/{{.ID}}/delete">
{{template "csrf_field" $.CSRFToken}}
<button type="submit" class="btn btn-sm btn-outline-danger">Delete</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="text-secondary">No shares yet. Create one from an album page.</p>
{{end}}
{{template "footer" .}}{{end}}