weee
This commit is contained in:
@@ -178,6 +178,21 @@ body {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.vs-song-art-cell {
|
||||
width: 48px;
|
||||
padding-left: 0 !important;
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
|
||||
.vs-song-art {
|
||||
display: block;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 0.25rem;
|
||||
object-fit: cover;
|
||||
background: var(--bs-secondary-bg);
|
||||
}
|
||||
|
||||
/* --- Player bar --- */
|
||||
|
||||
.vs-player {
|
||||
@@ -352,3 +367,9 @@ body {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
/* Inline page transitions keep the persistent player mounted. */
|
||||
.vs-main[aria-busy="true"] {
|
||||
opacity: .65;
|
||||
transition: opacity 120ms ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
+129
-1
@@ -474,7 +474,134 @@
|
||||
form.appendChild(input);
|
||||
});
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
form.requestSubmit();
|
||||
});
|
||||
}
|
||||
|
||||
// --- Inline navigation ---
|
||||
// Fetch server-rendered pages and replace only the main content area. The
|
||||
// audio element and player live outside that area, so playback continues
|
||||
// uninterrupted while the URL, title, and active sidebar item still track
|
||||
// the current page normally.
|
||||
function initInlineNavigation() {
|
||||
var pending = null;
|
||||
var currentURL = location.href;
|
||||
history.replaceState({ vsInline: true, scrollY: window.scrollY }, "", currentURL);
|
||||
|
||||
function isLocalHTTP(url) {
|
||||
return (url.protocol === "http:" || url.protocol === "https:") && url.origin === location.origin;
|
||||
}
|
||||
|
||||
function shouldHandleLink(link, ev) {
|
||||
if (!link || ev.defaultPrevented || ev.button !== 0) return false;
|
||||
if (ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return false;
|
||||
if (link.target || link.hasAttribute("download") || link.dataset.noInline != null) return false;
|
||||
var url = new URL(link.href, location.href);
|
||||
if (!isLocalHTTP(url)) return false;
|
||||
if (url.pathname.indexOf("/static/") === 0 || url.pathname.indexOf("/stream/") === 0 ||
|
||||
url.pathname.indexOf("/cover-art/") === 0 || url.pathname.indexOf("/share/") === 0 ||
|
||||
/\/export$/.test(url.pathname)) return false;
|
||||
// Let the browser handle an in-page fragment without a network request.
|
||||
if (url.pathname === location.pathname && url.search === location.search && url.hash) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function setBusy(busy) {
|
||||
var main = document.querySelector(".vs-main");
|
||||
if (!main) return;
|
||||
if (busy) main.setAttribute("aria-busy", "true");
|
||||
else main.removeAttribute("aria-busy");
|
||||
}
|
||||
|
||||
function applyPage(html, responseURL, push, restoreScroll) {
|
||||
var parsed = new DOMParser().parseFromString(html, "text/html");
|
||||
var nextMain = parsed.querySelector(".vs-main");
|
||||
var oldMain = document.querySelector(".vs-main");
|
||||
// Login, setup, shared, and error documents are intentionally full-page.
|
||||
if (!nextMain || !oldMain) return false;
|
||||
|
||||
oldMain.replaceWith(nextMain);
|
||||
document.title = parsed.title || document.title;
|
||||
var tokenScript = parsed.querySelector("script[data-vs-csrf]");
|
||||
if (tokenScript) window.vsCSRFToken = tokenScript.dataset.vsCsrf;
|
||||
|
||||
var activeHref = parsed.querySelector(".vs-sidebar .nav-link.active");
|
||||
document.querySelectorAll(".vs-sidebar .nav-link").forEach(function (link) {
|
||||
link.classList.toggle("active", !!activeHref && link.getAttribute("href") === activeHref.getAttribute("href"));
|
||||
});
|
||||
|
||||
currentURL = responseURL;
|
||||
if (push) history.pushState({ vsInline: true, scrollY: 0 }, "", responseURL);
|
||||
var top = typeof restoreScroll === "number" ? restoreScroll : 0;
|
||||
window.scrollTo(0, top);
|
||||
var autofocus = nextMain.querySelector("[autofocus]");
|
||||
if (autofocus) autofocus.focus({ preventScroll: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
function navigate(url, options) {
|
||||
options = options || {};
|
||||
if (pending) pending.abort();
|
||||
var controller = new AbortController();
|
||||
pending = controller;
|
||||
setBusy(true);
|
||||
var request = {
|
||||
method: options.method || "GET",
|
||||
body: options.body || null,
|
||||
signal: controller.signal,
|
||||
headers: { "X-VibeSonic-Inline": "1" },
|
||||
};
|
||||
return fetch(url, request).then(function (response) {
|
||||
if (!response.ok || !(response.headers.get("Content-Type") || "").includes("text/html")) {
|
||||
throw new Error("not an inline page");
|
||||
}
|
||||
return response.text().then(function (html) {
|
||||
if (!applyPage(html, response.url, options.push !== false, options.restoreScroll)) {
|
||||
location.assign(response.url);
|
||||
}
|
||||
});
|
||||
}).catch(function (err) {
|
||||
if (err.name !== "AbortError") {
|
||||
if ((options.method || "GET") === "GET") location.assign(url);
|
||||
else location.reload();
|
||||
}
|
||||
}).finally(function () {
|
||||
if (pending === controller) {
|
||||
pending = null;
|
||||
setBusy(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("click", function (ev) {
|
||||
var link = ev.target.closest("a[href]");
|
||||
if (!shouldHandleLink(link, ev)) return;
|
||||
ev.preventDefault();
|
||||
history.replaceState({ vsInline: true, scrollY: window.scrollY }, "", currentURL);
|
||||
navigate(link.href);
|
||||
});
|
||||
|
||||
document.addEventListener("submit", function (ev) {
|
||||
var form = ev.target;
|
||||
if (ev.defaultPrevented || !(form instanceof HTMLFormElement) || form.dataset.noInline != null) return;
|
||||
var url = new URL(form.action || location.href, location.href);
|
||||
if (!isLocalHTTP(url) || url.pathname === "/logout") return;
|
||||
ev.preventDefault();
|
||||
var data = new FormData(form);
|
||||
var method = (form.method || "GET").toUpperCase();
|
||||
if (method === "GET") {
|
||||
url.search = new URLSearchParams(data).toString();
|
||||
navigate(url.href);
|
||||
} else {
|
||||
navigate(url.href, { method: method, body: data });
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("popstate", function (ev) {
|
||||
navigate(location.href, {
|
||||
push: false,
|
||||
restoreScroll: ev.state && typeof ev.state.scrollY === "number" ? ev.state.scrollY : 0,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -483,5 +610,6 @@
|
||||
var player = initPlayer();
|
||||
initKeyboardShortcuts(player);
|
||||
initStarButtons();
|
||||
initInlineNavigation();
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
{{define "admin_album_edit"}}{{template "header" .}}
|
||||
<div class="card" style="max-width: 500px;">
|
||||
<div class="card-body">
|
||||
<h2 class="h6">Edit album</h2>
|
||||
<form method="post" action="/admin/albums/{{.Data.ID}}/edit" enctype="multipart/form-data">
|
||||
{{template "csrf_field" .CSRFToken}}
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Title</label>
|
||||
<input type="text" name="title" class="form-control form-control-sm" value="{{.Data.Title}}" required>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Sort title</label>
|
||||
<input type="text" name="sort_title" class="form-control form-control-sm" value="{{.Data.SortTitle}}">
|
||||
</div>
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-6">
|
||||
<label class="form-label small">Year</label>
|
||||
<input type="number" name="year" class="form-control form-control-sm" value="{{if .Data.Year.Valid}}{{.Data.Year.Int64}}{{end}}">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label small">Original year</label>
|
||||
<input type="number" name="original_year" class="form-control form-control-sm" value="{{if .Data.OriginalYear.Valid}}{{.Data.OriginalYear.Int64}}{{end}}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small">Record label</label>
|
||||
<input type="text" name="record_label" class="form-control form-control-sm" value="{{.Data.RecordLabel}}">
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
<h3 class="h6">Cover art</h3>
|
||||
{{if .Data.CoverArtID.Valid}}
|
||||
<img src="/cover-art/{{.Data.CoverArtID.String}}?size=150" style="width:120px;" class="mb-2" alt="">
|
||||
{{end}}
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Upload a cover</label>
|
||||
<input type="file" name="cover_file" class="form-control form-control-sm" accept="image/*">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small">...or set a cover URL</label>
|
||||
<input type="url" name="cover_url" class="form-control form-control-sm" placeholder="https://example.com/cover.jpg">
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Save</button>
|
||||
<a href="/albums/{{.Data.ID}}" class="btn btn-sm btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{template "footer" .}}{{end}}
|
||||
@@ -0,0 +1,50 @@
|
||||
{{define "admin_artist_edit"}}{{template "header" .}}
|
||||
<div class="card" style="max-width: 500px;">
|
||||
<div class="card-body">
|
||||
<h2 class="h6">Edit artist</h2>
|
||||
<form method="post" action="/admin/artists/{{.Data.ID}}/edit" enctype="multipart/form-data">
|
||||
{{template "csrf_field" .CSRFToken}}
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Name</label>
|
||||
<input type="text" name="name" class="form-control form-control-sm" value="{{.Data.Name}}" required>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Sort name</label>
|
||||
<input type="text" name="sort_name" class="form-control form-control-sm" value="{{.Data.SortName}}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small">Biography</label>
|
||||
<textarea name="bio" class="form-control form-control-sm" rows="4">{{.Data.Bio}}</textarea>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
<h3 class="h6">Photo</h3>
|
||||
{{if .Data.CoverArtID.Valid}}
|
||||
<img src="/cover-art/{{.Data.CoverArtID.String}}?size=150" style="width:100px;height:100px;border-radius:50%;object-fit:cover;" class="mb-2" alt="">
|
||||
{{end}}
|
||||
{{if .Data.PhotoManual}}
|
||||
<p class="small text-body-secondary">This photo was set manually and will not be replaced by auto-fetch.</p>
|
||||
<div class="form-check mb-2">
|
||||
<input class="form-check-input" type="checkbox" name="remove_photo" value="1" id="remove_photo">
|
||||
<label class="form-check-label" for="remove_photo">Remove custom photo (revert to auto-fetch)</label>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="small text-body-secondary">No custom photo set{{if not .Data.MusicBrainzID}} (this artist has no MusicBrainz ID, so auto-fetch cannot run){{end}}.</p>
|
||||
{{end}}
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Upload a photo</label>
|
||||
<input type="file" name="photo_file" class="form-control form-control-sm" accept="image/*">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small">...or set a photo URL</label>
|
||||
<input type="url" name="photo_url" class="form-control form-control-sm" placeholder="https://example.com/photo.jpg">
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Save</button>
|
||||
<a href="/artists/{{.Data.ID}}" class="btn btn-sm btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{template "footer" .}}{{end}}
|
||||
@@ -22,6 +22,13 @@
|
||||
{{if .Channel.ErrorMessage}}<div class="text-danger small">{{.Channel.ErrorMessage}}</div>{{end}}
|
||||
</div>
|
||||
<div>
|
||||
{{if .Episodes}}
|
||||
<form method="post" action="/podcasts/{{.Channel.ID}}/download-all" class="d-inline">
|
||||
{{template "csrf_field" $.CSRFToken}}
|
||||
<input type="hidden" name="next" value="/admin/podcasts">
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary"><i class="bi bi-download"></i> Download all</button>
|
||||
</form>
|
||||
{{end}}
|
||||
<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>
|
||||
|
||||
@@ -21,6 +21,43 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-3" style="max-width: 600px;">
|
||||
<div class="card-body">
|
||||
<h2 class="h6">Artist photos</h2>
|
||||
{{if .Data.ArtistPhotosEnabled}}
|
||||
<p class="text-secondary small mb-2">
|
||||
Fetch a photo (fanart.tv when an artist has a tagged MusicBrainz ID and a key is configured, otherwise a direct name search against Wikidata/Wikimedia Commons) for every artist with no manually-set photo. This never calls MusicBrainz itself, so it isn't affected by its rate limits — matching by name alone is less precise, though. This bypasses any previously-cached "not found" result, so it's a good way to retry after fixing a config or network issue.
|
||||
</p>
|
||||
{{else}}
|
||||
<p class="text-secondary small mb-2">
|
||||
Automatic background fetching is disabled (<code>VIBESONIC_ENABLE_ARTIST_PHOTOS=false</code>), but this button still runs a one-off fetch — useful for checking why photos aren't showing up.
|
||||
</p>
|
||||
{{end}}
|
||||
<form method="post" action="/admin/settings/artist-photos/refresh">
|
||||
{{template "csrf_field" .CSRFToken}}
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary">Fetch all artist photos now</button>
|
||||
</form>
|
||||
{{if .Data.ArtistPhotoResults}}
|
||||
<table class="table table-sm vs-table mt-3 mb-0">
|
||||
<thead><tr><th>Artist</th><th>Status</th><th>Error</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Data.ArtistPhotoResults}}
|
||||
<tr>
|
||||
<td>{{.ArtistName}}</td>
|
||||
<td>
|
||||
{{if .Error}}<span class="text-danger">{{.Status}}</span>
|
||||
{{else if eq .Status "fetched"}}<span class="text-success">{{.Status}}</span>
|
||||
{{else}}<span class="text-secondary">{{.Status}}</span>{{end}}
|
||||
</td>
|
||||
<td class="text-danger small">{{.Error}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-3" style="max-width: 600px;">
|
||||
<div class="card-body">
|
||||
<h2 class="h6">External authentication</h2>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
{{define "admin_song_edit"}}{{template "header" .}}
|
||||
<div class="card" style="max-width: 500px;">
|
||||
<div class="card-body">
|
||||
<h2 class="h6">Edit song</h2>
|
||||
<form method="post" action="/admin/songs/{{.Data.ID}}/edit">
|
||||
{{template "csrf_field" .CSRFToken}}
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Title</label>
|
||||
<input type="text" name="title" class="form-control form-control-sm" value="{{.Data.Title}}" required>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Sort title</label>
|
||||
<input type="text" name="sort_title" class="form-control form-control-sm" value="{{.Data.SortTitle}}">
|
||||
</div>
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-6">
|
||||
<label class="form-label small">Track #</label>
|
||||
<input type="number" name="track_number" class="form-control form-control-sm" value="{{if .Data.TrackNumber.Valid}}{{.Data.TrackNumber.Int64}}{{end}}">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label small">Disc #</label>
|
||||
<input type="number" name="disc_number" class="form-control form-control-sm" value="{{if .Data.DiscNumber.Valid}}{{.Data.DiscNumber.Int64}}{{end}}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Composer</label>
|
||||
<input type="text" name="composer" class="form-control form-control-sm" value="{{.Data.Composer}}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small">Conductor</label>
|
||||
<input type="text" name="conductor" class="form-control form-control-sm" value="{{.Data.Conductor}}">
|
||||
</div>
|
||||
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="checkbox" name="metadata_locked" value="1" id="metadata_locked" {{if .Data.MetadataLocked}}checked{{end}}>
|
||||
<label class="form-check-label" for="metadata_locked">Lock metadata (prevent library rescans from overwriting these fields)</label>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Save</button>
|
||||
<a href="/songs" class="btn btn-sm btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{template "footer" .}}{{end}}
|
||||
@@ -11,6 +11,9 @@
|
||||
{{template "csrf_field" .CSRFToken}}
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary"><i class="bi bi-share"></i> Share</button>
|
||||
</form>
|
||||
{{if and .User .User.IsAdmin}}
|
||||
<a class="btn btn-sm btn-outline-secondary" href="/admin/albums/{{.Data.Album.ID}}/edit"><i class="bi bi-pencil"></i> Edit album</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{template "song_rows" .Data.Songs}}
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
{{define "artist"}}{{template "header" .}}
|
||||
<div class="d-flex gap-3 mb-4">
|
||||
<img class="vs-card-art" style="width:180px;border-radius:50%;" src="{{if .Data.Artist.CoverArtID}}/cover-art/{{.Data.Artist.CoverArtID}}?size=400{{else}}/static/img/default-art.svg{{end}}" alt="">
|
||||
<div>
|
||||
<h2 class="h4 mb-1">{{.Data.Artist.Name}}</h2>
|
||||
{{if .Data.Artist.Bio}}<p class="text-secondary mb-2">{{.Data.Artist.Bio}}</p>{{end}}
|
||||
{{if and .User .User.IsAdmin}}
|
||||
<a class="btn btn-sm btn-outline-secondary" href="/admin/artists/{{.Data.Artist.ID}}/edit"><i class="bi bi-pencil"></i> Edit artist</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="vs-grid mb-3">
|
||||
{{range .Data.Albums}}{{template "album_card" .}}{{end}}
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
{{define "artist_card"}}
|
||||
<a href="/artists/{{.ID}}" class="vs-card">
|
||||
<img class="vs-card-art" style="border-radius:50%;" src="/static/img/default-art.svg" alt="" loading="lazy">
|
||||
<img class="vs-card-art" style="border-radius:50%;" src="{{if .CoverArtID}}/cover-art/{{.CoverArtID}}?size=300{{else}}/static/img/default-art.svg{{end}}" alt="" loading="lazy">
|
||||
<div class="vs-card-title">{{.Name}}</div>
|
||||
</a>
|
||||
{{end}}
|
||||
@@ -26,11 +26,17 @@
|
||||
data-cover="{{$s.CoverArtID}}"
|
||||
data-starred="{{if $s.Starred}}1{{else}}0{{end}}">
|
||||
<td class="vs-track-num">{{if $s.TrackNumber}}{{$s.TrackNumber}}{{else}}{{add $i 1}}{{end}}</td>
|
||||
<td class="vs-song-art-cell">
|
||||
<img class="vs-song-art" src="{{if $s.CoverArtID}}/cover-art/{{$s.CoverArtID}}?size=80{{else}}/static/img/default-art.svg{{end}}" alt="" loading="lazy">
|
||||
</td>
|
||||
<td>
|
||||
<div class="fw-semibold">{{$s.Title}}</div>
|
||||
{{if $s.AlbumTitle}}<div class="text-secondary small">{{$s.ArtistName}} — {{$s.AlbumTitle}}</div>{{else}}<div class="text-secondary small">{{$s.ArtistName}}</div>{{end}}
|
||||
</td>
|
||||
<td class="text-end text-secondary small">{{fmtDuration $s.DurationSeconds}}</td>
|
||||
<td class="text-end text-secondary small">
|
||||
{{fmtDuration $s.DurationSeconds}}
|
||||
{{if $s.IsAdmin}}<a href="/admin/songs/{{$s.ID}}/edit" title="Edit song" class="text-secondary ms-2"><i class="bi bi-pencil"></i></a>{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<script>window.vsCSRFToken = {{.CSRFToken}};</script>
|
||||
<script data-vs-csrf="{{.CSRFToken}}">window.vsCSRFToken = {{.CSRFToken}};</script>
|
||||
<header class="vs-topbar d-flex align-items-center justify-content-between">
|
||||
<a href="/" class="vs-brand d-flex align-items-center gap-2">
|
||||
<i class="bi bi-vinyl-fill"></i>
|
||||
|
||||
@@ -3,7 +3,15 @@
|
||||
{{range .Data}}
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h2 class="h6">{{.Channel.Title}}</h2>
|
||||
<div class="d-flex align-items-center justify-content-between gap-2">
|
||||
<h2 class="h6 mb-0">{{.Channel.Title}}</h2>
|
||||
{{if .Episodes}}
|
||||
<form method="post" action="/podcasts/{{.Channel.ID}}/download-all">
|
||||
{{template "csrf_field" $.CSRFToken}}
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary"><i class="bi bi-download"></i> Download all episodes</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
<p class="text-secondary small">{{.Channel.Description}}</p>
|
||||
<table class="table table-sm vs-table mb-0">
|
||||
<tbody>
|
||||
|
||||
Reference in New Issue
Block a user