Subsonic: sharing (getShares, createShare, updateShare, deleteShare) plus real public share web pages and stream route

This commit is contained in:
2026-08-23 00:18:51 -05:00
parent b77ccd242a
commit c6e0131984
9 changed files with 734 additions and 14 deletions
+153 -8
View File
@@ -3,39 +3,184 @@ package database
import ( import (
"database/sql" "database/sql"
"errors" "errors"
"time"
"git.noroutes.net/NoRoutes/vibetunes/internal/idgen" "git.noroutes.net/NoRoutes/vibetunes/internal/idgen"
) )
// Shares are a schema foundation for Phase 2's Subsonic share endpoints;
// Phase 1 doesn't yet expose a sharing UI.
type Share struct { type Share struct {
ID string ID string
OwnerID string OwnerID string
Token string Token string
Description string Description string
ExpiresAt sql.NullString ExpiresAt sql.NullString
CreatedAt time.Time
LastVisitedAt sql.NullString
VisitCount int64
} }
func CreateShare(db *sql.DB, ownerID, token, description string) (*Share, error) { const shareSelect = `SELECT id, owner_id, token, description, expires_at, created_at, last_visited_at, visit_count FROM shares`
func CreateShare(db *sql.DB, ownerID, token, description string, expiresAt sql.NullString) (*Share, error) {
id := idgen.New("sh") id := idgen.New("sh")
_, err := db.Exec(`INSERT INTO shares (id, owner_id, token, description) VALUES (?, ?, ?, ?)`, id, ownerID, token, description) var exp any
if expiresAt.Valid {
exp = expiresAt.String
}
_, err := db.Exec(`INSERT INTO shares (id, owner_id, token, description, expires_at) VALUES (?, ?, ?, ?, ?)`, id, ownerID, token, description, exp)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return GetShareByToken(db, token) return GetShare(db, id)
}
func GetShare(db *sql.DB, id string) (*Share, error) {
return scanShare(db.QueryRow(shareSelect+` WHERE id = ?`, id))
} }
func GetShareByToken(db *sql.DB, token string) (*Share, error) { func GetShareByToken(db *sql.DB, token string) (*Share, error) {
return scanShare(db.QueryRow(shareSelect+` WHERE token = ?`, token))
}
func scanShare(row *sql.Row) (*Share, error) {
var s Share var s Share
err := db.QueryRow(`SELECT id, owner_id, token, description, expires_at FROM shares WHERE token = ?`, token). var createdAt string
Scan(&s.ID, &s.OwnerID, &s.Token, &s.Description, &s.ExpiresAt) err := row.Scan(&s.ID, &s.OwnerID, &s.Token, &s.Description, &s.ExpiresAt, &createdAt, &s.LastVisitedAt, &s.VisitCount)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound return nil, ErrNotFound
} }
if err != nil { if err != nil {
return nil, err return nil, err
} }
s.CreatedAt, _ = parseTime(createdAt)
return &s, nil return &s, nil
} }
func ListSharesByOwner(db *sql.DB, ownerID string) ([]*Share, error) {
rows, err := db.Query(shareSelect+` WHERE owner_id = ? ORDER BY created_at DESC`, ownerID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanShares(rows)
}
func ListAllShares(db *sql.DB) ([]*Share, error) {
rows, err := db.Query(shareSelect + ` ORDER BY created_at DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
return scanShares(rows)
}
func scanShares(rows *sql.Rows) ([]*Share, error) {
var out []*Share
for rows.Next() {
var s Share
var createdAt string
if err := rows.Scan(&s.ID, &s.OwnerID, &s.Token, &s.Description, &s.ExpiresAt, &createdAt, &s.LastVisitedAt, &s.VisitCount); err != nil {
return nil, err
}
s.CreatedAt, _ = parseTime(createdAt)
out = append(out, &s)
}
return out, rows.Err()
}
func UpdateShare(db *sql.DB, id, description string, expiresAt sql.NullString) error {
var exp any
if expiresAt.Valid {
exp = expiresAt.String
}
_, err := db.Exec(`UPDATE shares SET description = ?, expires_at = ? WHERE id = ?`, description, exp, id)
return err
}
func DeleteShare(db *sql.DB, id string) error {
_, err := db.Exec(`DELETE FROM shares WHERE id = ?`, id)
return err
}
func RecordShareVisit(db *sql.DB, id string) error {
_, err := db.Exec(`UPDATE shares SET last_visited_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), visit_count = visit_count + 1 WHERE id = ?`, id)
return err
}
// IsExpired reports whether a share's expiry has passed.
func (s *Share) IsExpired() bool {
if !s.ExpiresAt.Valid {
return false
}
t, err := parseTime(s.ExpiresAt.String)
if err != nil {
return false
}
return time.Now().After(t)
}
// --- Share items ---
type ShareItem struct {
ID string
ShareID string
SongID sql.NullString
AlbumID sql.NullString
Position int
}
func AddShareItem(db *sql.DB, shareID string, songID, albumID sql.NullString, position int) error {
var song, album any
if songID.Valid {
song = songID.String
}
if albumID.Valid {
album = albumID.String
}
_, err := db.Exec(`INSERT INTO share_items (id, share_id, song_id, album_id, position) VALUES (?, ?, ?, ?, ?)`,
idgen.New("shi"), shareID, song, album, position)
return err
}
func ListShareItems(db *sql.DB, shareID string) ([]*ShareItem, error) {
rows, err := db.Query(`SELECT id, share_id, song_id, album_id, position FROM share_items WHERE share_id = ? ORDER BY position`, shareID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*ShareItem
for rows.Next() {
var si ShareItem
if err := rows.Scan(&si.ID, &si.ShareID, &si.SongID, &si.AlbumID, &si.Position); err != nil {
return nil, err
}
out = append(out, &si)
}
return out, rows.Err()
}
// ShareSongIDs resolves a share's items to a flat list of song IDs,
// expanding any shared albums into their songs.
func ShareSongIDs(db *sql.DB, shareID string) ([]string, error) {
items, err := ListShareItems(db, shareID)
if err != nil {
return nil, err
}
var out []string
for _, item := range items {
if item.SongID.Valid {
out = append(out, item.SongID.String)
continue
}
if item.AlbumID.Valid {
songs, err := ListSongsByAlbum(db, item.AlbumID.String)
if err != nil {
return nil, err
}
for _, s := range songs {
out = append(out, s.ID)
}
}
}
return out, nil
}
@@ -0,0 +1,7 @@
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:1
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:1.023322,
seg00000.ts
#EXT-X-ENDLIST
+198
View File
@@ -0,0 +1,198 @@
package subsonic
import (
"database/sql"
"strconv"
"time"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
"git.noroutes.net/NoRoutes/vibetunes/internal/idgen"
)
func init() {
register("getShares", handleGetShares)
register("createShare", handleCreateShare)
register("updateShare", handleUpdateShare)
register("deleteShare", handleDeleteShare)
}
func shareURL(rc *Context, token string) string {
scheme := "http"
if rc.R.TLS != nil {
scheme = "https"
}
return scheme + "://" + rc.R.Host + "/share/" + token
}
func shareToModel(db *sql.DB, rc *Context, s *database.Share) (Share, error) {
owner, _ := database.GetUserByID(db, s.OwnerID)
ownerName := ""
if owner != nil {
ownerName = owner.Username
}
out := Share{
ID: s.ID,
URL: shareURL(rc, s.Token),
Description: s.Description,
Username: ownerName,
Created: isoTime(s.CreatedAt),
VisitCount: s.VisitCount,
}
if s.ExpiresAt.Valid {
if t, err := parseISOTime(s.ExpiresAt.String); err == nil {
out.Expires = isoTime(t)
}
}
if s.LastVisitedAt.Valid {
if t, err := parseISOTime(s.LastVisitedAt.String); err == nil {
out.LastVisited = isoTime(t)
}
}
songIDs, err := database.ShareSongIDs(db, s.ID)
if err != nil {
return out, err
}
for _, id := range songIDs {
song, serr := database.GetSong(db, id)
if serr != nil {
continue
}
mf, merr := database.GetMediaFile(db, song.MediaFileID)
if merr != nil {
continue
}
out.Entry = append(out.Entry, childFromSong(db, song, mf, albumOfSong(db, song), ""))
}
return out, nil
}
func handleGetShares(rc *Context) *apiError {
if !rc.User.Allows(rc.User.Permissions.Share) {
return errUnauthorized("sharing")
}
db := rc.Server.DB
var shares []*database.Share
var err error
if rc.User.IsAdmin {
shares, err = database.ListAllShares(db)
} else {
shares, err = database.ListSharesByOwner(db, rc.User.ID)
}
if err != nil {
return newError(ErrGeneric, err.Error())
}
out := &Shares{}
for _, s := range shares {
model, merr := shareToModel(db, rc, s)
if merr != nil {
continue
}
out.Share = append(out.Share, model)
}
rc.Response = newResponse()
rc.Response.Shares = out
return nil
}
func handleCreateShare(rc *Context) *apiError {
if !rc.User.Allows(rc.User.Permissions.Share) {
return errUnauthorized("sharing")
}
ids := rc.Params.GetAll("id")
if len(ids) == 0 {
return errMissingParameter("id")
}
db := rc.Server.DB
description := rc.Params.Get("description")
var expiresAt sql.NullString
if v := rc.Params.Get("expires"); v != "" {
millis, perr := strconv.ParseInt(v, 10, 64)
if perr == nil {
expiresAt = sql.NullString{String: isoTime(time.UnixMilli(millis)), Valid: true}
}
}
token := idgen.New("")
share, err := database.CreateShare(db, rc.User.ID, token, description, expiresAt)
if err != nil {
return newError(ErrGeneric, err.Error())
}
pos := 0
for _, id := range ids {
var songID, albumID sql.NullString
if _, err := database.GetSong(db, id); err == nil {
songID = sql.NullString{String: id, Valid: true}
} else if _, err := database.GetAlbum(db, id); err == nil {
albumID = sql.NullString{String: id, Valid: true}
} else {
continue
}
if err := database.AddShareItem(db, share.ID, songID, albumID, pos); err != nil {
return newError(ErrGeneric, err.Error())
}
pos++
}
model, merr := shareToModel(db, rc, share)
if merr != nil {
return newError(ErrGeneric, merr.Error())
}
rc.Response = newResponse()
rc.Response.Shares = &Shares{Share: []Share{model}}
return nil
}
func handleUpdateShare(rc *Context) *apiError {
id, aerr := rc.Params.Required("id")
if aerr != nil {
return aerr
}
db := rc.Server.DB
share, err := database.GetShare(db, id)
if err != nil {
return errNotFound("Share")
}
if share.OwnerID != rc.User.ID && !rc.User.IsAdmin {
return errUnauthorized("modifying this share")
}
description := share.Description
if rc.Params.Has("description") {
description = rc.Params.Get("description")
}
expiresAt := share.ExpiresAt
if v := rc.Params.Get("expires"); v != "" {
millis, perr := strconv.ParseInt(v, 10, 64)
if perr == nil {
expiresAt = sql.NullString{String: isoTime(time.UnixMilli(millis)), Valid: true}
}
}
if err := database.UpdateShare(db, id, description, expiresAt); err != nil {
return newError(ErrGeneric, err.Error())
}
rc.Response = newResponse()
return nil
}
func handleDeleteShare(rc *Context) *apiError {
id, aerr := rc.Params.Required("id")
if aerr != nil {
return aerr
}
db := rc.Server.DB
share, err := database.GetShare(db, id)
if err != nil {
return errNotFound("Share")
}
if share.OwnerID != rc.User.ID && !rc.User.IsAdmin {
return errUnauthorized("deleting this share")
}
if err := database.DeleteShare(db, id); err != nil {
return newError(ErrGeneric, err.Error())
}
rc.Response = newResponse()
return nil
}
+95
View File
@@ -0,0 +1,95 @@
package subsonic
import (
"net/http/httptest"
"testing"
)
func TestShareLifecycle(t *testing.T) {
s, srv := newTestRecorderServer(t)
createTestUser(t, s, "alice", "password123", false)
seedLibrary(t, s)
song := firstSong(t, s)
q := baseParams("alice", "password123")
q.Add("id", song.ID)
q.Set("description", "Check this out")
q.Set("f", "json")
resp := getPath(t, srv.URL, "/rest/createShare.view", q)
j := decodeJSON(t, resp)
shares := j["shares"].(map[string]any)["share"].([]any)
if len(shares) != 1 {
t.Fatalf("createShare shares = %v, want 1", shares)
}
share := shares[0].(map[string]any)
if share["url"] == nil || share["url"] == "" {
t.Fatalf("share missing url: %v", share)
}
shareID := share["id"].(string)
entries, ok := share["entry"].([]any)
if !ok || len(entries) != 1 {
t.Fatalf("share entries = %v, want 1", share["entry"])
}
q2 := baseParams("alice", "password123")
q2.Set("f", "json")
resp = getPath(t, srv.URL, "/rest/getShares.view", q2)
j = decodeJSON(t, resp)
shares = j["shares"].(map[string]any)["share"].([]any)
if len(shares) != 1 {
t.Fatalf("getShares = %v, want 1", shares)
}
q3 := baseParams("alice", "password123")
q3.Set("id", shareID)
q3.Set("description", "Updated description")
resp = getPath(t, srv.URL, "/rest/updateShare.view", q3)
x := decodeXML(t, resp)
if x.Status != "ok" {
t.Fatalf("updateShare failed: %+v", x)
}
q4 := baseParams("alice", "password123")
q4.Set("id", shareID)
resp = getPath(t, srv.URL, "/rest/deleteShare.view", q4)
x = decodeXML(t, resp)
if x.Status != "ok" {
t.Fatalf("deleteShare failed: %+v", x)
}
}
func TestShareOtherUserCannotModify(t *testing.T) {
s, _ := newTestServer(t)
createTestUser(t, s, "owner", "password123", false)
createTestUser(t, s, "other", "password123", false)
seedLibrary(t, s)
srv := httptest.NewServer(s.Router())
defer srv.Close()
q := baseParams("owner", "password123")
q.Add("id", firstSong(t, s).ID)
q.Set("f", "json")
resp := getPath(t, srv.URL, "/rest/createShare.view", q)
j := decodeJSON(t, resp)
shares := j["shares"].(map[string]any)["share"].([]any)
shareID := shares[0].(map[string]any)["id"].(string)
q2 := baseParams("other", "password123")
q2.Set("id", shareID)
resp = getPath(t, srv.URL, "/rest/deleteShare.view", q2)
x := decodeXML(t, resp)
if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrUnauthorized {
t.Fatalf("expected error 50 deleting another user's share, got %+v", x)
}
}
func TestCreateShareMissingID(t *testing.T) {
s, srv := newTestRecorderServer(t)
createTestUser(t, s, "alice", "password123", false)
resp := getPath(t, srv.URL, "/rest/createShare.view", baseParams("alice", "password123"))
x := decodeXML(t, resp)
if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrMissingParameter {
t.Fatalf("expected error 10, got %+v", x)
}
}
+5 -1
View File
@@ -2,6 +2,7 @@ package web
import ( import (
"net/http" "net/http"
"strings"
"git.noroutes.net/NoRoutes/vibetunes/internal/database" "git.noroutes.net/NoRoutes/vibetunes/internal/database"
assets "git.noroutes.net/NoRoutes/vibetunes/web" assets "git.noroutes.net/NoRoutes/vibetunes/web"
@@ -24,6 +25,9 @@ func (h *Web) NewRouter() http.Handler {
mux.HandleFunc("POST /login", h.handleLogin) mux.HandleFunc("POST /login", h.handleLogin)
mux.HandleFunc("POST /logout", h.handleLogout) mux.HandleFunc("POST /logout", h.handleLogout)
mux.HandleFunc("GET /share/{token}", h.handleSharePage)
mux.HandleFunc("GET /share/{token}/stream/{songID}", h.handleShareStream)
mux.HandleFunc("GET /{$}", h.requireUser(h.handleHome)) mux.HandleFunc("GET /{$}", h.requireUser(h.handleHome))
mux.HandleFunc("GET /search", h.requireUser(h.handleSearch)) mux.HandleFunc("GET /search", h.requireUser(h.handleSearch))
@@ -91,7 +95,7 @@ func (h *Web) requireAdmin(fn http.HandlerFunc) http.HandlerFunc {
func (h *Web) setupGate(next http.Handler) http.Handler { func (h *Web) setupGate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/setup" || r.URL.Path == "/healthz" || r.URL.Path == "/readyz" || if r.URL.Path == "/setup" || r.URL.Path == "/healthz" || r.URL.Path == "/readyz" ||
len(r.URL.Path) >= 8 && r.URL.Path[:8] == "/static/" { strings.HasPrefix(r.URL.Path, "/static/") || strings.HasPrefix(r.URL.Path, "/share/") {
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
return return
} }
+109
View File
@@ -0,0 +1,109 @@
package web
import (
"net/http"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
"git.noroutes.net/NoRoutes/vibetunes/internal/streaming"
)
// handleSharePage serves a public, unauthenticated page for a share
// token — the whole point of a share link is that the recipient doesn't
// need a VibeSonic account. Expired or unknown tokens 404 rather than
// leaking which is which.
func (h *Web) handleSharePage(w http.ResponseWriter, r *http.Request) {
token := r.PathValue("token")
share, err := database.GetShareByToken(h.DB, token)
if err != nil || share.IsExpired() {
http.NotFound(w, r)
return
}
_ = database.RecordShareVisit(h.DB, share.ID)
songIDs, err := database.ShareSongIDs(h.DB, share.ID)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
var rows []SongRow
for _, id := range songIDs {
song, serr := database.GetSong(h.DB, id)
if serr != nil {
continue
}
rows = append(rows, toSongRow(h.DB, song, albumOf(h.DB, song), ""))
}
owner, _ := database.GetUserByID(h.DB, share.OwnerID)
ownerName := ""
if owner != nil {
ownerName = owner.Username
}
render(w, "share", PageData{
Title: "Shared music",
Data: struct {
Description string
OwnerName string
Songs []SongRow
Token string
}{share.Description, ownerName, rows, token},
})
}
// handleShareStream serves one song from a share, publicly and without
// a session — but only a song that is actually part of that share, and
// only through the same path-validated streaming.ServeFile used
// everywhere else, so a share link can never be used to fetch arbitrary
// filesystem paths.
func (h *Web) handleShareStream(w http.ResponseWriter, r *http.Request) {
token := r.PathValue("token")
songID := r.PathValue("songID")
share, err := database.GetShareByToken(h.DB, token)
if err != nil || share.IsExpired() {
http.NotFound(w, r)
return
}
songIDs, err := database.ShareSongIDs(h.DB, share.ID)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
allowed := false
for _, id := range songIDs {
if id == songID {
allowed = true
break
}
}
if !allowed {
http.NotFound(w, r)
return
}
song, err := database.GetSong(h.DB, songID)
if err != nil {
http.NotFound(w, r)
return
}
mf, err := database.GetMediaFile(h.DB, song.MediaFileID)
if err != nil {
http.NotFound(w, r)
return
}
folder, err := database.GetMusicFolder(h.DB, mf.MusicFolderID)
if err != nil {
http.NotFound(w, r)
return
}
absPath, perr := streaming.ResolvePath(folder.Path, mf.RelativePath)
if perr != nil {
http.Error(w, "403 Forbidden", http.StatusForbidden)
return
}
if err := streaming.ServeFile(w, r, absPath, mf.RelativePath); err != nil {
http.Error(w, "could not read media file", http.StatusInternalServerError)
}
}
+108
View File
@@ -0,0 +1,108 @@
package web
import (
"database/sql"
"io"
"net/http"
"net/http/httptest"
"testing"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
)
func TestPublicShareRequiresNoAuth(t *testing.T) {
h, db := newTestWeb(t)
seedShareFixture(t, h, db)
srv := httptest.NewServer(h.NewRouter())
defer srv.Close()
share, err := firstShare(db)
if err != nil {
t.Fatal(err)
}
resp, err := http.Get(srv.URL + "/share/" + share.Token)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("public share page status = %d, want 200", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if len(body) == 0 {
t.Fatal("empty share page body")
}
}
func TestPublicShareUnknownToken404s(t *testing.T) {
h, _ := newTestWeb(t)
srv := httptest.NewServer(h.NewRouter())
defer srv.Close()
resp, err := http.Get(srv.URL + "/share/does-not-exist")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("status = %d, want 404", resp.StatusCode)
}
}
func TestShareStreamRejectsSongNotInShare(t *testing.T) {
h, db := newTestWeb(t)
seedShareFixture(t, h, db)
srv := httptest.NewServer(h.NewRouter())
defer srv.Close()
share, err := firstShare(db)
if err != nil {
t.Fatal(err)
}
resp, err := http.Get(srv.URL + "/share/" + share.Token + "/stream/sg_not_in_this_share")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("status = %d, want 404 for a song not part of the share", resp.StatusCode)
}
}
func firstShare(db *sql.DB) (*database.Share, error) {
shares, err := database.ListAllShares(db)
if err != nil {
return nil, err
}
return shares[0], nil
}
// seedShareFixture creates a user, a music folder/song row, and a share
// containing that song, directly via the database (no scanner needed —
// this package's tests don't exercise ffmpeg).
func seedShareFixture(t *testing.T, h *Web, db *sql.DB) {
t.Helper()
owner, err := database.CreateUser(db, "shareowner", "", "", false)
if err != nil {
t.Fatal(err)
}
folder, err := database.CreateMusicFolder(db, "Music", t.TempDir(), "music")
if err != nil {
t.Fatal(err)
}
mf, err := database.CreateMediaFile(db, folder.ID, "song.mp3", 100, 0, "hash", "mp3", "song", 10, 128, 44100, 2)
if err != nil {
t.Fatal(err)
}
song, err := database.UpsertSong(db, database.UpsertSongInput{MediaFileID: mf.ID, Title: "Shared Song"})
if err != nil {
t.Fatal(err)
}
share, err := database.CreateShare(db, owner.ID, "test-token-123", "A test share", sql.NullString{})
if err != nil {
t.Fatal(err)
}
if err := database.AddShareItem(db, share.ID, sql.NullString{String: song.ID, Valid: true}, sql.NullString{}, 0); err != nil {
t.Fatal(err)
}
}
+54
View File
@@ -0,0 +1,54 @@
{{define "share"}}<!doctype html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} · VibeSonic</title>
<link rel="stylesheet" href="/static/css/bootstrap.min.css">
<link rel="stylesheet" href="/static/css/bootstrap-icons.min.css">
<link rel="stylesheet" href="/static/css/app.css">
</head>
<body>
<div class="container py-4" style="max-width: 700px;">
<h1 class="h4 mb-1"><i class="bi bi-vinyl-fill"></i> Shared music</h1>
{{if .Data.OwnerName}}<p class="text-secondary small">Shared by {{.Data.OwnerName}}</p>{{end}}
{{if .Data.Description}}<p>{{.Data.Description}}</p>{{end}}
{{if .Data.Songs}}
<div class="table-responsive" data-playable-list>
<table class="vs-table vs-table-hover table mb-0">
<tbody>
{{range $i, $s := .Data.Songs}}
<tr class="vs-playable" data-song-id="{{$s.ID}}" data-title="{{$s.Title}}" data-artist="{{$s.ArtistName}}" data-album="{{$s.AlbumTitle}}">
<td class="vs-track-num">{{add $i 1}}</td>
<td>
<div class="fw-semibold">{{$s.Title}}</div>
<div class="text-secondary small">{{$s.ArtistName}}</div>
</td>
<td class="text-end"><a class="btn btn-sm btn-outline-secondary" href="/share/{{$.Data.Token}}/stream/{{$s.ID}}" download><i class="bi bi-download"></i></a></td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<p class="text-secondary">This share has no items.</p>
{{end}}
</div>
<audio id="vs-share-audio" controls style="position:fixed; bottom:0; left:0; right:0; width:100%;"></audio>
<script>
(function () {
var audio = document.getElementById("vs-share-audio");
document.addEventListener("click", function (ev) {
var el = ev.target.closest(".vs-playable");
if (!el) return;
ev.preventDefault();
audio.src = "/share/{{.Data.Token}}/stream/" + encodeURIComponent(el.dataset.songId);
audio.play().catch(function () {});
});
})();
</script>
</body>
</html>
{{end}}