HOLY REFACTOR GOOD GRIEF (also finally started some CRUD work)

Signed-off-by: ari melody <ari@arimelody.me>
This commit is contained in:
ari melody 2024-08-02 22:48:26 +01:00
parent 1c310c9101
commit 442889340c
80 changed files with 1571 additions and 1330 deletions

View file

@ -7,13 +7,13 @@ tmp_dir = "tmp"
bin = "./tmp/main" bin = "./tmp/main"
cmd = "go build -o ./tmp/main ." cmd = "go build -o ./tmp/main ."
delay = 1000 delay = 1000
exclude_dir = ["assets", "tmp", "vendor", "testdata"] exclude_dir = ["admin\\static", "public", "uploads"]
exclude_file = [] exclude_file = []
exclude_regex = ["_test.go"] exclude_regex = ["_test.go"]
exclude_unchanged = false exclude_unchanged = false
follow_symlink = false follow_symlink = false
full_bin = "" full_bin = ""
include_dir = [".", "admin", "colour", "db", "discord", "global", "music", "views"] include_dir = []
include_ext = ["go", "tpl", "tmpl"] include_ext = ["go", "tpl", "tmpl"]
include_file = [] include_file = []
kill_delay = "0s" kill_delay = "0s"

2
.gitignore vendored
View file

@ -2,4 +2,4 @@
.idea/ .idea/
tmp/ tmp/
test/ test/
data/* uploads/*

View file

@ -9,9 +9,9 @@ import (
type ( type (
Session struct { Session struct {
UserID string Token string
Token string UserID string
Expires int64 Expires time.Time
} }
) )
@ -28,11 +28,11 @@ var ADMIN_ID_DISCORD = func() string {
var sessions []*Session var sessions []*Session
func createSession(UserID string) Session { func createSession(username string, expires time.Time) Session {
return Session{ return Session{
UserID: UserID,
Token: string(generateToken()), Token: string(generateToken()),
Expires: time.Now().Add(24 * time.Hour).Unix(), UserID: username,
Expires: expires,
} }
} }

View file

@ -12,71 +12,106 @@ import (
"arimelody.me/arimelody.me/discord" "arimelody.me/arimelody.me/discord"
"arimelody.me/arimelody.me/global" "arimelody.me/arimelody.me/global"
musicModel "arimelody.me/arimelody.me/music/model"
) )
func Handler() http.Handler { func Handler() http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.Handle("/login", LoginHandler())
mux.Handle("/logout", MustAuthorise(LogoutHandler()))
mux.Handle("/static/", http.StripPrefix("/static", staticHandler()))
mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) if r.URL.Path != "/" {
w.Write([]byte("hello /admin!")) http.NotFound(w, r)
return
}
session := GetSession(r)
if session == nil {
http.Redirect(w, r, "/admin/login", http.StatusFound)
return
}
type IndexData struct {
Releases []musicModel.Release
Artists []musicModel.Artist
}
serveTemplate("index.html", IndexData{
Releases: global.Releases,
Artists: global.Artists,
}).ServeHTTP(w, r)
})) }))
mux.Handle("/callback", global.HTTPLog(OAuthCallbackHandler()))
mux.Handle("/login", global.HTTPLog(LoginHandler()))
mux.Handle("/verify", global.HTTPLog(MustAuthorise(VerifyHandler())))
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) {
auth := r.Header.Get("Authorization") session := GetSession(r)
if strings.HasPrefix(auth, "Bearer ") {
auth = auth[7:]
} else {
cookie, err := r.Cookie("token")
if err != nil {
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
return
}
auth = cookie.Value
}
var session *Session
for _, s := range sessions {
if s.Expires < time.Now().Unix() {
// expired session. remove it from the list!
new_sessions := []*Session{}
for _, ns := range sessions {
if ns.Token == s.Token {
continue
}
new_sessions = append(new_sessions, ns)
}
continue
}
if s.Token == auth {
session = s
break
}
}
if session == nil { if session == nil {
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
return return
} }
ctx := context.WithValue(r.Context(), "role", "admin") ctx := context.WithValue(r.Context(), "session", session)
next.ServeHTTP(w, r.WithContext(ctx)) next.ServeHTTP(w, r.WithContext(ctx))
}) })
} }
func GetSession(r *http.Request) *Session {
// TODO: remove later- this bypasses auth!
return &Session{}
var token = ""
// is the session token in context?
var ctx_session = r.Context().Value("session")
if ctx_session != nil {
token = ctx_session.(string)
}
// okay, is it in the auth header?
if token == "" {
if strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") {
token = r.Header.Get("Authorization")[7:]
}
}
// finally, is it in the cookie?
if token == "" {
cookie, err := r.Cookie("token")
if err != nil {
return nil
}
token = cookie.Value
}
var session *Session = nil
for _, s := range sessions {
if s.Expires.Before(time.Now()) {
// expired session. remove it from the list!
new_sessions := []*Session{}
for _, ns := range sessions {
if ns.Token == s.Token {
continue
}
new_sessions = append(new_sessions, ns)
}
sessions = new_sessions
continue
}
if s.Token == token {
session = s
break
}
}
return session
}
func LoginHandler() http.Handler { func LoginHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if ADMIN_ID_DISCORD == "" { if discord.CREDENTIALS_PROVIDED && ADMIN_ID_DISCORD == "" {
http.Error(w, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable) http.Error(w, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable)
return return
} }
@ -84,7 +119,7 @@ func LoginHandler() http.Handler {
code := r.URL.Query().Get("code") code := r.URL.Query().Get("code")
if code == "" { if code == "" {
http.Redirect(w, r, discord.REDIRECT_URI, http.StatusTemporaryRedirect) serveTemplate("login.html", discord.REDIRECT_URI).ServeHTTP(w, r)
return return
} }
@ -109,7 +144,7 @@ func LoginHandler() http.Handler {
} }
// login success! // login success!
session := createSession(discord_user.Username) session := createSession(discord_user.Username, time.Now().Add(24 * time.Hour))
sessions = append(sessions, &session) sessions = append(sessions, &session)
cookie := http.Cookie{} cookie := http.Cookie{}
@ -122,12 +157,25 @@ func LoginHandler() http.Handler {
http.SetCookie(w, &cookie) http.SetCookie(w, &cookie)
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
w.Write([]byte(session.Token)) w.Header().Add("Content-Type", "text/html")
w.Write([]byte(
"<!DOCTYPE html><html><head>"+
"<meta http-equiv=\"refresh\" content=\"5;url=/admin/\" />"+
"</head><body>"+
"Logged in successfully. "+
"You should be redirected to <a href=\"/admin/\">/admin/</a> in 5 seconds."+
"</body></html>"),
)
}) })
} }
func LogoutHandler() http.Handler { func LogoutHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.NotFound(w, r)
return
}
token := r.Context().Value("token").(string) token := r.Context().Value("token").(string)
if token == "" { if token == "" {
@ -145,31 +193,19 @@ func LogoutHandler() http.Handler {
}(token) }(token)
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
w.Write([]byte("OK")) w.Write([]byte(
"<meta http-equiv=\"refresh\" content=\"5;url=/\" />"+
"Logged out successfully. "+
"You should be redirected to <a href=\"/\">/</a> in 5 seconds."),
)
}) })
} }
func OAuthCallbackHandler() http.Handler { func serveTemplate(page string, data any) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
})
}
func VerifyHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// this is an authorised endpoint, so you *must* supply a valid token
// before accessing this route.
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
})
}
func ServeTemplate(page string, data any) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
lp_layout := filepath.Join("views", "layout.html") lp_layout := filepath.Join("views", "admin", "layout.html")
lp_header := filepath.Join("views", "header.html")
lp_footer := filepath.Join("views", "footer.html")
lp_prideflag := filepath.Join("views", "prideflag.html") lp_prideflag := filepath.Join("views", "prideflag.html")
fp := filepath.Join("views", filepath.Clean(page)) fp := filepath.Join("views", "admin", filepath.Clean(page))
info, err := os.Stat(fp) info, err := os.Stat(fp)
if err != nil { if err != nil {
@ -184,7 +220,7 @@ func ServeTemplate(page string, data any) http.Handler {
return return
} }
template, err := template.ParseFiles(lp_layout, lp_header, lp_footer, lp_prideflag, fp) template, err := template.ParseFiles(lp_layout, lp_prideflag, fp)
if err != nil { if err != nil {
fmt.Printf("Error parsing template files: %s\n", err) fmt.Printf("Error parsing template files: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)

187
admin/static/admin.css Normal file
View file

@ -0,0 +1,187 @@
@import url("/style/prideflag.css");
@import url("/font/inter/inter.css");
body {
width: 100%;
height: 100vh;
margin: 0;
padding: 0;
font-family: "Inter", sans-serif;
font-size: 16px;
color: #303030;
background: #f0f0f0;
}
header {
width: min(720px, calc(100% - 2em));
height: 2em;
margin: 1em auto;
display: flex;
flex-direction: row;
justify-content: center;
background: #f8f8f8;
border-radius: .5em;
border: 1px solid #808080;
}
header .icon {
height: 100%;
margin-right: 1em;
}
header a {
height: 100%;
width: auto;
margin: 0px;
padding: 0 1em;
display: flex;
line-height: 2em;
text-decoration: none;
color: inherit;
}
header a:hover {
background: #00000010;
}
main {
width: min(720px, calc(100% - 2em));
margin: 0 auto;
padding: 1em;
}
a {
color: inherit;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.card h2 {
margin: 0 0 .5em 0;
}
.card h3,
.card p {
margin: 0;
}
.release {
padding: 1em;
display: flex;
flex-direction: row;
gap: 1em;
border-radius: .5em;
background: #f8f8f8f8;
border: 1px solid #808080;
}
.release-artwork {
width: 96px;
display: flex;
justify-content: center;
align-items: center;
}
.release-artwork img {
width: 100%;
aspect-ratio: 1;
}
.latest-release .release-info {
width: 300px;
flex-direction: column;
}
.release-title small {
opacity: .75;
}
.release-links {
margin: .5em 0;
padding: 0;
display: flex;
flex-direction: row;
list-style: none;
flex-wrap: wrap;
gap: .5em;
}
.release-links li {
flex-grow: 1;
}
.release-links a {
padding: .5em;
display: block;
border-radius: .5em;
text-decoration: none;
color: #f0f0f0;
background: #303030;
text-align: center;
transition: color .1s, background .1s;
}
.release-links a:hover {
color: #303030;
background: #f0f0f0;
}
.release-actions {
margin-top: .5em;
}
.release-actions a {
margin-right: .3em;
padding: .3em .5em;
display: inline-block;
border-radius: .3em;
background: #e0e0e0;
transition: color .1s, background .1s;
}
.release-actions a:hover {
color: #303030;
background: #f0f0f0;
text-decoration: none;
}
.artist {
padding: .5em;
display: flex;
flex-direction: row;
align-items: center;
gap: .5em;
border-radius: .5em;
background: #f8f8f8f8;
border: 1px solid #808080;
}
.artist:hover {
text-decoration: hover;
}
.artist-avatar {
width: 32px;
height: 32px;
object-fit: cover;
border-radius: 100%;
}

0
admin/static/admin.js Normal file
View file

43
api/api.go Normal file
View file

@ -0,0 +1,43 @@
package api
import (
"net/http"
"arimelody.me/arimelody.me/admin"
music "arimelody.me/arimelody.me/music/view"
)
func Handler() http.Handler {
mux := http.NewServeMux()
mux.Handle("/v1/artist/", http.StripPrefix("/v1/artist", ServeArtist()))
mux.Handle("/v1/artist", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
ServeAllArtists().ServeHTTP(w, r)
return
case http.MethodPost:
admin.MustAuthorise(CreateArtist()).ServeHTTP(w, r)
return
default:
http.NotFound(w, r)
return
}
}))
mux.Handle("/v1/music/", http.StripPrefix("/v1/music", music.ServeRelease()))
mux.Handle("/v1/music", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
ServeCatalog().ServeHTTP(w, r)
return
case http.MethodPost:
admin.MustAuthorise(CreateRelease()).ServeHTTP(w, r)
return
default:
http.NotFound(w, r)
return
}
}))
return mux
}

130
api/artist.go Normal file
View file

@ -0,0 +1,130 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"arimelody.me/arimelody.me/global"
"arimelody.me/arimelody.me/music/model"
controller "arimelody.me/arimelody.me/music/controller"
)
func ServeAllArtists() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
type (
creditJSON struct {
Role string `json:"role"`
Primary bool `json:"primary"`
}
)
var artists = []model.Artist{}
for _, artist := range global.Artists {
artists = append(artists, model.Artist{
ID: artist.ID,
Name: artist.Name,
Website: artist.Website,
Avatar: artist.Avatar,
})
}
w.Header().Add("Content-Type", "application/json")
err := json.NewEncoder(w).Encode(artists)
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
})
}
func ServeArtist() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" {
ServeAllArtists().ServeHTTP(w, r)
return
}
type (
creditJSON struct {
Role string `json:"role"`
Primary bool `json:"primary"`
}
artistJSON struct {
model.Artist
Credits map[string]creditJSON `json:"credits"`
}
)
var res = artistJSON{}
res.ID = r.URL.Path[1:]
var artist = global.GetArtist(res.ID)
if artist == nil {
http.NotFound(w, r)
return
}
res.Name = artist.Name
res.Website = artist.Website
res.Credits = make(map[string]creditJSON)
for _, release := range global.Releases {
for _, credit := range release.Credits {
if credit.Artist.ID != res.ID {
continue
}
res.Credits[release.ID] = creditJSON{
Role: credit.Role,
Primary: credit.Primary,
}
}
}
w.Header().Add("Content-Type", "application/json")
err := json.NewEncoder(w).Encode(res)
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
})
}
func CreateArtist() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.NotFound(w, r)
return
}
var data model.Artist
err := json.NewDecoder(r.Body).Decode(&data)
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
if global.GetArtist(data.ID) != nil {
http.Error(w, fmt.Sprintf("Artist %s already exists", data.ID), http.StatusBadRequest)
return
}
var artist = model.Artist{
ID: data.ID,
Name: data.Name,
Website: data.Website,
Avatar: data.Avatar,
}
global.Artists = append(global.Artists, artist)
err = controller.CreateArtistDB(global.DB, &artist)
if err != nil {
fmt.Printf("Failed to create artist %s: %s\n", artist.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
w.Header().Add("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
err = json.NewEncoder(w).Encode(artist)
})
}

92
api/music.go Normal file
View file

@ -0,0 +1,92 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"time"
"arimelody.me/arimelody.me/admin"
"arimelody.me/arimelody.me/global"
"arimelody.me/arimelody.me/music/model"
controller "arimelody.me/arimelody.me/music/controller"
)
func ServeCatalog() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
releases := []model.Release{}
authorised := admin.GetSession(r) != nil
for _, release := range global.Releases {
if !release.IsReleased() && !authorised {
continue
}
releases = append(releases, release)
}
w.Header().Add("Content-Type", "application/json")
err := json.NewEncoder(w).Encode(releases)
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
})
}
func CreateRelease() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.NotFound(w, r)
return
}
type PostReleaseBody struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
ReleaseType model.ReleaseType `json:"type"`
ReleaseDate time.Time `json:"releaseDate"`
Artwork string `json:"artwork"`
Buyname string `json:"buyname"`
Buylink string `json:"buylink"`
}
var data PostReleaseBody
err := json.NewDecoder(r.Body).Decode(&data)
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
if global.GetRelease(data.ID) != nil {
http.Error(w, fmt.Sprintf("Release %s already exists", data.ID), http.StatusBadRequest)
return
}
var release = model.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: []model.Link{},
Credits: []model.Credit{},
Tracks: []model.Track{},
}
global.Releases = append([]model.Release{release}, global.Releases...)
err = controller.CreateReleaseDB(global.DB, &release)
if err != nil {
fmt.Printf("Failed to create release %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
w.Header().Add("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
err = json.NewEncoder(w).Encode(release)
})
}

View file

@ -1,24 +0,0 @@
package db
import (
"fmt"
"os"
"time"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
)
func InitDatabase() *sqlx.DB {
db, err := sqlx.Connect("postgres", "user=arimelody dbname=arimelody password=fuckingpassword sslmode=disable")
if err != nil {
fmt.Fprintf(os.Stderr, "unable to create database connection pool: %v\n", err)
os.Exit(1)
}
db.SetConnMaxLifetime(time.Minute * 3)
db.SetMaxOpenConns(10)
db.SetMaxIdleConns(10)
return db
}

View file

@ -12,10 +12,12 @@ import (
const API_ENDPOINT = "https://discord.com/api/v10" const API_ENDPOINT = "https://discord.com/api/v10"
var CREDENTIALS_PROVIDED = true
var CLIENT_ID = func() string { var CLIENT_ID = func() string {
envvar := os.Getenv("DISCORD_CLIENT_ID") envvar := os.Getenv("DISCORD_CLIENT_ID")
if envvar == "" { if envvar == "" {
fmt.Printf("DISCORD_CLIENT_ID was not provided. Admin login will be unavailable.\n") fmt.Printf("DISCORD_CLIENT_ID was not provided. Admin login will be unavailable.\n")
CREDENTIALS_PROVIDED = false
} }
return envvar return envvar
}() }()
@ -23,6 +25,7 @@ var CLIENT_SECRET = func() string {
envvar := os.Getenv("DISCORD_CLIENT_SECRET") envvar := os.Getenv("DISCORD_CLIENT_SECRET")
if envvar == "" { if envvar == "" {
fmt.Printf("DISCORD_CLIENT_SECRET was not provided. Admin login will be unavailable.\n") fmt.Printf("DISCORD_CLIENT_SECRET was not provided. Admin login will be unavailable.\n")
CREDENTIALS_PROVIDED = false
} }
return envvar return envvar
}() }()
@ -30,6 +33,7 @@ var REDIRECT_URI = func() string {
envvar := os.Getenv("DISCORD_REDIRECT_URI") envvar := os.Getenv("DISCORD_REDIRECT_URI")
if envvar == "" { if envvar == "" {
fmt.Printf("DISCORD_REDIRECT_URI was not provided. Admin login will be unavailable.\n") fmt.Printf("DISCORD_REDIRECT_URI was not provided. Admin login will be unavailable.\n")
CREDENTIALS_PROVIDED = false
} }
return envvar return envvar
}() }()
@ -37,14 +41,15 @@ var OAUTH_CALLBACK_URI = func() string {
envvar := os.Getenv("OAUTH_CALLBACK_URI") envvar := os.Getenv("OAUTH_CALLBACK_URI")
if envvar == "" { if envvar == "" {
fmt.Printf("OAUTH_CALLBACK_URI was not provided. Admin login will be unavailable.\n") fmt.Printf("OAUTH_CALLBACK_URI was not provided. Admin login will be unavailable.\n")
CREDENTIALS_PROVIDED = false
} }
return envvar return envvar
}() }()
type ( type (
AccessTokenResponse struct { AccessTokenResponse struct {
TokenType string `json:"token_type"`
AccessToken string `json:"access_token"` AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"` ExpiresIn int `json:"expires_in"`
RefreshToken string `json:"refresh_token"` RefreshToken string `json:"refresh_token"`
Scope string `json:"scope"` Scope string `json:"scope"`
@ -52,27 +57,27 @@ type (
AuthInfoResponse struct { AuthInfoResponse struct {
Application struct { Application struct {
Id string Id string `json:"id"`
Name string Name string `json:"name"`
Icon string Icon string `json:"icon"`
Description string Description string `json:"description"`
Hook bool Hook bool `json:"hook"`
BotPublic bool BotPublic bool `json:"bot_public"`
botRequireCodeGrant bool BotRequireCodeGrant bool `json:"bot_require_code_grant"`
VerifyKey bool VerifyKey string `json:"verify_key"`
} } `json:"application"`
Scopes []string Scopes []string `json:"scopes"`
Expires string Expires string `json:"expires"`
User DiscordUser User DiscordUser `json:"user"`
} }
DiscordUser struct { DiscordUser struct {
Id string Id string `json:"id"`
Username string Username string `json:"username"`
Avatar string Avatar string `json:"avatar"`
Discriminator string Discriminator string `json:"discriminator"`
GlobalName string GlobalName string `json:"global_name"`
PublicFlags int PublicFlags int `json:"public_flags"`
} }
) )

40
global/data.go Normal file
View file

@ -0,0 +1,40 @@
package global
import (
"os"
"arimelody.me/arimelody.me/music/model"
"github.com/jmoiron/sqlx"
)
var HTTP_DOMAIN = func() string {
envvar := os.Getenv("HTTP_DOMAIN")
if envvar != "" {
return envvar
}
return "https://arimelody.me"
}
var DB *sqlx.DB
var Releases []model.Release
var Artists []model.Artist
var Tracks []model.Track
func GetRelease(id string) *model.Release {
for _, release := range Releases {
if release.ID == id {
return &release
}
}
return nil
}
func GetArtist(id string) *model.Artist {
for _, artist := range Artists {
if artist.ID == id {
return &artist
}
}
return nil
}

View file

@ -16,6 +16,7 @@ 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) {
w.Header().Add("Server", "arimelody.me") w.Header().Add("Server", "arimelody.me")
w.Header().Add("Cache-Control", "max-age=2592000") w.Header().Add("Cache-Control", "max-age=2592000")
next.ServeHTTP(w, r)
}) })
} }

57
main.go
View file

@ -6,61 +6,70 @@ import (
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"time"
"arimelody.me/arimelody.me/admin" "arimelody.me/arimelody.me/admin"
"arimelody.me/arimelody.me/music" "arimelody.me/arimelody.me/api"
"arimelody.me/arimelody.me/db"
"arimelody.me/arimelody.me/global" "arimelody.me/arimelody.me/global"
musicController "arimelody.me/arimelody.me/music/controller"
musicView "arimelody.me/arimelody.me/music/view"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
) )
const DEFAULT_PORT int = 8080 const DEFAULT_PORT int = 8080
func main() { func main() {
db := db.InitDatabase() // initialise database connection
defer db.Close()
var err error var err error
music.Artists, err = music.PullAllArtists(db) global.DB, err = sqlx.Connect("postgres", "user=arimelody dbname=arimelody password=fuckingpassword sslmode=disable")
if err != nil {
fmt.Fprintf(os.Stderr, "unable to create database connection pool: %v\n", err)
os.Exit(1)
}
global.DB.SetConnMaxLifetime(time.Minute * 3)
global.DB.SetMaxOpenConns(10)
global.DB.SetMaxIdleConns(10)
defer global.DB.Close()
// pull artist data from DB
global.Artists, err = musicController.PullAllArtists(global.DB)
if err != nil { if err != nil {
fmt.Printf("Failed to pull artists from database: %v\n", err); fmt.Printf("Failed to pull artists from database: %v\n", err);
panic(1) panic(1)
} }
fmt.Printf("%d artists loaded successfully.\n", len(music.Artists)) fmt.Printf("%d artists loaded successfully.\n", len(global.Artists))
music.Releases, err = music.PullAllReleases(db) // pull release data from DB
global.Releases, err = musicController.PullAllReleases(global.DB)
if err != nil { if err != nil {
fmt.Printf("Failed to pull releases from database: %v\n", err); fmt.Printf("Failed to pull releases from database: %v\n", err);
panic(1) panic(1)
} }
fmt.Printf("%d releases loaded successfully.\n", len(music.Releases)) fmt.Printf("%d releases loaded successfully.\n", len(global.Releases))
// start the web server!
mux := createServeMux() mux := createServeMux()
port := DEFAULT_PORT port := DEFAULT_PORT
fmt.Printf("now serving at http://127.0.0.1:%d\n", port) fmt.Printf("now serving at http://127.0.0.1:%d\n", port)
log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", port), mux)) log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", port), global.HTTPLog(mux)))
} }
func createServeMux() *http.ServeMux { func createServeMux() *http.ServeMux {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.Handle("/admin/", global.HTTPLog(http.StripPrefix("/admin", admin.Handler()))) mux.Handle("/admin/", http.StripPrefix("/admin", admin.Handler()))
mux.Handle("/api/", http.StripPrefix("/api", api.Handler()))
mux.Handle("/api/v1/music/artist/", global.HTTPLog(http.StripPrefix("/api/v1/music/artist", music.ServeArtist()))) mux.Handle("/music/", http.StripPrefix("/music", musicView.Handler()))
mux.Handle("/api/v1/music/", global.HTTPLog(http.StripPrefix("/api/v1/music", music.ServeRelease()))) mux.Handle("/uploads/", http.StripPrefix("/uploads", staticHandler("uploads")))
mux.Handle("/api/v1/music", global.HTTPLog(music.PostRelease())) mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("/music-artwork/", global.HTTPLog(http.StripPrefix("/music-artwork", music.ServeArtwork())))
mux.Handle("/music/", global.HTTPLog(http.StripPrefix("/music", music.ServeGateway())))
mux.Handle("/music", global.HTTPLog(music.ServeCatalog()))
mux.Handle("/", global.HTTPLog(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" || r.URL.Path == "/index.html" { if r.URL.Path == "/" || r.URL.Path == "/index.html" {
global.ServeTemplate("index.html", nil).ServeHTTP(w, r) global.ServeTemplate("index.html", nil).ServeHTTP(w, r)
return return
} }
staticHandler("public").ServeHTTP(w, r) staticHandler("public").ServeHTTP(w, r)
}))) }))
return mux return mux
} }
@ -82,6 +91,6 @@ func staticHandler(directory string) http.Handler {
return return
} }
http.FileServer(http.Dir("./public")).ServeHTTP(w, r) http.FileServer(http.Dir(directory)).ServeHTTP(w, r)
}) })
} }

View file

@ -1,155 +0,0 @@
package music
import (
"encoding/json"
"net/http"
"github.com/jmoiron/sqlx"
)
type Artist struct {
ID string `json:"id"`
Name string `json:"name"`
Website string `json:"website"`
}
var Artists []Artist
func GetArtist(id string) *Artist {
for _, artist := range Artists {
if artist.GetID() == id {
return &artist
}
}
return nil
}
// GETTERS
func (artist Artist) GetID() string {
return artist.ID
}
func (artist Artist) GetName() string {
return artist.Name
}
func (artist Artist) GetWebsite() string {
return artist.Website
}
// SETTERS
func (artist Artist) SetID(id string) error {
artist.ID = id
return nil
}
func (artist Artist) SetName(name string) error {
artist.Name = name
return nil
}
func (artist Artist) SetWebsite(website string) error {
artist.Website = website
return nil
}
// DATABASE
func (artist Artist) PushToDB(db *sqlx.DB) {
// fmt.Printf("Pushing artist [%s] to database...", artist.Name)
db.MustExec(
"INSERT INTO artists (id, name, website) "+
"VALUES ($1, $2, $3) "+
"ON CONFLICT (id) "+
"DO UPDATE SET name=$2, website=$3",
artist.ID,
artist.Name,
artist.Website,
)
// fmt.Printf("done!\n")
}
func PullAllArtists(db *sqlx.DB) ([]Artist, error) {
artists := []Artist{}
rows, err := db.Query("SELECT id, name, website FROM artists")
if err != nil {
return nil, err
}
for rows.Next() {
var artist = Artist{}
err = rows.Scan(
&artist.ID,
&artist.Name,
&artist.Website,
)
if err != nil {
return nil, err
}
artists = append(artists, artist)
}
return artists, nil
}
// HTTP HANDLERS
func ServeArtist() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" {
http.NotFound(w, r)
return
}
type (
creditJSON struct {
Role string `json:"role"`
Primary bool `json:"primary"`
}
artistJSON struct {
Artist
Credits map[string]creditJSON `json:"credits"`
}
)
var res = artistJSON{}
res.ID = r.URL.Path[1:]
var artist = GetArtist(res.ID)
if artist == nil {
http.NotFound(w, r)
return
}
res.Name = artist.Name
res.Website = artist.Website
res.Credits = make(map[string]creditJSON)
for _, release := range Releases {
for _, credit := range release.Credits {
if credit.Artist.ID != res.ID {
continue
}
res.Credits[release.ID] = creditJSON{
Role: credit.Role,
Primary: credit.Primary,
}
}
}
jsonBytes, err := json.Marshal(res)
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
w.Header().Add("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write(jsonBytes)
})
}

View file

@ -0,0 +1,65 @@
package music
import (
"arimelody.me/arimelody.me/music/model"
"github.com/jmoiron/sqlx"
)
// DATABASE
func PullAllArtists(db *sqlx.DB) ([]model.Artist, error) {
var artists = []model.Artist{}
err := db.Select(&artists, "SELECT * FROM artist")
if err != nil {
return nil, err
}
return artists, nil
}
func CreateArtistDB(db *sqlx.DB, artist *model.Artist) error {
_, err := db.Exec(
"INSERT INTO artist (id, name, website, avatar) "+
"VALUES ($1, $2, $3, $4)",
artist.ID,
artist.Name,
artist.Website,
artist.Avatar,
)
if err != nil {
return err
}
return nil
}
func UpdateArtistDB(db *sqlx.DB, artist *model.Artist) error {
_, err := db.Exec(
"UPDATE artist "+
"SET name=$2, website=$3, avatar=$4 "+
"WHERE id=$1",
artist.ID,
artist.Name,
artist.Website,
artist.Avatar,
)
if err != nil {
return err
}
return nil
}
func DeleteArtistDB(db *sqlx.DB, artistID string) error {
_, err := db.Exec(
"DELETE FROM artist "+
"WHERE id=$1",
artistID,
)
if err != nil {
return err
}
return nil
}

View file

@ -0,0 +1,70 @@
package music
import (
"arimelody.me/arimelody.me/music/model"
"github.com/jmoiron/sqlx"
)
// DATABASE
func PullReleaseCredits(db *sqlx.DB, releaseID string) ([]model.Credit, error) {
var credits = []model.Credit{}
err := db.Select(
&credits,
"SELECT * FROM musiccredit WHERE release=$1",
releaseID,
)
if err != nil {
return nil, err
}
return credits, nil
}
func CreateCreditDB(db *sqlx.DB, releaseID string, artistID string, credit *model.Credit) (error) {
_, err := db.Exec(
"INSERT INTO musiccredit (release, artist, role, is_primary) "+
"VALUES ($1, $2, $3, $4)",
releaseID,
artistID,
credit.Role,
credit.Primary,
)
if err != nil {
return err
}
return nil
}
func UpdateCreditDB(db *sqlx.DB, releaseID string, artistID string, credit *model.Credit) (error) {
_, err := db.Exec(
"UPDATE musiccredit SET "+
"role=$3, is_primary=$4 "+
"WHERE release=$1, artist=$2",
releaseID,
artistID,
credit.Role,
credit.Primary,
)
if err != nil {
return err
}
return nil
}
func DeleteCreditDB(db *sqlx.DB, releaseID string, artistID string) (error) {
_, err := db.Exec(
"DELETE FROM musiccredit "+
"WHERE release=$1, artist=$2",
releaseID,
artistID,
)
if err != nil {
return err
}
return nil
}

68
music/controller/link.go Normal file
View file

@ -0,0 +1,68 @@
package music
import (
"arimelody.me/arimelody.me/music/model"
"github.com/jmoiron/sqlx"
)
// DATABASE
func PullReleaseLinks(db *sqlx.DB, releaseID string) ([]model.Link, error) {
var links = []model.Link{}
err := db.Select(
&links,
"SELECT * FROM musiclink WHERE release=$1",
releaseID,
)
if err != nil {
return nil, err
}
return links, nil
}
func CreateLinkDB(db *sqlx.DB, releaseID string, link *model.Link) (error) {
_, err := db.Exec(
"INSERT INTO musiclink (release, name, url) "+
"VALUES ($1, $2, $3)",
releaseID,
link.Name,
link.URL,
)
if err != nil {
return err
}
return nil
}
func UpdateLinkDB(db *sqlx.DB, releaseID string, link *model.Link) (error) {
_, err := db.Exec(
"UPDATE musiclink SET "+
"name=$2, url=$3 "+
"WHERE release=$1",
releaseID,
link.Name,
link.URL,
)
if err != nil {
return err
}
return nil
}
func DeleteLinkDB(db *sqlx.DB, releaseID string, link *model.Link) (error) {
_, err := db.Exec(
"DELETE FROM musiclink "+
"WHERE release=$1, name=$2",
releaseID,
link.Name,
)
if err != nil {
return err
}
return nil
}

View file

@ -0,0 +1,76 @@
package music
import (
"arimelody.me/arimelody.me/music/model"
"github.com/jmoiron/sqlx"
)
// DATABASE
func PullAllReleases(db *sqlx.DB) ([]model.Release, error) {
var releases = []model.Release{}
err := db.Select(&releases, "SELECT * FROM musicrelease ORDER BY release_date DESC")
if err != nil {
return nil, err
}
return releases, nil
}
func CreateReleaseDB(db *sqlx.DB, release *model.Release) error {
_, err := db.Exec(
"INSERT INTO musicrelease "+
"(id, visible, title, description, type, release_date, artwork, buyname, buylink) "+
"VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
release.ID,
release.Visible,
release.Title,
release.Description,
release.ReleaseType,
release.ReleaseDate.Format("2-Jan-2006"),
release.Artwork,
release.Buyname,
release.Buylink,
)
if err != nil {
return err
}
return nil
}
func UpdateReleaseDB(db *sqlx.DB, release *model.Release) error {
_, err := db.Exec(
"UPDATE musicrelease SET "+
"title=$2, description=$3, type=$4, release_date=$5, artwork=$6, buyname=$7, buylink=$8) "+
"VALUES ($2, $3, $4, $5, $6, $7, $8) "+
"WHERE id=$1",
release.ID,
release.Title,
release.Description,
release.ReleaseType,
release.ReleaseDate.Format("2-Jan-2006"),
release.Artwork,
release.Buyname,
release.Buylink,
)
if err != nil {
return err
}
return nil
}
func DeleteReleaseDB(db *sqlx.DB, release model.Release) error {
_, err := db.Exec(
"DELETE FROM musicrelease "+
"WHERE id=$1",
release.ID,
)
if err != nil {
return err
}
return nil
}

69
music/controller/track.go Normal file
View file

@ -0,0 +1,69 @@
package music
import (
"arimelody.me/arimelody.me/music/model"
"github.com/jmoiron/sqlx"
)
// DATABASE
func PullAllTracks(db *sqlx.DB) ([]model.Track, error) {
var tracks = []model.Track{}
err := db.Select(&tracks, "SELECT id, title, description, lyrics, preview_url FROM musictrack RETURNING id")
if err != nil {
return nil, err
}
return tracks, nil
}
func CreateTrackDB(db *sqlx.DB, track *model.Track) (string, error) {
var trackID string
err := db.QueryRow(
"INSERT INTO musictrack (title, description, lyrics, preview_url) "+
"VALUES ($1, $2, $3, $4) "+
"RETURNING id",
track.Title,
track.Description,
track.Lyrics,
track.PreviewURL,
).Scan(&trackID)
if err != nil {
return "", err
}
return trackID, nil
}
func UpdateTrackDB(db *sqlx.DB, track *model.Track) error {
_, err := db.Exec(
"UPDATE musictrack "+
"SET title=$2, description=$3, lyrics=$4, preview_url=$5 "+
"WHERE id=$1"+
"RETURNING id",
track.ID,
track.Title,
track.Description,
track.Lyrics,
track.PreviewURL,
)
if err != nil {
return err
}
return nil
}
func DeleteTrackDB(db *sqlx.DB, trackID string) error {
_, err := db.Exec(
"DELETE FROM musictrack "+
"WHERE id=$1",
trackID,
)
if err != nil {
return err
}
return nil
}

View file

@ -1,92 +0,0 @@
package music
import (
"fmt"
"github.com/jmoiron/sqlx"
)
type (
Credit struct {
Artist *Artist `json:"artist"`
Role string `json:"role"`
Primary bool `json:"primary"`
}
PostCreditBody struct {
Artist string `json:"artist"`
Role string `json:"role"`
Primary bool `json:"primary"`
}
)
// GETTERS
func (credit Credit) GetArtist() Artist {
return *credit.Artist
}
func (credit Credit) GetRole() string {
return credit.Role
}
func (credit Credit) IsPrimary() bool {
return credit.Primary
}
// SETTERS
func (credit Credit) SetArtist(artist *Artist) error {
// TODO: update DB
credit.Artist = artist
return nil
}
func (credit Credit) SetRole(role string) error {
// TODO: update DB
credit.Role = role
return nil
}
func (credit Credit) SetPrimary(primary bool) error {
// TODO: update DB
credit.Primary = primary
return nil
}
// DATABASE
func PullReleaseCredits(db *sqlx.DB, releaseID string) ([]Credit, error) {
var credits = []Credit{}
credit_rows, err := db.Query("SELECT artist, role, is_primary FROM musiccredits WHERE release=$1", releaseID)
if err != nil {
return []Credit{}, err
}
for credit_rows.Next() {
var artistID string
var credit = Credit{}
err = credit_rows.Scan(
&artistID,
&credit.Role,
&credit.Primary,
)
if err != nil {
fmt.Printf("Error while pulling credit for release %s: %s\n", releaseID, err)
continue
}
credit.Artist = GetArtist(artistID)
if credit.Artist == nil {
// this should absolutely not happen ever due to foreign key
// constraints, but it doesn't hurt to be sure!
fmt.Printf("Error while pulling credit for release %s: Artist %s does not exist\n", releaseID, artistID)
continue
}
credits = append(credits, credit)
}
return credits, nil
}

View file

@ -1,73 +0,0 @@
package music
import (
"fmt"
"regexp"
"strings"
"github.com/jmoiron/sqlx"
)
type Link struct {
Name string `json:"name"`
URL string `json:"url"`
}
// GETTERS
func (link Link) GetName() string {
return link.Name
}
func (link Link) GetURL() string {
return link.URL
}
// SETTERS
func (link Link) SetName(name string) error {
// TODO: update DB
link.Name = name
return nil
}
func (link Link) SetURL(url string) error {
// TODO: update DB
link.URL = url
return nil
}
// MISC
func (link Link) NormaliseName() string {
rgx := regexp.MustCompile(`[^a-z0-9]`)
return strings.ToLower(rgx.ReplaceAllString(link.Name, ""))
}
// DATABASE
func PullReleaseLinks(db *sqlx.DB, releaseID string) ([]Link, error) {
var links = []Link{}
link_rows, err := db.Query("SELECT name, url FROM musiclinks WHERE release=$1", releaseID);
if err != nil {
return []Link{}, err
}
for link_rows.Next() {
var link = Link{}
err = link_rows.Scan(
&link.Name,
&link.URL,
)
if err != nil {
fmt.Printf("Error while pulling link for release %s: %s\n", releaseID, err)
continue
}
links = append(links, link)
}
return links, nil
}

17
music/model/artist.go Normal file
View file

@ -0,0 +1,17 @@
package model
type (
Artist struct {
ID string `json:"id"`
Name string `json:"name"`
Website string `json:"website"`
Avatar string `json:"avatar"`
}
)
func (artist Artist) GetAvatar() string {
if artist.Avatar == "" {
return "/img/default-avatar.png"
}
return artist.Avatar
}

7
music/model/credit.go Normal file
View file

@ -0,0 +1,7 @@
package model
type Credit struct {
Artist *Artist `json:"artist"`
Role string `json:"role"`
Primary bool `json:"primary" db:"is_primary"`
}

16
music/model/link.go Normal file
View file

@ -0,0 +1,16 @@
package model
import (
"regexp"
"strings"
)
type Link struct {
Name string `json:"name"`
URL string `json:"url"`
}
func (link Link) NormaliseName() string {
rgx := regexp.MustCompile(`[^a-z0-9]`)
return strings.ToLower(rgx.ReplaceAllString(link.Name, ""))
}

109
music/model/release.go Normal file
View file

@ -0,0 +1,109 @@
package model
import (
"strings"
"time"
)
type (
ReleaseType string
Release struct {
ID string `json:"id"`
Visible bool `json:"visible"`
Title string `json:"title"`
Description string `json:"description"`
ReleaseType ReleaseType `json:"type" db:"type"`
ReleaseDate time.Time `json:"releaseDate" db:"release_date"`
Artwork string `json:"artwork"`
Buyname string `json:"buyname"`
Buylink string `json:"buylink"`
Links []Link `json:"links"`
Credits []Credit `json:"credits"`
Tracks []Track `json:"tracks"`
}
)
const (
Single ReleaseType = "Single"
Album ReleaseType = "Album"
EP ReleaseType = "EP"
Compilation ReleaseType = "Compilation"
)
// GETTERS
func (release Release) GetArtwork() string {
if release.Artwork == "" {
return "/img/default-cover-art.png"
}
return release.Artwork
}
func (release Release) PrintReleaseDate() string {
return release.ReleaseDate.Format("02 January 2006")
}
func (release Release) GetReleaseYear() int {
return release.ReleaseDate.Year()
}
func (release Release) IsSingle() bool {
return len(release.Tracks) == 1;
}
func (release Release) IsReleased() bool {
return release.ReleaseDate.Before(time.Now())
}
func (release Release) GetUniqueArtists(only_primary bool) []Artist {
var artists = []Artist{}
for _, credit := range release.Credits {
if only_primary && !credit.Primary {
continue
}
exists := false
for _, a := range artists {
if a.ID == credit.Artist.ID {
exists = true
break
}
}
if exists {
continue
}
artists = append(artists, *credit.Artist)
}
return artists
}
func (release Release) GetUniqueArtistNames(only_primary bool) []string {
var names = []string{}
for _, artist := range release.GetUniqueArtists(only_primary) {
names = append(names, artist.Name)
}
return names
}
func (release Release) PrintArtists(only_primary bool, ampersand bool) string {
names := release.GetUniqueArtistNames(only_primary)
if len(names) == 0 {
return "Unknown Artist"
} else if len(names) == 1 {
return names[0]
}
if ampersand {
res := strings.Join(names[:len(names)-1], ", ")
res += " & " + names[len(names)-1]
return res
} else {
return strings.Join(names[:], ", ")
}
}

9
music/model/track.go Normal file
View file

@ -0,0 +1,9 @@
package model
type Track struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Lyrics string `json:"lyrics"`
PreviewURL string `json:"previewURL" db:"preview_url"`
}

View file

@ -1,492 +0,0 @@
package music
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"arimelody.me/arimelody.me/admin"
"github.com/jmoiron/sqlx"
)
type ReleaseType string
const (
Single ReleaseType = "Single"
Album ReleaseType = "Album"
EP ReleaseType = "EP"
Compilation ReleaseType = "Compilation"
)
type (
Release 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 []Credit `json:"credits"`
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;
// GETTERS
func (release Release) GetID() string {
return release.ID
}
func (release Release) GetTitle() string {
return release.Title
}
func (release Release) GetDescription() string {
return release.Description
}
func (release Release) GetType() ReleaseType {
return release.ReleaseType
}
func (release Release) GetReleaseDate() time.Time {
return release.ReleaseDate
}
func (release Release) GetArtwork() string {
if release.Artwork == "" {
return "/img/default-cover-art.png"
}
return release.Artwork
}
func (release Release) GetBuyName() string {
return release.Buyname
}
func (release Release) GetBuyLink() string {
return release.Buylink
}
func (release Release) GetLinks() []Link {
return release.Links
}
func (release Release) GetCredits() []Credit {
return release.Credits
}
func (release Release) GetTracks() []Track {
return release.Tracks
}
// SETTERS
func (release Release) SetID(id string) error {
// TODO: update DB
release.ID = id
return nil
}
func (release Release) SetTitle(title string) error {
// TODO: update DB
release.Title = title
return nil
}
func (release Release) SetDescription(description string) error {
// TODO: update DB
release.Description = description
return nil
}
func (release Release) SetType(releaseType ReleaseType) error {
// TODO: update DB
release.ReleaseType = releaseType
return nil
}
func (release Release) SetReleaseDate(releaseDate time.Time) error {
// TODO: update DB
release.ReleaseDate = releaseDate
return nil
}
func (release Release) SetArtwork(artwork string) error {
// TODO: update DB
release.Artwork = artwork
return nil
}
func (release Release) SetBuyName(buyname string) error {
// TODO: update DB
release.Buyname = buyname
return nil
}
func (release Release) SetBuyLink(buylink string) error {
// TODO: update DB
release.Buylink = buylink
return nil
}
func (release Release) SetLinks(links []Link) error {
// TODO: update DB
release.Links = links
return nil
}
func (release Release) SetCredits(credits []Credit) error {
// TODO: update DB
release.Credits = credits
return nil
}
func (release Release) SetTracks(tracks []Track) error {
// TODO: update DB
release.Tracks = tracks
return nil
}
// MISC
func GetRelease(id string) *Release {
for _, release := range Releases {
if release.GetID() == id {
return &release
}
}
return nil
}
func (release Release) PrintReleaseDate() string {
return release.ReleaseDate.Format("02 January 2006")
}
func (release Release) GetReleaseYear() int {
return release.ReleaseDate.Year()
}
func (release Release) IsSingle() bool {
return len(release.Tracks) == 1;
}
func (release Release) IsReleased() bool {
return release.ReleaseDate.Before(time.Now())
}
func (release Release) GetUniqueArtists(only_primary bool) []Artist {
var artists = []Artist{}
for _, credit := range release.Credits {
if only_primary && !credit.Primary {
continue
}
exists := false
for _, a := range artists {
if a.ID == credit.Artist.ID {
exists = true
break
}
}
if exists {
continue
}
artists = append(artists, *credit.Artist)
}
return artists
}
func (release Release) GetUniqueArtistNames(only_primary bool) []string {
var names = []string{}
for _, artist := range release.GetUniqueArtists(only_primary) {
names = append(names, artist.GetName())
}
return names
}
func (release Release) PrintArtists(only_primary bool, ampersand bool) string {
names := release.GetUniqueArtistNames(only_primary)
if len(names) == 0 {
return "Unknown Artist"
} else if len(names) == 1 {
return names[0]
}
if ampersand {
res := strings.Join(names[:len(names)-1], ", ")
res += " & " + names[len(names)-1]
return res
} else {
return strings.Join(names[:], ", ")
}
}
// DATABASE
func (release Release) PushToDB(db *sqlx.DB) error {
// fmt.Printf("Pushing release [%s] to database...", release.ID)
tx, err := db.Begin()
if err != nil {
return errors.New(fmt.Sprintf("Failed to initiate transaction: %s", err))
}
_, err = tx.Exec(
"INSERT INTO musicreleases (id, title, description, type, release_date, artwork, buyname, buylink) "+
"VALUES ($1, $2, $3, $4, $5, $6, $7, $8) "+
"ON CONFLICT (id) "+
"DO UPDATE SET title=$2, description=$3, type=$4, release_date=$5, artwork=$6, buyname=$7, buylink=$8",
release.ID,
release.Title,
release.Description,
release.ReleaseType,
release.ReleaseDate.Format("2-Jan-2006"),
release.Artwork,
release.Buyname,
release.Buylink,
)
for _, link := range release.Links {
_, err = tx.Exec(
"INSERT INTO musiclinks (release, name, url) "+
"VALUES ($1, $2, $3) "+
"ON CONFLICT (release, name) "+
"DO UPDATE SET url=$3 ",
release.ID,
link.Name,
link.URL,
)
if err != nil {
return errors.New(fmt.Sprintf("Failed to add music link to transaction: %s", err))
}
}
for _, credit := range release.Credits {
_, err = tx.Exec(
"INSERT INTO musiccredits (release, artist, role, is_primary) "+
"VALUES ($1, $2, $3, $4) "+
"ON CONFLICT (release, artist) "+
"DO UPDATE SET role=$3, is_primary=$4",
release.ID,
credit.Artist.ID,
credit.Role,
credit.Primary,
)
if err != nil {
return errors.New(fmt.Sprintf("Failed to add music credit to transaction: %s", err))
}
}
for _, track := range release.Tracks {
_, err = tx.Exec(
"INSERT INTO musictracks (release, number, title, description, lyrics, preview_url) "+
"VALUES ($1, $2, $3, $4, $5, $6) "+
"ON CONFLICT (release, number) "+
"DO UPDATE SET title=$3, description=$4, lyrics=$5, preview_url=$6",
release.ID,
track.Number,
track.Title,
track.Description,
track.Lyrics,
track.PreviewURL,
)
if err != nil {
return errors.New(fmt.Sprintf("Failed to add music track to transaction: %s", err))
}
}
err = tx.Commit()
if err != nil {
return errors.New(fmt.Sprintf("Failed to commit transaction: %s", err))
}
// fmt.Printf("done!\n")
return nil
}
func (release Release) DeleteFromDB(db *sqlx.DB) error {
// this probably doesn't need to be a transaction;
// i just felt like making it one
tx, err := db.Begin()
if err != nil {
return errors.New(fmt.Sprintf("Failed to initiate transaction: %s", err))
}
_, err = tx.Exec("DELETE FROM musicreleases WHERE id=$1", release.ID)
err = tx.Commit()
if err != nil {
return errors.New(fmt.Sprintf("Failed to commit transaction: %s", err))
}
return nil
}
func PullAllReleases(db *sqlx.DB) ([]Release, error) {
releases := []Release{}
rows, err := db.Query("SELECT id, title, description, type, release_date, artwork, buyname, buylink FROM musicreleases ORDER BY release_date DESC")
if err != nil {
return nil, err
}
for rows.Next() {
var release = Release{}
err = rows.Scan(
&release.ID,
&release.Title,
&release.Description,
&release.ReleaseType,
&release.ReleaseDate,
&release.Artwork,
&release.Buyname,
&release.Buylink,
)
if err != nil {
fmt.Printf("Error while pulling a release: %s\n", err)
continue
}
release.Credits, err = PullReleaseCredits(db, release.ID)
if err != nil {
fmt.Printf("Failed to pull credits for %s: %v\n", release.ID, err)
}
release.Links, err = PullReleaseLinks(db, release.ID)
if err != nil {
fmt.Printf("Failed to pull links for %s: %v\n", release.ID, err)
}
release.Tracks, err = PullReleaseTracks(db, release.ID)
if err != nil {
return nil, errors.New(fmt.Sprintf("error pulling tracks for %s: %v\n", release.ID, err))
}
releases = append(releases, release)
}
return releases, nil
}
// HTTP HANDLERS
func ServeRelease() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" {
http.NotFound(w, r)
return
}
releaseID := r.URL.Path[1:]
var release = GetRelease(releaseID)
if release == nil {
http.NotFound(w, r)
return
}
// only allow authorised users to view unreleased releases
authorised := r.Context().Value("role") != nil && r.Context().Value("role") == "admin"
if !release.IsReleased() && !authorised {
admin.MustAuthorise(ServeRelease()).ServeHTTP(w, r)
return
}
jsonBytes, err := json.Marshal(release)
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
w.Header().Add("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write(jsonBytes)
})
}
func PostRelease() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.NotFound(w, r)
return
}
var data PostReleaseBody
err := json.NewDecoder(r.Body).Decode(&data)
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
if GetRelease(data.ID) != nil {
http.Error(w, fmt.Sprintf("Release %s already exists", data.ID), http.StatusBadRequest)
return
}
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)
w.Header().Add("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
w.Write(jsonBytes)
})
}

View file

@ -1,100 +0,0 @@
package music
import (
"fmt"
"github.com/jmoiron/sqlx"
)
type Track struct {
Number int `json:"number"`
Title string `json:"title"`
Description string `json:"description"`
Lyrics string `json:"lyrics"`
PreviewURL string `json:"previewURL"`
}
// GETTERS
func (track Track) GetNumber() int {
return track.Number
}
func (track Track) GetTitle() string {
return track.Title
}
func (track Track) GetDescription() string {
return track.Description
}
func (track Track) GetLyrics() string {
return track.Lyrics
}
func (track Track) GetPreviewURL() string {
return track.PreviewURL
}
// SETTERS
func (track Track) SetNumber(number int) error {
// TODO: update DB
track.Number = number
return nil
}
func (track Track) SetTitle(title string) error {
// TODO: update DB
track.Title = title
return nil
}
func (track Track) SetDescription(description string) error {
// TODO: update DB
track.Description = description
return nil
}
func (track Track) SetLyrics(lyrics string) error {
// TODO: update DB
track.Lyrics = lyrics
return nil
}
func (track Track) SetPreviewURL(previewURL string) error {
// TODO: update DB
track.PreviewURL = previewURL
return nil
}
// DATABASE
func PullReleaseTracks(db *sqlx.DB, releaseID string) ([]Track, error) {
var tracks = []Track{}
track_rows, err := db.Query("SELECT number, title, description, lyrics, preview_url FROM musictracks WHERE release=$1", releaseID)
if err != nil {
return []Track{}, err
}
for track_rows.Next() {
var track = Track{}
err = track_rows.Scan(
&track.Number,
&track.Title,
&track.Description,
&track.Lyrics,
&track.PreviewURL,
)
if err != nil {
fmt.Printf("Error while pulling track for release %s: %s\n", releaseID, err)
continue
}
tracks = append(tracks, track)
}
return tracks, nil
}

View file

@ -1,65 +1,45 @@
package music package view
import ( import (
"fmt"
"net/http" "net/http"
"os"
"path/filepath"
"strings"
"arimelody.me/arimelody.me/admin" "arimelody.me/arimelody.me/admin"
"arimelody.me/arimelody.me/global" "arimelody.me/arimelody.me/global"
"arimelody.me/arimelody.me/music/model"
) )
// HTTP HANDLER METHODS // HTTP HANDLER METHODS
func Handler() http.Handler {
mux := http.NewServeMux()
mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" {
ServeCatalog().ServeHTTP(w, r)
return
}
ServeGateway().ServeHTTP(w, r)
}))
return mux
}
func ServeCatalog() http.Handler { func ServeCatalog() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
releases := []Release{} releases := []model.Release{}
authorised := r.Context().Value("role") != nil && r.Context().Value("role") == "admin" authorised := admin.GetSession(r) != nil
for _, release := range Releases { for _, release := range global.Releases {
if !release.IsReleased() && !authorised { if !release.IsReleased() && !authorised {
continue continue
} }
releases = append(releases, release) releases = append(releases, release)
} }
global.ServeTemplate("music.html", Releases).ServeHTTP(w, r) global.ServeTemplate("music.html", releases).ServeHTTP(w, r)
})
}
func ServeGateway() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" {
http.Redirect(w, r, "/music", http.StatusPermanentRedirect)
return
}
id := r.URL.Path[1:]
release := GetRelease(id)
if release == nil {
http.NotFound(w, r)
return
}
// only allow authorised users to view unreleased releases
authorised := r.Context().Value("role") != nil && r.Context().Value("role") == "admin"
if !release.IsReleased() && !authorised {
admin.MustAuthorise(ServeGateway()).ServeHTTP(w, r)
return
}
lrw := global.LoggingResponseWriter{w, http.StatusOK}
global.ServeTemplate("music-gateway.html", release).ServeHTTP(&lrw, r)
if lrw.Code != http.StatusOK {
fmt.Printf("Error loading music gateway for %s\n", id)
return
}
}) })
} }
/*
func ServeArtwork() http.Handler { func ServeArtwork() 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 == "/" { if r.URL.Path == "/" {
@ -111,3 +91,4 @@ func ServeArtwork() http.Handler {
w.Write(bytes) w.Write(bytes)
}) })
} }
*/

74
music/view/release.go Normal file
View file

@ -0,0 +1,74 @@
package view
import (
"encoding/json"
"fmt"
"net/http"
"arimelody.me/arimelody.me/admin"
"arimelody.me/arimelody.me/global"
)
// HTTP HANDLERS
func ServeRelease() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" {
http.NotFound(w, r)
return
}
releaseID := r.URL.Path[1:]
var release = global.GetRelease(releaseID)
if release == nil {
http.NotFound(w, r)
return
}
// only allow authorised users to view unreleased releases
authorised := admin.GetSession(r) != nil
if !release.IsReleased() && !authorised {
admin.MustAuthorise(ServeRelease()).ServeHTTP(w, r)
return
}
w.Header().Add("Content-Type", "application/json")
err := json.NewEncoder(w).Encode(release)
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
})
}
func ServeGateway() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" {
http.Redirect(w, r, "/music", http.StatusPermanentRedirect)
return
}
id := r.URL.Path[1:]
release := global.GetRelease(id)
if release == nil {
http.NotFound(w, r)
return
}
// only allow authorised users to view unreleased releases
authorised := admin.GetSession(r) != nil
if !release.IsReleased() && !authorised {
admin.MustAuthorise(ServeGateway()).ServeHTTP(w, r)
return
}
lrw := global.LoggingResponseWriter{w, http.StatusOK}
global.ServeTemplate("music-gateway.html", release).ServeHTTP(&lrw, r)
if lrw.Code != http.StatusOK {
fmt.Printf("Error rendering music gateway for %s\n", id)
return
}
})
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,92 @@
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION AND CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View file

@ -0,0 +1,57 @@
/* Variable fonts usage:
:root { font-family: "Inter", sans-serif; }
@supports (font-variation-settings: normal) {
:root { font-family: "InterVariable", sans-serif; font-optical-sizing: auto; }
} */
@font-face {
font-family: InterVariable;
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url("InterVariable.woff2") format("woff2");
}
@font-face {
font-family: InterVariable;
font-style: italic;
font-weight: 100 900;
font-display: swap;
src: url("InterVariable-Italic.woff2") format("woff2");
}
/* static fonts */
@font-face { font-family: "Inter"; font-style: normal; font-weight: 100; font-display: swap; src: url("Inter-Thin.woff2") format("woff2"); }
@font-face { font-family: "Inter"; font-style: italic; font-weight: 100; font-display: swap; src: url("Inter-ThinItalic.woff2") format("woff2"); }
@font-face { font-family: "Inter"; font-style: normal; font-weight: 200; font-display: swap; src: url("Inter-ExtraLight.woff2") format("woff2"); }
@font-face { font-family: "Inter"; font-style: italic; font-weight: 200; font-display: swap; src: url("Inter-ExtraLightItalic.woff2") format("woff2"); }
@font-face { font-family: "Inter"; font-style: normal; font-weight: 300; font-display: swap; src: url("Inter-Light.woff2") format("woff2"); }
@font-face { font-family: "Inter"; font-style: italic; font-weight: 300; font-display: swap; src: url("Inter-LightItalic.woff2") format("woff2"); }
@font-face { font-family: "Inter"; font-style: normal; font-weight: 400; font-display: swap; src: url("Inter-Regular.woff2") format("woff2"); }
@font-face { font-family: "Inter"; font-style: italic; font-weight: 400; font-display: swap; src: url("Inter-Italic.woff2") format("woff2"); }
@font-face { font-family: "Inter"; font-style: normal; font-weight: 500; font-display: swap; src: url("Inter-Medium.woff2") format("woff2"); }
@font-face { font-family: "Inter"; font-style: italic; font-weight: 500; font-display: swap; src: url("Inter-MediumItalic.woff2") format("woff2"); }
@font-face { font-family: "Inter"; font-style: normal; font-weight: 600; font-display: swap; src: url("Inter-SemiBold.woff2") format("woff2"); }
@font-face { font-family: "Inter"; font-style: italic; font-weight: 600; font-display: swap; src: url("Inter-SemiBoldItalic.woff2") format("woff2"); }
@font-face { font-family: "Inter"; font-style: normal; font-weight: 700; font-display: swap; src: url("Inter-Bold.woff2") format("woff2"); }
@font-face { font-family: "Inter"; font-style: italic; font-weight: 700; font-display: swap; src: url("Inter-BoldItalic.woff2") format("woff2"); }
@font-face { font-family: "Inter"; font-style: normal; font-weight: 800; font-display: swap; src: url("Inter-ExtraBold.woff2") format("woff2"); }
@font-face { font-family: "Inter"; font-style: italic; font-weight: 800; font-display: swap; src: url("Inter-ExtraBoldItalic.woff2") format("woff2"); }
@font-face { font-family: "Inter"; font-style: normal; font-weight: 900; font-display: swap; src: url("Inter-Black.woff2") format("woff2"); }
@font-face { font-family: "Inter"; font-style: italic; font-weight: 900; font-display: swap; src: url("Inter-BlackItalic.woff2") format("woff2"); }
@font-face { font-family: "InterDisplay"; font-style: normal; font-weight: 100; font-display: swap; src: url("InterDisplay-Thin.woff2") format("woff2"); }
@font-face { font-family: "InterDisplay"; font-style: italic; font-weight: 100; font-display: swap; src: url("InterDisplay-ThinItalic.woff2") format("woff2"); }
@font-face { font-family: "InterDisplay"; font-style: normal; font-weight: 200; font-display: swap; src: url("InterDisplay-ExtraLight.woff2") format("woff2"); }
@font-face { font-family: "InterDisplay"; font-style: italic; font-weight: 200; font-display: swap; src: url("InterDisplay-ExtraLightItalic.woff2") format("woff2"); }
@font-face { font-family: "InterDisplay"; font-style: normal; font-weight: 300; font-display: swap; src: url("InterDisplay-Light.woff2") format("woff2"); }
@font-face { font-family: "InterDisplay"; font-style: italic; font-weight: 300; font-display: swap; src: url("InterDisplay-LightItalic.woff2") format("woff2"); }
@font-face { font-family: "InterDisplay"; font-style: normal; font-weight: 400; font-display: swap; src: url("InterDisplay-Regular.woff2") format("woff2"); }
@font-face { font-family: "InterDisplay"; font-style: italic; font-weight: 400; font-display: swap; src: url("InterDisplay-Italic.woff2") format("woff2"); }
@font-face { font-family: "InterDisplay"; font-style: normal; font-weight: 500; font-display: swap; src: url("InterDisplay-Medium.woff2") format("woff2"); }
@font-face { font-family: "InterDisplay"; font-style: italic; font-weight: 500; font-display: swap; src: url("InterDisplay-MediumItalic.woff2") format("woff2"); }
@font-face { font-family: "InterDisplay"; font-style: normal; font-weight: 600; font-display: swap; src: url("InterDisplay-SemiBold.woff2") format("woff2"); }
@font-face { font-family: "InterDisplay"; font-style: italic; font-weight: 600; font-display: swap; src: url("InterDisplay-SemiBoldItalic.woff2") format("woff2"); }
@font-face { font-family: "InterDisplay"; font-style: normal; font-weight: 700; font-display: swap; src: url("InterDisplay-Bold.woff2") format("woff2"); }
@font-face { font-family: "InterDisplay"; font-style: italic; font-weight: 700; font-display: swap; src: url("InterDisplay-BoldItalic.woff2") format("woff2"); }
@font-face { font-family: "InterDisplay"; font-style: normal; font-weight: 800; font-display: swap; src: url("InterDisplay-ExtraBold.woff2") format("woff2"); }
@font-face { font-family: "InterDisplay"; font-style: italic; font-weight: 800; font-display: swap; src: url("InterDisplay-ExtraBoldItalic.woff2") format("woff2"); }
@font-face { font-family: "InterDisplay"; font-style: normal; font-weight: 900; font-display: swap; src: url("InterDisplay-Black.woff2") format("woff2"); }
@font-face { font-family: "InterDisplay"; font-style: italic; font-weight: 900; font-display: swap; src: url("InterDisplay-BlackItalic.woff2") format("woff2"); }

View file

@ -1,152 +0,0 @@
@import url("/style/main.css");
main {
width: min(calc(100% - 4rem), 720px);
min-height: calc(100vh - 10.3rem);
margin: 0 auto 2rem auto;
padding-top: 4rem;
}
main h1 {
line-height: 3rem;
color: var(--primary);
}
main h2 {
color: var(--secondary);
}
main h3 {
color: var(--tertiary);
}
div#me_irl {
width: fit-content;
height: fit-content;
border: 2px solid white;
}
div#me_irl img {
display: block;
}
div#me_irl::before {
content: "";
position: absolute;
width: 104px;
height: 104px;
transform: translate(2px, 2px);
background-image: linear-gradient(to top right,
var(--primary),
var(--secondary));
z-index: -1;
}
h1,
h2,
h3,
h4,
h5,
h6,
p,
small,
blockquote {
transition: background-color 0.1s;
}
h1 a,
h2 a,
h3 a,
h4 a,
h5 a,
h6 a {
color: inherit;
}
h1 a:hover,
h2 a:hover,
h3 a:hover,
h4 a:hover,
h5 a:hover,
h6 a:hover {
text-decoration: none;
}
main h1:hover,
main h2:hover,
main h3:hover,
main h4:hover,
main h5:hover,
main h6:hover,
main p:hover,
main small:hover,
main blockquote:hover {
background-color: #fff1;
}
blockquote {
margin: 1rem 0;
padding: 0 2.5rem;
}
hr {
text-align: center;
line-height: 0px;
border-width: 1px 0 0 0;
border-color: #888f;
margin: 1.5em 0;
overflow: visible;
}
ul.links {
display: flex;
gap: 1em .5em;
flex-wrap: wrap;
}
ul.links li {
list-style: none;
}
ul.links li a {
padding: .2em .5em;
border: 1px solid var(--links);
color: var(--links);
border-radius: 2px;
background-color: transparent;
transition-property: color, border-color, background-color;
transition-duration: .2s;
animation-delay: 0s;
animation: list-item-fadein .2s forwards;
opacity: 0;
}
ul.links li a:hover {
color: #eee;
border-color: #eee;
background-color: var(--links) !important;
text-decoration: none;
box-shadow: 0 0 1em var(--links);
}
div#web-buttons {
margin: 2rem 0;
}
#web-buttons a {
text-decoration: none;
}
#web-buttons img {
image-rendering: auto;
image-rendering: crisp-edges;
image-rendering: pixelated;
}
#web-buttons img:hover {
margin: -1px;
border: 1px solid #eee;
transform: translate(-2px, -2px);
box-shadow: 1px 1px 0 #eee, 2px 2px 0 #eee;
}

View file

@ -1,19 +1,20 @@
-- --
-- Artists (should be applicable to all art) -- Artists (should be applicable to all art)
-- --
CREATE TABLE artist ( CREATE TABLE public.artist (
id uuid DEFAULT gen_random_uuid(), id character varying(64) DEFAULT gen_random_uuid(),
name text NOT NULL, name text NOT NULL,
website text, website text,
avatar text avatar text
); );
ALTER TABLE artist ADD CONSTRAINT artist_pk PRIMARY KEY (id); ALTER TABLE public.artist ADD CONSTRAINT artist_pk PRIMARY KEY (id);
-- --
-- Music releases -- Music releases
-- --
CREATE TABLE musicrelease ( CREATE TABLE public.musicrelease (
id character varying(64) NOT NULL, id character varying(64) NOT NULL,
visible bool DEFAULT false,
title text NOT NULL, title text NOT NULL,
description text, description text,
type text, type text,
@ -22,56 +23,56 @@ CREATE TABLE musicrelease (
buyname text, buyname text,
buylink text buylink text
); );
ALTER TABLE musicrelease ADD CONSTRAINT musicrelease_pk PRIMARY KEY (id); ALTER TABLE public.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 musiclink ( CREATE TABLE public.musiclink (
release character varying(64) NOT NULL, release character varying(64) NOT NULL,
name text NOT NULL, name text NOT NULL,
url text NOT NULL url text NOT NULL
); );
ALTER TABLE musiclink ADD CONSTRAINT musiclink_pk PRIMARY KEY (release, name); ALTER TABLE public.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 musiccredit ( CREATE TABLE public.musiccredit (
release character varying(64) NOT NULL, release character varying(64) NOT NULL,
artist uuid NOT NULL, artist character varying(64) NOT NULL,
role text NOT NULL, role text NOT NULL,
is_primary boolean DEFAULT false is_primary boolean DEFAULT false
); );
ALTER TABLE musiccredit ADD CONSTRAINT musiccredit_pk PRIMARY KEY (release, artist); ALTER TABLE public.musiccredit ADD CONSTRAINT musiccredit_pk PRIMARY KEY (release, artist);
-- --
-- Music tracks (tracks under a release) -- Music tracks (tracks under a release)
-- --
CREATE TABLE musictrack ( CREATE TABLE public.musictrack (
id uuid DEFAULT gen_random_uuid(), id uuid DEFAULT gen_random_uuid(),
title text NOT NULL, title text NOT NULL,
description text, description text,
lyrics text, lyrics text,
preview_url text preview_url text
); );
ALTER TABLE musictrack ADD CONSTRAINT musictrack_pk PRIMARY KEY (id); ALTER TABLE public.musictrack ADD CONSTRAINT musictrack_pk PRIMARY KEY (id);
-- --
-- Music release/track pairs -- Music release/track pairs
-- --
CREATE TABLE musicreleasetrack ( CREATE TABLE public.musicreleasetrack (
release character varying(64) NOT NULL, release character varying(64) NOT NULL,
track uuid NOT NULL, track uuid NOT NULL,
number integer NOT NULL number integer NOT NULL
); );
ALTER TABLE musicreleasetrack ADD CONSTRAINT musictrack_pk PRIMARY KEY (release, track); ALTER TABLE public.musicreleasetrack ADD CONSTRAINT musicreleasetrack_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 public.musiccredit ADD CONSTRAINT musiccredit_artist_fk FOREIGN KEY (artist) REFERENCES public.artist(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 public.musiccredit ADD CONSTRAINT musiccredit_release_fk FOREIGN KEY (release) REFERENCES public.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 public.musiclink ADD CONSTRAINT musiclink_release_fk FOREIGN KEY (release) REFERENCES public.musicrelease(id) ON UPDATE CASCADE ON DELETE CASCADE;
ALTER TABLE musicreleasetrack ADD CONSTRAINT music_pair_trackref_fk FOREIGN KEY (release) REFERENCES musicrelease(id) ON DELETE CASCADE; ALTER TABLE public.musicreleasetrack ADD CONSTRAINT music_pair_trackref_fk FOREIGN KEY (release) REFERENCES public.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 public.musicreleasetrack ADD CONSTRAINT music_pair_releaseref_fk FOREIGN KEY (track) REFERENCES public.musictrack(id) ON DELETE CASCADE;

View file

@ -1,22 +0,0 @@
{{define "head"}}
<title>admin - ari melody 💫</title>
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<link rel="stylesheet" href="/style/admin.css">
{{end}}
{{define "content"}}
<main>
<script type="module" src="/script/admin.js" defer></script>
<h1>
# admin panel
</h1>
<p>
whapow! nothing here.
<br>
nice try, though.
</p>
</main>
{{end}}

55
views/admin/index.html Normal file
View file

@ -0,0 +1,55 @@
{{define "head"}}
<title>admin - ari melody 💫</title>
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
{{end}}
{{define "content"}}
<header>
<img src="/img/favicon.png" alt="" class="icon">
<a href="/admin">home</a>
<a href="/admin/releases">releases</a>
<a href="/admin/artists">artists</a>
</header>
<main>
<h1>Releases</h1>
<div class="card releases">
{{range $Release := .Releases}}
<div class="release">
<div class="release-artwork">
<img src="{{$Release.Artwork}}" alt="" width="128" loading="lazy">
</div>
<div class="release-info">
<h3 class="release-title">{{$Release.Title}} <small>{{$Release.GetReleaseYear}}</small></h3>
<p class="release-artists">{{$Release.PrintArtists true true}}</p>
<p class="release-type-single">{{$Release.ReleaseType}}</p>
<div class="release-actions">
<a href="/admin/releases/{{$Release.ID}}">Edit</a>
<a href="/music/{{$Release.ID}}" target="_blank">Gateway</a>
</div>
</div>
</div>
{{end}}
{{if not .Releases}}
<p>There are no releases.</p>
{{end}}
</div>
<h1>Artists</h1>
<div class="card artists">
{{range $Artist := .Artists}}
<div class="artist">
<img src="https://arimelody.me/img/favicon.png" alt="" width="64" loading="lazy" class="artist-avatar">
<a href="/admin/artists/arimelody" class="artist-name">ari melody</a>
</div>
{{end}}
{{if not .Artists}}
<p>There are no artists.</p>
{{end}}
</div>
</main>
<script type="module" src="/admin/static/admin.js" defer></script>
{{end}}

22
views/admin/layout.html Normal file
View file

@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{{block "head" .}}{{end}}
<link rel="stylesheet" href="/admin/static/admin.css">
</head>
<body>
{{block "content" .}}
{{end}}
{{template "prideflag"}}
</body>
</html>

20
views/admin/login.html Normal file
View file

@ -0,0 +1,20 @@
{{define "head"}}
<title>admin - ari melody 💫</title>
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<style>
.discord {
color: #5865F2;
}
</style>
{{end}}
{{define "content"}}
<main>
<p>Log in with <a href="{{.}}" class="discord">Discord</a>.</p>
</main>
<script type="module" src="/admin/static/admin.js" defer></script>
{{end}}

View file

@ -1,28 +1,28 @@
{{define "head"}} {{define "head"}}
<title>{{.GetTitle}} - {{.PrintArtists true true}}</title> <title>{{.Title}} - {{.PrintArtists true true}}</title>
<link rel="icon" type="image/png" href="{{.GetArtwork}}"> <link rel="icon" type="image/png" href="{{.GetArtwork}}">
<meta name="description" content="Stream &quot;{{.GetTitle}}&quot; by {{.PrintArtists true true}} on all platforms!"> <meta name="description" content="Stream &quot;{{.Title}}&quot; by {{.PrintArtists true true}} on all platforms!">
<meta name="author" content="{{.PrintArtists true true}}"> <meta name="author" content="{{.PrintArtists true true}}">
<meta name="keywords" content="{{.PrintArtists true false}}, music, {{.GetTitle}}, {{.GetID}}, {{.GetReleaseYear}}"> <meta name="keywords" content="{{.PrintArtists true false}}, music, {{.Title}}, {{.ID}}, {{.GetReleaseYear}}">
<meta property="og:url" content="https://arimelody.me/music/{{.GetID}}"> <meta property="og:url" content="https://arimelody.me/music/{{.ID}}">
<meta property="og:type" content="website"> <meta property="og:type" content="website">
<meta property="og:locale" content="en_IE"> <meta property="og:locale" content="en_IE">
<meta property="og:site_name" content="ari melody music"> <meta property="og:site_name" content="ari melody music">
<meta property="og.Title" content="{{.GetTitle}} - {{.PrintArtists true true}}"> <meta property="og.Title" content="{{.Title}} - {{.PrintArtists true true}}">
<meta property="og:description" content="Stream &quot;{{.GetTitle}}&quot; by {{.PrintArtists true true}} on all platforms!"> <meta property="og:description" content="Stream &quot;{{.Title}}&quot; by {{.PrintArtists true true}} on all platforms!">
<meta property="og:image" content="https://arimelody.me{{.GetArtwork}}"> <meta property="og:image" content="https://arimelody.me{{.GetArtwork}}">
<meta name="twitter:card" content="summary_large_image"> <meta name="twitter:card" content="summary_large_image">
<meta name="twitter:site" content="@funniduck"> <meta name="twitter:site" content="@funniduck">
<meta name="twitter:creator" content="@funniduck"> <meta name="twitter:creator" content="@funniduck">
<meta property="twitter:domain" content="arimelody.me"> <meta property="twitter:domain" content="arimelody.me">
<meta property="twitter:url" content="https://arimelody.me/music/{{.GetID}}"> <meta property="twitter:url" content="https://arimelody.me/music/{{.ID}}">
<meta name="twitter.Title" content="{{.PrintArtists true true}} - {{.GetTitle}}"> <meta name="twitter.Title" content="{{.PrintArtists true true}} - {{.Title}}">
<meta name="twitter:description" content="Stream &quot;{{.GetTitle}}&quot; by {{.PrintArtists true true}} on all platforms!"> <meta name="twitter:description" content="Stream &quot;{{.Title}}&quot; by {{.PrintArtists true true}} on all platforms!">
<meta name="twitter:image" content="https://arimelody.me{{.GetArtwork}}"> <meta name="twitter:image" content="https://arimelody.me{{.GetArtwork}}">
<meta name="twitter:image:alt" content="Cover art for &quot;{{.GetTitle}}&quot;"> <meta name="twitter:image:alt" content="Cover art for &quot;{{.Title}}&quot;">
<link rel="stylesheet" href="/style/main.css"> <link rel="stylesheet" href="/style/main.css">
<link rel="stylesheet" href="/style/music-gateway.css"> <link rel="stylesheet" href="/style/music-gateway.css">
@ -47,51 +47,51 @@
<div class="tilt-bottom"></div> <div class="tilt-bottom"></div>
<div class="tilt-bottomleft"></div> <div class="tilt-bottomleft"></div>
<div class="tilt-left"></div> <div class="tilt-left"></div>
<img id="artwork" src="{{.GetArtwork}}" alt="{{.GetTitle}} artwork" width=240 height=240> <img id="artwork" src="{{.GetArtwork}}" alt="{{.Title}} artwork" width=240 height=240>
</div> </div>
<div id="vertical-line"></div> <div id="vertical-line"></div>
<div id="info"> <div id="info">
<div id="overview"> <div id="overview">
<div id="title-container"> <div id="title-container">
<h1 id="title">{{.GetTitle}}</h1> <h1 id="title">{{.Title}}</h1>
<span id="year" title="{{.PrintReleaseDate}}">{{.GetReleaseYear}}</span> <span id="year" title="{{.PrintReleaseDate}}">{{.GetReleaseYear}}</span>
</div> </div>
<p id="artist">{{.PrintArtists true true}}</p> <p id="artist">{{.PrintArtists true true}}</p>
<p id="type" class="{{.GetType}}">{{.GetType}}</p> <p id="type" class="{{.ReleaseType}}">{{.ReleaseType}}</p>
<ul id="links"> <ul id="links">
{{if .GetBuyLink}} {{if .Buylink}}
<li> <li>
<a href="{{.GetBuyLink}}" class="buy">{{or .GetBuyName "buy"}}</a> <a href="{{.Buylink}}" class="buy">{{or .Buyname "buy"}}</a>
</li> </li>
{{end}} {{end}}
{{range .GetLinks}} {{range .Links}}
<li> <li>
<a class="{{.NormaliseName}}" href="{{.GetURL}}">{{.GetName}}</a> <a class="{{.NormaliseName}}" href="{{.URL}}">{{.Name}}</a>
</li> </li>
{{end}} {{end}}
</ul> </ul>
{{if .GetDescription}} {{if .Description}}
<p id="description"> <p id="description">
{{.GetDescription}} {{.Description}}
</p> </p>
{{end}} {{end}}
<button id="share">share</button> <button id="share">share</button>
</div> </div>
{{if .GetCredits}} {{if .Credits}}
<div id="credits"> <div id="credits">
<h2>credits:</h2> <h2>credits:</h2>
<ul> <ul>
{{range .GetCredits}} {{range .Credits}}
{{$Artist := .GetArtist}} {{$Artist := .Artist}}
{{if $Artist.GetWebsite}} {{if $Artist.Website}}
<li><strong><a href="{{$Artist.GetWebsite}}">{{$Artist.GetName}}</a></strong>: {{.GetRole}}</li> <li><strong><a href="{{$Artist.Website}}">{{$Artist.Name}}</a></strong>: {{.Role}}</li>
{{else}} {{else}}
<li><strong>{{$Artist.GetName}}</strong>: {{.GetRole}}</li> <li><strong>{{$Artist.Name}}</strong>: {{.Role}}</li>
{{end}} {{end}}
{{end}} {{end}}
</ul> </ul>
@ -99,20 +99,20 @@
{{end}} {{end}}
{{if .IsSingle}} {{if .IsSingle}}
{{$Track := index .GetTracks 0}} {{$Track := index .Tracks 0}}
{{if $Track.GetLyrics}} {{if $Track.Lyrics}}
<div id="lyrics"> <div id="lyrics">
<h2>lyrics:</h2> <h2>lyrics:</h2>
<p>{{$Track.GetLyrics}}</p> <p>{{$Track.Lyrics}}</p>
</div> </div>
{{end}} {{end}}
{{else}} {{else}}
<div id="tracks"> <div id="tracks">
<h2>tracks:</h2> <h2>tracks:</h2>
{{range $i, $track := .GetTracks}} {{range $i, $track := .Tracks}}
<details> <details>
<summary class="album-track-title">{{$track.GetNumber}}. {{$track.GetTitle}}</summary> <summary class="album-track-title">{{$track.Number}}. {{$track.Title}}</summary>
{{$track.GetLyrics}} {{$track.Lyrics}}
</details> </details>
{{end}} {{end}}
</div> </div>
@ -123,13 +123,13 @@
<ul> <ul>
<li><a href="#overview">overview</a></li> <li><a href="#overview">overview</a></li>
{{if .GetCredits}} {{if .Credits}}
<li><a href="#credits">credits</a></li> <li><a href="#credits">credits</a></li>
{{end}} {{end}}
{{if .IsSingle}} {{if .IsSingle}}
{{$Track := index .GetTracks 0}} {{$Track := index .Tracks 0}}
{{if $Track.GetLyrics}} {{if $Track.Lyrics}}
<li><a href="#lyrics">lyrics</a></li> <li><a href="#lyrics">lyrics</a></li>
{{end}} {{end}}
{{else}} {{else}}
@ -156,7 +156,7 @@
<!-- <% } else { %> --> <!-- <% } else { %> -->
<!-- <div class="track-preview" id="preview-<%= data.id %>"> --> <!-- <div class="track-preview" id="preview-<%= data.id %>"> -->
<!-- <i class="fa-solid fa-play play"></i> --> <!-- <i class="fa-solid fa-play play"></i> -->
<!-- <p>{{.GetTitle}}</p> --> <!-- <p>{{.Title}}</p> -->
<!-- <audio src="<%= file %>"></audio> --> <!-- <audio src="<%= file %>"></audio> -->
<!-- </div> --> <!-- </div> -->
<!-- <% } %> --> <!-- <% } %> -->

View file

@ -26,22 +26,22 @@
<div id="music-container"> <div id="music-container">
{{range $Release := .}} {{range $Release := .}}
<div class="music" id="{{$Release.GetID}}" swap-url="/music/{{$Release.GetID}}"> <div class="music" id="{{$Release.ID}}" swap-url="/music/{{$Release.ID}}">
<div class="music-artwork"> <div class="music-artwork">
<img src="{{$Release.GetArtwork}}" alt="{{$Release.GetTitle}} artwork" width="128" loading="lazy"> <img src="{{$Release.GetArtwork}}" alt="{{$Release.Title}} artwork" width="128" loading="lazy">
</div> </div>
<div class="music-details"> <div class="music-details">
<h1 class="music-title"> <h1 class="music-title">
<a href="/music/{{$Release.GetID}}"> <a href="/music/{{$Release.ID}}">
{{$Release.GetTitle}} {{$Release.Title}}
</a> </a>
</h1> </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.ReleaseType}}">{{$Release.ReleaseType}}</h3>
<ul class="music-links"> <ul class="music-links">
{{range $Link := $Release.GetLinks}} {{range $Link := $Release.Links}}
<li> <li>
<a href="{{$Link.GetURL}}" class="link-button">{{$Link.GetName}}</a> <a href="{{$Link.URL}}" class="link-button">{{$Link.Name}}</a>
</li> </li>
{{end}} {{end}}
</ul> </ul>

View file

@ -1,5 +1,5 @@
{{define "prideflag"}} {{define "prideflag"}}
<a href="https://github.com/mellodoot/prideflag" target="_blank" id="prideflag"> <a href="https://git.arimelody.me/ari/prideflag" target="_blank" id="prideflag">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" hx-preserve="true"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" hx-preserve="true">
<path id="red" d="M120,80 L100,100 L120,120 Z" style="fill:#d20605"/> <path id="red" d="M120,80 L100,100 L120,120 Z" style="fill:#d20605"/>
<path id="orange" d="M120,80 V40 L80,80 L100,100 Z" style="fill:#ef9c00"/> <path id="orange" d="M120,80 V40 L80,80 L100,100 Z" style="fill:#ef9c00"/>