MORE REFACTORING!! + some improvements

Signed-off-by: ari melody <ari@arimelody.me>
This commit is contained in:
ari melody 2024-08-02 00:53:19 +01:00
parent 151b2d8fd9
commit cba791deba
17 changed files with 376 additions and 223 deletions

47
admin/admin.go Normal file
View file

@ -0,0 +1,47 @@
package admin
import (
"fmt"
"math/rand"
"os"
"time"
)
type (
Session struct {
UserID string
Token string
Expires int64
}
)
const TOKEN_LENGTH = 64
const TOKEN_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
var ADMIN_ID_DISCORD = func() string {
envvar := os.Getenv("DISCORD_ADMIN_ID")
if envvar == "" {
fmt.Printf("DISCORD_ADMIN_ID was not provided. Admin login will be unavailable.\n")
}
return envvar
}()
var sessions []*Session
func createSession(UserID string) Session {
return Session{
UserID: UserID,
Token: string(generateToken()),
Expires: time.Now().Add(24 * time.Hour).Unix(),
}
}
func generateToken() string {
var token []byte
for i := 0; i < TOKEN_LENGTH; i++ {
token = append(token, TOKEN_CHARS[rand.Intn(len(TOKEN_CHARS))])
}
return string(token)
}

View file

@ -3,45 +3,17 @@ package admin
import ( import (
"context" "context"
"fmt" "fmt"
"math/rand" "html/template"
"net/http" "net/http"
"os" "os"
// "strings" "path/filepath"
"strings"
"time" "time"
"arimelody.me/arimelody.me/discord" "arimelody.me/arimelody.me/discord"
"arimelody.me/arimelody.me/global" "arimelody.me/arimelody.me/global"
) )
type (
Session struct {
UserID string
Token string
Expires int64
}
)
const TOKEN_LENGTH = 64
const TOKEN_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
var ADMIN_ID_DISCORD = func() string {
envvar := os.Getenv("DISCORD_ADMIN_ID")
if envvar == "" {
fmt.Printf("DISCORD_ADMIN_ID was not provided. Admin login will be unavailable.\n")
}
return envvar
}()
var sessions []*Session
func CreateSession(UserID string) Session {
return Session{
UserID: UserID,
Token: string(generateToken()),
Expires: time.Now().Add(24 * time.Hour).Unix(),
}
}
func Handler() http.Handler { func Handler() http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
@ -53,18 +25,13 @@ func Handler() http.Handler {
mux.Handle("/login", global.HTTPLog(LoginHandler())) mux.Handle("/login", global.HTTPLog(LoginHandler()))
mux.Handle("/verify", global.HTTPLog(MustAuthorise(VerifyHandler()))) mux.Handle("/verify", global.HTTPLog(MustAuthorise(VerifyHandler())))
mux.Handle("/logout", global.HTTPLog(MustAuthorise(LogoutHandler()))) mux.Handle("/logout", global.HTTPLog(MustAuthorise(LogoutHandler())))
mux.Handle("/static", global.HTTPLog(MustAuthorise(staticHandler())))
return mux return mux
} }
func MustAuthorise(next http.Handler) http.Handler { func MustAuthorise(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) {
// TEMPORARY
ctx := context.WithValue(r.Context(), "role", "admin")
next.ServeHTTP(w, r.WithContext(ctx))
return
/*
auth := r.Header.Get("Authorization") auth := r.Header.Get("Authorization")
if strings.HasPrefix(auth, "Bearer ") { if strings.HasPrefix(auth, "Bearer ") {
auth = auth[7:] auth = auth[7:]
@ -104,7 +71,6 @@ func MustAuthorise(next http.Handler) http.Handler {
ctx := context.WithValue(r.Context(), "role", "admin") ctx := context.WithValue(r.Context(), "role", "admin")
next.ServeHTTP(w, r.WithContext(ctx)) next.ServeHTTP(w, r.WithContext(ctx))
*/
}) })
} }
@ -143,7 +109,7 @@ func LoginHandler() http.Handler {
} }
// login success! // login success!
session := CreateSession(discord_user.Username) session := createSession(discord_user.Username)
sessions = append(sessions, &session) sessions = append(sessions, &session)
cookie := http.Cookie{} cookie := http.Cookie{}
@ -197,12 +163,60 @@ func VerifyHandler() http.Handler {
}) })
} }
func generateToken() string { func ServeTemplate(page string, data any) http.Handler {
var token []byte return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
lp_layout := filepath.Join("views", "layout.html")
lp_header := filepath.Join("views", "header.html")
lp_footer := filepath.Join("views", "footer.html")
lp_prideflag := filepath.Join("views", "prideflag.html")
fp := filepath.Join("views", filepath.Clean(page))
for i := 0; i < TOKEN_LENGTH; i++ { info, err := os.Stat(fp)
token = append(token, TOKEN_CHARS[rand.Intn(len(TOKEN_CHARS))]) if err != nil {
if os.IsNotExist(err) {
http.NotFound(w, r)
return
}
} }
return string(token) if info.IsDir() {
http.NotFound(w, r)
return
}
template, err := template.ParseFiles(lp_layout, lp_header, lp_footer, lp_prideflag, fp)
if err != nil {
fmt.Printf("Error parsing template files: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
err = template.ExecuteTemplate(w, "layout.html", data)
if err != nil {
fmt.Printf("Error executing template: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
})
}
func staticHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
info, err := os.Stat(filepath.Join("admin", "static", filepath.Clean(r.URL.Path)))
// does the file exist?
if err != nil {
if os.IsNotExist(err) {
http.NotFound(w, r)
return
}
}
// is thjs a directory? (forbidden)
if info.IsDir() {
http.NotFound(w, r)
return
}
http.FileServer(http.Dir(filepath.Join("admin", "static"))).ServeHTTP(w, r)
})
} }

11
colour/colour.go Normal file
View file

@ -0,0 +1,11 @@
package colour
var Reset = "\033[0m"
var Red = "\033[31m"
var Green = "\033[32m"
var Yellow = "\033[33m"
var Blue = "\033[34m"
var Purple = "\033[35m"
var Cyan = "\033[36m"
var Gray = "\033[37m"
var White = "\033[97m"

View file

@ -6,19 +6,40 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
"os"
"strings" "strings"
) )
const API_ENDPOINT = "https://discord.com/api/v10" const API_ENDPOINT = "https://discord.com/api/v10"
const CLIENT_ID = "1268013769578119208"
// TODO: good GOD change this later please i beg you. we've already broken var CLIENT_ID = func() string {
// the rules by doing this at all envvar := os.Getenv("DISCORD_CLIENT_ID")
const CLIENT_SECRET = "JUEZnixhN7BxmLIHmbECiKETMP85VT0E" if envvar == "" {
const REDIRECT_URI = "https://discord.com/oauth2/authorize?client_id=1268013769578119208&response_type=code&redirect_uri=http%3A%2F%2F127.0.0.1%3A8080%2Fapi%2Fv1%2Fadmin%2Flogin&scope=identify" fmt.Printf("DISCORD_CLIENT_ID was not provided. Admin login will be unavailable.\n")
}
// TODO: change before prod return envvar
const MY_REDIRECT_URI = "http://127.0.0.1:8080/api/v1/admin/login" }()
var CLIENT_SECRET = func() string {
envvar := os.Getenv("DISCORD_CLIENT_SECRET")
if envvar == "" {
fmt.Printf("DISCORD_CLIENT_SECRET was not provided. Admin login will be unavailable.\n")
}
return envvar
}()
var REDIRECT_URI = func() string {
envvar := os.Getenv("DISCORD_REDIRECT_URI")
if envvar == "" {
fmt.Printf("DISCORD_REDIRECT_URI was not provided. Admin login will be unavailable.\n")
}
return envvar
}()
var OAUTH_CALLBACK_URI = func() string {
envvar := os.Getenv("OAUTH_CALLBACK_URI")
if envvar == "" {
fmt.Printf("OAUTH_CALLBACK_URI was not provided. Admin login will be unavailable.\n")
}
return envvar
}()
type ( type (
AccessTokenResponse struct { AccessTokenResponse struct {
@ -63,7 +84,7 @@ func GetOAuthTokenFromCode(code string) (string, error) {
"client_secret": {CLIENT_SECRET}, "client_secret": {CLIENT_SECRET},
"grant_type": {"authorization_code"}, "grant_type": {"authorization_code"},
"code": {code}, "code": {code},
"redirect_uri": {MY_REDIRECT_URI}, "redirect_uri": {OAUTH_CALLBACK_URI},
}.Encode())) }.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded") req.Header.Add("Content-Type", "application/x-www-form-urlencoded")

View file

@ -8,19 +8,9 @@ import (
"html/template" "html/template"
"strconv" "strconv"
"time" "time"
)
var MimeTypes = map[string]string{ "arimelody.me/arimelody.me/colour"
"css": "text/css; charset=utf-8", )
"png": "image/png",
"jpg": "image/jpg",
"webp": "image/webp",
"html": "text/html",
"asc": "text/plain",
"pub": "text/plain",
"txt": "text/plain",
"js": "application/javascript",
}
func DefaultHeaders(next http.Handler) http.Handler { func DefaultHeaders(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) {
@ -54,11 +44,20 @@ func HTTPLog(next http.Handler) http.Handler {
elapsed = strconv.Itoa(difference) elapsed = strconv.Itoa(difference)
} }
fmt.Printf("[%s] %s %s - %d (%sms) (%s)\n", codeColour := colour.Reset
if lrw.Code - 600 <= 0 { codeColour = colour.Red }
if lrw.Code - 500 <= 0 { codeColour = colour.Yellow }
if lrw.Code - 400 <= 0 { codeColour = colour.White }
if lrw.Code - 300 <= 0 { codeColour = colour.Green }
fmt.Printf("[%s] %s %s - %s%d%s (%sms) (%s)\n",
after.Format(time.UnixDate), after.Format(time.UnixDate),
r.Method, r.Method,
r.URL.Path, r.URL.Path,
codeColour,
lrw.Code, lrw.Code,
colour.Reset,
elapsed, elapsed,
r.Header["User-Agent"][0]) r.Header["User-Agent"][0])
}) })

View file

@ -7,8 +7,8 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"arimelody.me/arimelody.me/api/v1/admin" "arimelody.me/arimelody.me/admin"
"arimelody.me/arimelody.me/api/v1/music" "arimelody.me/arimelody.me/music"
"arimelody.me/arimelody.me/db" "arimelody.me/arimelody.me/db"
"arimelody.me/arimelody.me/global" "arimelody.me/arimelody.me/global"
) )
@ -44,7 +44,7 @@ func main() {
func createServeMux() *http.ServeMux { func createServeMux() *http.ServeMux {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.Handle("/api/v1/admin/", global.HTTPLog(http.StripPrefix("/api/v1/admin", admin.Handler()))) mux.Handle("/admin/", global.HTTPLog(http.StripPrefix("/admin", admin.Handler())))
mux.Handle("/api/v1/music/artist/", global.HTTPLog(http.StripPrefix("/api/v1/music/artist", music.ServeArtist()))) mux.Handle("/api/v1/music/artist/", global.HTTPLog(http.StripPrefix("/api/v1/music/artist", music.ServeArtist())))
mux.Handle("/api/v1/music/", global.HTTPLog(http.StripPrefix("/api/v1/music", music.ServeRelease()))) mux.Handle("/api/v1/music/", global.HTTPLog(http.StripPrefix("/api/v1/music", music.ServeRelease())))

View file

@ -6,12 +6,20 @@ import (
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
) )
type Credit struct { type (
Credit struct {
Artist *Artist `json:"artist"` Artist *Artist `json:"artist"`
Role string `json:"role"` Role string `json:"role"`
Primary bool `json:"primary"` Primary bool `json:"primary"`
} }
PostCreditBody struct {
Artist string `json:"artist"`
Role string `json:"role"`
Primary bool `json:"primary"`
}
)
// GETTERS // GETTERS
func (credit Credit) GetArtist() Artist { func (credit Credit) GetArtist() Artist {

View file

@ -7,19 +7,10 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"arimelody.me/arimelody.me/api/v1/admin" "arimelody.me/arimelody.me/admin"
"arimelody.me/arimelody.me/global" "arimelody.me/arimelody.me/global"
) )
// func make_date_work(date string) time.Time {
// res, err := time.Parse("2-Jan-2006", date)
// if err != nil {
// fmt.Printf("somehow we failed to parse %s! falling back to epoch :]\n", date)
// return time.Unix(0, 0)
// }
// return res
// }
// HTTP HANDLER METHODS // HTTP HANDLER METHODS
func ServeCatalog() http.Handler { func ServeCatalog() http.Handler {
@ -33,7 +24,7 @@ func ServeCatalog() http.Handler {
releases = append(releases, release) releases = append(releases, release)
} }
global.ServeTemplate("music.html", releases).ServeHTTP(w, r) global.ServeTemplate("music.html", Releases).ServeHTTP(w, r)
}) })
} }
@ -96,7 +87,6 @@ func ServeArtwork() http.Handler {
} }
fp := filepath.Join("data", "music-artwork", releaseID + ".png") fp := filepath.Join("data", "music-artwork", releaseID + ".png")
fmt.Println(fp)
info, err := os.Stat(fp) info, err := os.Stat(fp)
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {

View file

@ -8,7 +8,7 @@ import (
"strings" "strings"
"time" "time"
"arimelody.me/arimelody.me/api/v1/admin" "arimelody.me/arimelody.me/admin"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
) )
@ -21,7 +21,8 @@ const (
Compilation ReleaseType = "Compilation" Compilation ReleaseType = "Compilation"
) )
type Release struct { type (
Release struct {
ID string `json:"id"` ID string `json:"id"`
Title string `json:"title"` Title string `json:"title"`
Description string `json:"description"` Description string `json:"description"`
@ -35,6 +36,21 @@ type Release struct {
Tracks []Track `json:"tracks"` Tracks []Track `json:"tracks"`
} }
PostReleaseBody struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
ReleaseType ReleaseType `json:"type"`
ReleaseDate time.Time `json:"releaseDate"`
Artwork string `json:"artwork"`
Buyname string `json:"buyname"`
Buylink string `json:"buylink"`
Links []Link `json:"links"`
Credits []PostCreditBody `json:"credits"`
Tracks []Track `json:"tracks"`
}
)
var Releases []Release; var Releases []Release;
// GETTERS // GETTERS
@ -337,7 +353,7 @@ func (release Release) DeleteFromDB(db *sqlx.DB) error {
func PullAllReleases(db *sqlx.DB) ([]Release, error) { func PullAllReleases(db *sqlx.DB) ([]Release, error) {
releases := []Release{} releases := []Release{}
rows, err := db.Query("SELECT id, title, description, type, release_date, artwork, buyname, buylink FROM musicreleases") rows, err := db.Query("SELECT id, title, description, type, release_date, artwork, buyname, buylink FROM musicreleases ORDER BY release_date DESC")
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -423,19 +439,50 @@ func PostRelease() http.Handler {
return return
} }
var release Release var data PostReleaseBody
err := json.NewDecoder(r.Body).Decode(&release) err := json.NewDecoder(r.Body).Decode(&data)
if err != nil { if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return return
} }
if GetRelease(release.ID) != nil { if GetRelease(data.ID) != nil {
http.Error(w, fmt.Sprintf("Release %s already exists", release.ID), http.StatusBadRequest) http.Error(w, fmt.Sprintf("Release %s already exists", data.ID), http.StatusBadRequest)
return return
} }
Releases = append(Releases, release) var credits = []Credit{}
for _, credit := range data.Credits {
var artist = GetArtist(credit.Artist)
if artist == nil {
http.Error(w, fmt.Sprintf("Artist %s does not exist", credit.Artist), http.StatusBadRequest)
return
}
credits = append(credits, Credit{
Artist: artist,
Role: credit.Role,
Primary: credit.Primary,
})
}
var release = Release{
ID: data.ID,
Title: data.Title,
Description: data.Description,
ReleaseType: data.ReleaseType,
ReleaseDate: data.ReleaseDate,
Artwork: data.Artwork,
Buyname: data.Buyname,
Buylink: data.Buylink,
Links: data.Links,
Credits: credits,
Tracks: data.Tracks,
}
Releases = append([]Release{release}, Releases...)
jsonBytes, err := json.Marshal(release) jsonBytes, err := json.Marshal(release)
w.Header().Add("Content-Type", "application/json") w.Header().Add("Content-Type", "application/json")

View file

@ -7,6 +7,6 @@ document.addEventListener("click", event => {
} }
}); });
hamburger.addEventListener("click", event => { hamburger.addEventListener("click", () => {
header_links.classList.toggle("open"); header_links.classList.toggle("open");
}); });

View file

@ -1,7 +1,12 @@
import "./main.js"; import "./main.js";
document.addEventListener("swap", () => { document.querySelectorAll("div.music").forEach(container => {
document.querySelectorAll("h1.music-title").forEach(element => { const link = container.querySelector(".music-title a").href
element.href = "";
container.addEventListener("click", event => {
if (event.target.href) return;
event.preventDefault();
location = link;
}); });
}); });

View file

@ -1,17 +1,18 @@
-- --
-- Artists (should be applicable to all art) -- Artists (should be applicable to all art)
-- --
CREATE TABLE IF NOT EXISTS artists ( CREATE TABLE artist (
id text NOT NULL, id uuid DEFAULT gen_random_uuid(),
name text, name text NOT NULL,
website text website text,
avatar text
); );
ALTER TABLE artists ADD CONSTRAINT artists_pk PRIMARY KEY (id); ALTER TABLE artist ADD CONSTRAINT artist_pk PRIMARY KEY (id);
-- --
-- Music releases -- Music releases
-- --
CREATE TABLE IF NOT EXISTS musicreleases ( CREATE TABLE musicrelease (
id character varying(64) NOT NULL, id character varying(64) NOT NULL,
title text NOT NULL, title text NOT NULL,
description text, description text,
@ -21,50 +22,56 @@ CREATE TABLE IF NOT EXISTS musicreleases (
buyname text, buyname text,
buylink text buylink text
); );
ALTER TABLE musicreleases ADD CONSTRAINT musicreleases_pk PRIMARY KEY (id); ALTER TABLE musicrelease ADD CONSTRAINT musicrelease_pk PRIMARY KEY (id);
-- --
-- Music links (external platform links under a release) -- Music links (external platform links under a release)
-- --
CREATE TABLE IF NOT EXISTS musiclinks ( CREATE TABLE musiclink (
release character varying(64) NOT NULL, release character varying(64) NOT NULL,
name text NOT NULL, name text NOT NULL,
url text url text NOT NULL
); );
ALTER TABLE musiclinks ADD CONSTRAINT musiclinks_pk PRIMARY KEY (release, name); ALTER TABLE musiclink ADD CONSTRAINT musiclink_pk PRIMARY KEY (release, name);
-- --
-- Music credits (artist credits under a release) -- Music credits (artist credits under a release)
-- --
CREATE TABLE IF NOT EXISTS musiccredits ( CREATE TABLE musiccredit (
release character varying(64) NOT NULL, release character varying(64) NOT NULL,
artist text NOT NULL, artist uuid NOT NULL,
role text, role text NOT NULL,
is_primary boolean is_primary boolean DEFAULT false
); );
ALTER TABLE musiccredits ADD CONSTRAINT musiccredits_pk PRIMARY KEY (release, artist); ALTER TABLE musiccredit ADD CONSTRAINT musiccredit_pk PRIMARY KEY (release, artist);
-- --
-- Music tracks (tracks under a release) -- Music tracks (tracks under a release)
-- --
CREATE TABLE IF NOT EXISTS musictracks ( CREATE TABLE musictrack (
release character varying(64) NOT NULL, id uuid DEFAULT gen_random_uuid(),
number integer NOT NULL,
title text NOT NULL, title text NOT NULL,
description text, description text,
lyrics text, lyrics text,
preview_url text preview_url text
); );
ALTER TABLE musictracks ADD CONSTRAINT musictracks_pk PRIMARY KEY (release, number); ALTER TABLE musictrack ADD CONSTRAINT musictrack_pk PRIMARY KEY (id);
--
-- Music release/track pairs
--
CREATE TABLE musicreleasetrack (
release character varying(64) NOT NULL,
track uuid NOT NULL,
number integer NOT NULL
);
ALTER TABLE musicreleasetrack ADD CONSTRAINT musictrack_pk PRIMARY KEY (release, track);
-- --
-- Foreign keys -- Foreign keys
-- --
ALTER TABLE musiccredit ADD CONSTRAINT musiccredit_artist_fk FOREIGN KEY (artist) REFERENCES artist(id) ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE musiccredits ADD CONSTRAINT IF NOT EXISTS musiccredits_artist_fk FOREIGN KEY (artist) REFERENCES artists(id) ON DELETE CASCADE ON UPDATE CASCADE; ALTER TABLE musiccredit ADD CONSTRAINT musiccredit_release_fk FOREIGN KEY (release) REFERENCES musicrelease(id) ON DELETE CASCADE;
ALTER TABLE musiclink ADD CONSTRAINT musiclink_release_fk FOREIGN KEY (release) REFERENCES musicrelease(id) ON UPDATE CASCADE ON DELETE CASCADE;
ALTER TABLE musiccredits ADD CONSTRAINT IF NOT EXISTS musiccredits_release_fk FOREIGN KEY (release) REFERENCES musicreleases(id) ON DELETE CASCADE; ALTER TABLE musicreleasetrack ADD CONSTRAINT music_pair_trackref_fk FOREIGN KEY (release) REFERENCES musicrelease(id) ON DELETE CASCADE;
ALTER TABLE musicreleasetrack ADD CONSTRAINT music_pair_releaseref_fk FOREIGN KEY (track) REFERENCES musictrack(id) ON DELETE CASCADE;
ALTER TABLE musiclinks ADD CONSTRAINT IF NOT EXISTS musiclinks_release_fk FOREIGN KEY (release) REFERENCES musicreleases(id) ON UPDATE CASCADE ON DELETE CASCADE;
ALTER TABLE musictracks ADD CONSTRAINT IF NOT EXISTS musictracks_release_fk FOREIGN KEY (release) REFERENCES musicreleases(id) ON DELETE CASCADE;

View file

@ -111,7 +111,7 @@
<h2>tracks:</h2> <h2>tracks:</h2>
{{range $i, $track := .GetTracks}} {{range $i, $track := .GetTracks}}
<details> <details>
<summary class="album-track-title">{{$i}}. {{$track.GetTitle}}</summary> <summary class="album-track-title">{{$track.GetNumber}}. {{$track.GetTitle}}</summary>
{{$track.GetLyrics}} {{$track.GetLyrics}}
</details> </details>
{{end}} {{end}}

View file

@ -31,7 +31,11 @@
<img src="{{$Release.GetArtwork}}" alt="{{$Release.GetTitle}} artwork" width="128" loading="lazy"> <img src="{{$Release.GetArtwork}}" alt="{{$Release.GetTitle}} artwork" width="128" loading="lazy">
</div> </div>
<div class="music-details"> <div class="music-details">
<a href="/music/{{$Release.GetID}}"><h1 class="music-title">{{$Release.GetTitle}}</h1></a> <h1 class="music-title">
<a href="/music/{{$Release.GetID}}">
{{$Release.GetTitle}}
</a>
</h1>
<h2 class="music-artist">{{$Release.PrintArtists true true}}</h2> <h2 class="music-artist">{{$Release.PrintArtists true true}}</h2>
<h3 class="music-type-{{$Release.GetType}}">{{$Release.GetType}}</h3> <h3 class="music-type-{{$Release.GetType}}">{{$Release.GetType}}</h3>
<ul class="music-links"> <ul class="music-links">