Compare commits

..

7 commits
fullDB ... main

48 changed files with 1369 additions and 985 deletions

View file

@ -7,7 +7,7 @@ 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 = ["admin\\static", "public", "uploads"] exclude_dir = ["admin/static", "public", "uploads", "test", "db"]
exclude_file = [] exclude_file = []
exclude_regex = ["_test.go"] exclude_regex = ["_test.go"]
exclude_unchanged = false exclude_unchanged = false

11
.dockerignore Normal file
View file

@ -0,0 +1,11 @@
**/.DS_Store
.git/
.air.toml/
.gitattributes
.gitignore
uploads/*
test/
tmp/
docker-compose.yml
Dockerfile
schema.sql

2
.gitignore vendored
View file

@ -1,5 +1,7 @@
**/.DS_Store **/.DS_Store
.idea/ .idea/
db/
tmp/ tmp/
test/ test/
uploads/* uploads/*
docker-compose-test.yml

23
Dockerfile Normal file
View file

@ -0,0 +1,23 @@
FROM golang:1.22 AS build-stage
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /arimelody-web
# ---
FROM build-stage AS build-release-stage
WORKDIR /app
COPY --from=build-stage /arimelody-web /arimelody-web
COPY . .
EXPOSE 8080
CMD ["/arimelody-web"]

28
README.md Normal file
View file

@ -0,0 +1,28 @@
# arimelody.me
home to your local SPACEGIRL! 💫
---
built up from the initial [static](https://git.arimelody.me/ari/arimelody.me-static) branch, this powerful, server-side rendered version comes complete with live updates, powered by a new database and super handy admin panel!
the admin panel currently facilitates live updating of my music discography, though i plan to expand it towards art portfolio and blog posts in the future. if all goes well, i'd like to later separate these components into their own library for others to use in their own sites. exciting stuff!
## build
easy! just `git clone` this repo and `go build` from the root. `arimelody-web(.exe)` should be generated.
## running
the webserver depends on some environment variables (don't worry about forgetting some; it'll be sure to bug you about them):
- `HTTP_DOMAIN`: the domain the webserver will use for generating oauth redirect URIs (default `https://arimelody.me`)
- `DISCORD_ADMIN`[^1]: the user ID of your discord account (discord auth is intended to be temporary, and will be replaced with its own auth system later)
- `DISCORD_CLIENT`[^1]: the client ID of your discord OAuth application.
- `DISCORD_SECRET`[^1]: the client secret of your discord OAuth application.
[^1]: not required, but the admin panel will be **disabled** if these are not provided.
the webserver requires a database to run. in this case, postgres.
the [docker compose script](docker-compose.yml) contains the basic requirements to get you up and running, though it does not currently initialise the schema on first run. you'll need to `docker compose exec -it arimelody.me-db-1` to access the database container while it's running, run `psql -U arimelody` to get a postgres shell, and copy/paste the contents of [schema.sql](schema.sql) to initialise the database. i'll build an automated initialisation script later ;p

View file

@ -3,9 +3,10 @@ package admin
import ( import (
"fmt" "fmt"
"math/rand" "math/rand"
"os"
"time" "time"
"arimelody.me/arimelody.me/global" "arimelody-web/global"
) )
type ( type (
@ -28,9 +29,9 @@ var ADMIN_BYPASS = func() bool {
}() }()
var ADMIN_ID_DISCORD = func() string { var ADMIN_ID_DISCORD = func() string {
id := global.Args["discordAdmin"] id := os.Getenv("DISCORD_ADMIN")
if id == "" { if id == "" {
fmt.Printf("WARN: Discord admin ID (-discordAdmin) was not provided. Admin login will be unavailable.\n") fmt.Printf("WARN: Discord admin ID (DISCORD_ADMIN) was not provided. Admin login will be unavailable.\n")
} }
return id return id
}() }()

47
admin/artisthttp.go Normal file
View file

@ -0,0 +1,47 @@
package admin
import (
"fmt"
"net/http"
"strings"
"arimelody-web/global"
"arimelody-web/music/model"
"arimelody-web/music/controller"
)
func serveArtist() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
slices := strings.Split(r.URL.Path[1:], "/")
id := slices[0]
artist, err := music.GetArtist(global.DB, id)
if err != nil {
if artist == nil {
http.NotFound(w, r)
return
}
fmt.Printf("Error rendering admin artist page for %s: %s\n", id, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
credits, err := music.GetArtistCredits(global.DB, artist.ID, true)
if err != nil {
fmt.Printf("Error rendering admin track page for %s: %s\n", id, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
type Artist struct {
*model.Artist
Credits []*model.Credit
}
err = pages["artist"].Execute(w, Artist{ Artist: artist, Credits: credits })
if err != nil {
fmt.Printf("Error rendering admin track page for %s: %s\n", id, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
}

View file

@ -61,6 +61,7 @@
el.remove(); el.remove();
}); });
el.draggable = true;
el.addEventListener("dragstart", () => { el.classList.add("moving") }); el.addEventListener("dragstart", () => { el.classList.add("moving") });
el.addEventListener("dragend", () => { el.classList.remove("moving") }); el.addEventListener("dragend", () => { el.classList.remove("moving") });
} }

View file

@ -1,7 +1,7 @@
{{define "release"}} {{define "release"}}
<div class="release"> <div class="release">
<div class="release-artwork"> <div class="release-artwork">
<img src="{{.Artwork}}" alt="" width="128" loading="lazy"> <img src="{{.GetArtwork}}" alt="" width="128" loading="lazy">
</div> </div>
<div class="release-info"> <div class="release-info">
<h3 class="release-title"> <h3 class="release-title">

View file

@ -3,17 +3,16 @@ package admin
import ( import (
"context" "context"
"fmt" "fmt"
"html/template"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
"arimelody.me/arimelody.me/discord" "arimelody-web/discord"
"arimelody.me/arimelody.me/global" "arimelody-web/global"
musicModel "arimelody.me/arimelody.me/music/model" musicDB "arimelody-web/music/controller"
musicDB "arimelody.me/arimelody.me/music/controller" musicModel "arimelody-web/music/model"
) )
type loginData struct { type loginData struct {
@ -28,6 +27,7 @@ func Handler() http.Handler {
mux.Handle("/logout", MustAuthorise(LogoutHandler())) mux.Handle("/logout", MustAuthorise(LogoutHandler()))
mux.Handle("/static/", http.StripPrefix("/static", staticHandler())) mux.Handle("/static/", http.StripPrefix("/static", staticHandler()))
mux.Handle("/release/", MustAuthorise(http.StripPrefix("/release", serveRelease()))) mux.Handle("/release/", MustAuthorise(http.StripPrefix("/release", serveRelease())))
mux.Handle("/artist/", MustAuthorise(http.StripPrefix("/artist", serveArtist())))
mux.Handle("/track/", MustAuthorise(http.StripPrefix("/track", serveTrack()))) mux.Handle("/track/", MustAuthorise(http.StripPrefix("/track", serveTrack())))
mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" { if r.URL.Path != "/" {
@ -41,30 +41,12 @@ func Handler() http.Handler {
return return
} }
type ( releases, err := musicDB.GetAllReleases(global.DB, false, 0, true)
IndexData struct {
Releases []musicModel.FullRelease
Artists []*musicModel.Artist
Tracks []musicModel.DisplayTrack
}
)
dbReleases, err := musicDB.GetAllReleases(global.DB)
if err != nil { if err != nil {
fmt.Printf("FATAL: Failed to pull releases: %s\n", err) fmt.Printf("FATAL: Failed to pull releases: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
releases := []musicModel.FullRelease{}
for _, release := range dbReleases {
fullRelease, err := musicDB.GetFullRelease(global.DB, release)
if err != nil {
fmt.Printf("FATAL: Failed to pull full release data for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
releases = append(releases, *fullRelease)
}
artists, err := musicDB.GetAllArtists(global.DB) artists, err := musicDB.GetAllArtists(global.DB)
if err != nil { if err != nil {
@ -73,19 +55,17 @@ func Handler() http.Handler {
return return
} }
dbTracks, err := musicDB.GetOrphanTracks(global.DB) tracks, err := musicDB.GetOrphanTracks(global.DB)
if err != nil { if err != nil {
fmt.Printf("FATAL: Failed to pull orphan tracks: %s\n", err) fmt.Printf("FATAL: Failed to pull orphan tracks: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
var tracks = []musicModel.DisplayTrack{} type IndexData struct {
for _, track := range dbTracks { Releases []*musicModel.Release
tracks = append(tracks, musicModel.DisplayTrack{ Artists []*musicModel.Artist
Track: track, Tracks []*musicModel.Track
Lyrics: template.HTML(strings.Replace(track.Lyrics, "\n", "<br>", -1)),
})
} }
err = pages["index"].Execute(w, IndexData{ err = pages["index"].Execute(w, IndexData{
@ -125,8 +105,9 @@ func GetSession(r *http.Request) *Session {
// is the session token in context? // is the session token in context?
var ctx_session = r.Context().Value("session") var ctx_session = r.Context().Value("session")
if ctx_session != nil { if ctx_session != nil {
token = ctx_session.(string) token = ctx_session.(*Session).Token
} }
// okay, is it in the auth header? // okay, is it in the auth header?
if token == "" { if token == "" {
if strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") { if strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") {
@ -168,11 +149,15 @@ func GetSession(r *http.Request) *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 discord.CREDENTIALS_PROVIDED && 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
} }
fmt.Println(discord.CLIENT_ID)
fmt.Println(discord.API_ENDPOINT)
fmt.Println(discord.REDIRECT_URI)
code := r.URL.Query().Get("code") code := r.URL.Query().Get("code")
if code == "" { if code == "" {
@ -209,8 +194,9 @@ func LoginHandler() http.Handler {
cookie.Name = "token" cookie.Name = "token"
cookie.Value = session.Token cookie.Value = session.Token
cookie.Expires = time.Now().Add(24 * time.Hour) cookie.Expires = time.Now().Add(24 * time.Hour)
// TODO: uncomment this probably that might be nice i think if strings.HasPrefix(global.HTTP_DOMAIN, "https") {
// cookie.Secure = true cookie.Secure = true
}
cookie.HttpOnly = true cookie.HttpOnly = true
cookie.Path = "/" cookie.Path = "/"
http.SetCookie(w, &cookie) http.SetCookie(w, &cookie)

View file

@ -5,25 +5,26 @@ import (
"net/http" "net/http"
"strings" "strings"
"arimelody.me/arimelody.me/global" "arimelody-web/global"
"arimelody.me/arimelody.me/music/model" db "arimelody-web/music/controller"
controller "arimelody.me/arimelody.me/music/controller" "arimelody-web/music/model"
) )
func serveRelease() http.Handler { func serveRelease() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
slices := strings.Split(r.URL.Path[1:], "/") slices := strings.Split(r.URL.Path[1:], "/")
releaseID := slices[0] releaseID := slices[0]
release, err := controller.GetRelease(global.DB, releaseID)
release, err := db.GetRelease(global.DB, releaseID, true)
if err != nil { if err != nil {
fmt.Printf("FATAL: Failed to pull release %s: %s\n", releaseID, err) if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r)
return
}
fmt.Printf("FATAL: Failed to pull full release data for %s: %s\n", releaseID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
if release == nil {
http.NotFound(w, r)
return
}
authorised := GetSession(r) != nil authorised := GetSession(r) != nil
if !authorised && !release.Visible { if !authorised && !release.Visible {
@ -31,32 +32,25 @@ func serveRelease() http.Handler {
return return
} }
fullRelease, err := controller.GetFullRelease(global.DB, release)
if err != nil {
fmt.Printf("FATAL: Failed to pull full release data for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if len(slices) > 1 { if len(slices) > 1 {
switch slices[1] { switch slices[1] {
case "editcredits": case "editcredits":
serveEditCredits(fullRelease).ServeHTTP(w, r) serveEditCredits(release).ServeHTTP(w, r)
return return
case "addcredit": case "addcredit":
serveAddCredit(fullRelease).ServeHTTP(w, r) serveAddCredit(release).ServeHTTP(w, r)
return return
case "newcredit": case "newcredit":
serveNewCredit().ServeHTTP(w, r) serveNewCredit().ServeHTTP(w, r)
return return
case "editlinks": case "editlinks":
serveEditLinks(fullRelease).ServeHTTP(w, r) serveEditLinks(release).ServeHTTP(w, r)
return return
case "edittracks": case "edittracks":
serveEditTracks(fullRelease).ServeHTTP(w, r) serveEditTracks(release).ServeHTTP(w, r)
return return
case "addtrack": case "addtrack":
serveAddTrack(fullRelease).ServeHTTP(w, r) serveAddTrack(release).ServeHTTP(w, r)
return return
case "newtrack": case "newtrack":
serveNewTrack().ServeHTTP(w, r) serveNewTrack().ServeHTTP(w, r)
@ -66,7 +60,7 @@ func serveRelease() http.Handler {
return return
} }
err = pages["release"].Execute(w, fullRelease) err = pages["release"].Execute(w, release)
if err != nil { if err != nil {
fmt.Printf("Error rendering admin release page for %s: %s\n", release.ID, err) fmt.Printf("Error rendering admin release page for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -74,7 +68,7 @@ func serveRelease() http.Handler {
}) })
} }
func serveEditCredits(release *model.FullRelease) http.Handler { func serveEditCredits(release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html") w.Header().Set("Content-Type", "text/html")
err := components["editcredits"].Execute(w, release) err := components["editcredits"].Execute(w, release)
@ -85,9 +79,9 @@ func serveEditCredits(release *model.FullRelease) http.Handler {
}) })
} }
func serveAddCredit(release *model.FullRelease) http.Handler { func serveAddCredit(release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
artists, err := controller.GetArtistsNotOnRelease(global.DB, release.Release) artists, err := db.GetArtistsNotOnRelease(global.DB, release.ID)
if err != nil { if err != nil {
fmt.Printf("FATAL: Failed to pull artists not on %s: %s\n", release.ID, err) fmt.Printf("FATAL: Failed to pull artists not on %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -114,7 +108,7 @@ func serveAddCredit(release *model.FullRelease) http.Handler {
func serveNewCredit() http.Handler { func serveNewCredit() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
artistID := strings.Split(r.URL.Path, "/")[3] artistID := strings.Split(r.URL.Path, "/")[3]
artist, err := controller.GetArtist(global.DB, artistID) artist, err := db.GetArtist(global.DB, artistID)
if err != nil { if err != nil {
fmt.Printf("FATAL: Failed to pull artists %s: %s\n", artistID, err) fmt.Printf("FATAL: Failed to pull artists %s: %s\n", artistID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -134,7 +128,7 @@ func serveNewCredit() http.Handler {
}) })
} }
func serveEditLinks(release *model.FullRelease) http.Handler { func serveEditLinks(release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html") w.Header().Set("Content-Type", "text/html")
err := components["editlinks"].Execute(w, release) err := components["editlinks"].Execute(w, release)
@ -145,7 +139,7 @@ func serveEditLinks(release *model.FullRelease) http.Handler {
}) })
} }
func serveEditTracks(release *model.FullRelease) http.Handler { func serveEditTracks(release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html") w.Header().Set("Content-Type", "text/html")
err := components["edittracks"].Execute(w, release) err := components["edittracks"].Execute(w, release)
@ -156,9 +150,9 @@ func serveEditTracks(release *model.FullRelease) http.Handler {
}) })
} }
func serveAddTrack(release *model.FullRelease) http.Handler { func serveAddTrack(release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tracks, err := controller.GetTracksNotOnRelease(global.DB, release.Release) tracks, err := db.GetTracksNotOnRelease(global.DB, release.ID)
if err != nil { if err != nil {
fmt.Printf("FATAL: Failed to pull tracks not on %s: %s\n", release.ID, err) fmt.Printf("FATAL: Failed to pull tracks not on %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -186,7 +180,7 @@ func serveAddTrack(release *model.FullRelease) http.Handler {
func serveNewTrack() http.Handler { func serveNewTrack() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
trackID := strings.Split(r.URL.Path, "/")[3] trackID := strings.Split(r.URL.Path, "/")[3]
track, err := controller.GetTrack(global.DB, trackID) track, err := db.GetTrack(global.DB, trackID)
if err != nil { if err != nil {
fmt.Printf("Error rendering new track component for %s: %s\n", trackID, err) fmt.Printf("Error rendering new track component for %s: %s\n", trackID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)

View file

@ -0,0 +1,151 @@
h1 {
margin: 0 0 1em 0;
}
#artist {
margin-bottom: 1em;
padding: 1.5em;
display: flex;
flex-direction: row;
gap: 1.2em;
border-radius: .5em;
background: #f8f8f8f8;
border: 1px solid #808080;
}
.artist-avatar {
width: 200px;
text-align: center;
}
.artist-avatar img {
width: 100%;
aspect-ratio: 1;
}
.artist-avatar img:hover {
outline: 1px solid #808080;
cursor: pointer;
}
.artist-avatar #remove-avatar {
padding: .3em .4em;
}
.artist-info {
margin: -1em 0 0 0;
flex-grow: 1;
display: flex;
flex-direction: column;
}
.attribute-header {
margin: 1em 0 .2em 0;
opacity: .5;
}
.artist-name {
margin: 0;
}
input[type="text"] {
width: calc(100% - .4em);
padding: .1em .2em;
font-size: inherit;
font-family: inherit;
font-weight: inherit;
color: inherit;
background: #ffffff;
border: 1px solid transparent;
border-radius: 4px;
outline: none;
}
input[type="text"]:hover {
border-color: #80808080;
}
input[type="text"]:active,
input[type="text"]:focus {
border-color: #808080;
}
button, .button {
padding: .5em .8em;
font-family: inherit;
font-size: inherit;
border-radius: .5em;
border: 1px solid #a0a0a0;
background: #f0f0f0;
color: inherit;
}
button:hover, .button:hover {
background: #fff;
border-color: #d0d0d0;
}
button:active, .button:active {
background: #d0d0d0;
border-color: #808080;
}
button {
color: inherit;
}
button.save {
background: #6fd7ff;
border-color: #6f9eb0;
}
button.delete {
background: #ff7171;
border-color: #7d3535;
}
button:hover {
background: #fff;
border-color: #d0d0d0;
}
button:active {
background: #d0d0d0;
border-color: #808080;
}
button[disabled] {
background: #d0d0d0 !important;
border-color: #808080 !important;
opacity: .5;
cursor: not-allowed !important;
}
a.delete {
color: #d22828;
}
.artist-actions {
margin-top: auto;
display: flex;
gap: .5em;
flex-direction: row;
justify-content: right;
}
.card-title a.button {
text-decoration: none;
}
.credit {
margin: 1em 0;
padding: .5em;
display: flex;
flex-direction: row;
gap: 1em;
align-items: center;
background: #f8f8f8;
border-radius: 8px;
border: 1px solid #808080;
}
.release-artwork {
width: 64px;
height: 64px;
border-radius: 4px;
}
.credit-info h3,
.credit-info p {
margin: 0;
font-size: .9em;
}

View file

@ -0,0 +1,79 @@
const artistID = document.getElementById("artist").dataset.id;
const nameInput = document.getElementById("name");
const avatarImg = document.getElementById("avatar");
const removeAvatarBtn = document.getElementById("remove-avatar");
const avatarInput = document.getElementById("avatar-file");
const websiteInput = document.getElementById("website");
const saveBtn = document.getElementById("save");
const deleteBtn = document.getElementById("delete");
saveBtn.addEventListener("click", () => {
fetch("/api/v1/artist/" + artistID, {
method: "PUT",
body: JSON.stringify({
name: nameInput.value,
website: websiteInput.value,
avatar: avatarImg.src,
}),
headers: { "Content-Type": "application/json" }
}).then(res => {
if (!res.ok) {
res.text().then(error => {
console.error(error);
alert("Failed to update release: " + error);
});
return;
}
location = location;
});
});
deleteBtn.addEventListener("click", () => {
if (artistID != prompt(
"You are about to permanently delete " + artistID + ". " +
"This action is irreversible. " +
"Please enter \"" + artistID + "\" to continue.")) return;
fetch("/api/v1/artist/" + artistID, {
method: "DELETE",
}).then(res => {
if (!res.ok) {
res.text().then(error => {
console.error(error);
alert("Failed to delete release: " + error);
});
return;
}
location = "/admin";
});
});
[nameInput, websiteInput].forEach(input => {
input.addEventListener("change", () => {
saveBtn.disabled = false;
});
input.addEventListener("keypress", () => {
saveBtn.disabled = false;
});
});
avatarImg.addEventListener("click", () => {
avatarInput.addEventListener("change", () => {
if (avatarInput.files.length > 0) {
const reader = new FileReader();
reader.onload = e => {
const data = e.target.result;
avatarImg.src = data;
saveBtn.disabled = false;
};
reader.readAsDataURL(avatarInput.files[0]);
}
});
avatarInput.click();
});
removeAvatarBtn.addEventListener("click", () => {
avatarImg.src = "/img/default-avatar.png"
saveBtn.disabled = false;
});

View file

@ -18,8 +18,8 @@ input[type="text"] {
.release-artwork { .release-artwork {
width: 200px; width: 200px;
text-align: center;
} }
.release-artwork img { .release-artwork img {
width: 100%; width: 100%;
aspect-ratio: 1; aspect-ratio: 1;
@ -28,6 +28,9 @@ input[type="text"] {
outline: 1px solid #808080; outline: 1px solid #808080;
cursor: pointer; cursor: pointer;
} }
.release-artwork #remove-artwork {
padding: .3em .4em;
}
.release-info { .release-info {
width: 0; width: 0;
@ -342,7 +345,7 @@ dialog div.dialog-actions {
background-color: #8cff83 background-color: #8cff83
} }
.card.links a.button[data-name="applemusic"] { .card.links a.button[data-name="apple music"] {
background-color: #8cd9ff background-color: #8cd9ff
} }
@ -548,3 +551,14 @@ dialog div.dialog-actions {
#addtrack ul li.new-track:hover { #addtrack ul li.new-track:hover {
background: #e0e0e0; background: #e0e0e0;
} }
@media only screen and (max-width: 1105px) {
#release {
flex-direction: column;
align-items: center;
}
.release-info {
width: auto;
}
}

View file

@ -1,47 +1,36 @@
import Stateful from "/script/silver.min.js"
const releaseID = document.getElementById("release").dataset.id; const releaseID = document.getElementById("release").dataset.id;
const titleInput = document.getElementById("title"); const titleInput = document.getElementById("title");
const artworkImg = document.getElementById("artwork"); const artworkImg = document.getElementById("artwork");
const removeArtworkBtn = document.getElementById("remove-artwork");
const artworkInput = document.getElementById("artwork-file"); const artworkInput = document.getElementById("artwork-file");
const typeInput = document.getElementById("type"); const typeInput = document.getElementById("type");
const descInput = document.getElementById("description"); const descInput = document.getElementById("description");
const dateInput = document.getElementById("release-date"); const dateInput = document.getElementById("release-date");
const buynameInput = document.getElementById("buyname"); const buynameInput = document.getElementById("buyname");
const buylinkInput = document.getElementById("buylink"); const buylinkInput = document.getElementById("buylink");
const copyrightInput = document.getElementById("copyright");
const copyrightURLInput = document.getElementById("copyright-url");
const visInput = document.getElementById("visibility"); const visInput = document.getElementById("visibility");
const saveBtn = document.getElementById("save"); const saveBtn = document.getElementById("save");
const deleteBtn = document.getElementById("delete"); const deleteBtn = document.getElementById("delete");
var artworkData = artworkImg.attributes.src.value; var artworkData = artworkImg.attributes.src.value;
var edited = new Stateful(false);
var releaseData = updateData(undefined);
function updateData(old) {
var releaseData = {
visible: visInput.value === "true",
title: titleInput.value,
description: descInput.value,
type: typeInput.value,
releaseDate: dateInput.value,
artwork: artworkData,
buyname: buynameInput.value,
buylink: buylinkInput.value,
};
if (releaseData && releaseData != old) {
edited.set(true);
}
return releaseData;
}
function saveRelease() {
console.table(releaseData);
saveBtn.addEventListener("click", () => {
fetch("/api/v1/music/" + releaseID, { fetch("/api/v1/music/" + releaseID, {
method: "PUT", method: "PUT",
body: JSON.stringify(releaseData), body: JSON.stringify({
visible: visInput.value === "true",
title: titleInput.value,
description: descInput.value,
type: typeInput.value,
releaseDate: dateInput.value + ":00Z",
artwork: artworkData,
buyname: buynameInput.value,
buylink: buylinkInput.value,
copyright: copyrightInput.value,
copyrightURL: copyrightURLInput.value,
}),
headers: { "Content-Type": "application/json" } headers: { "Content-Type": "application/json" }
}).then(res => { }).then(res => {
if (!res.ok) { if (!res.ok) {
@ -54,9 +43,13 @@ function saveRelease() {
location = location; location = location;
}); });
} });
function deleteRelease() { deleteBtn.addEventListener("click", () => {
if (releaseID != prompt(
"You are about to permanently delete " + releaseID + ". " +
"This action is irreversible. " +
"Please enter \"" + releaseID + "\" to continue.")) return;
fetch("/api/v1/music/" + releaseID, { fetch("/api/v1/music/" + releaseID, {
method: "DELETE", method: "DELETE",
}).then(res => { }).then(res => {
@ -70,15 +63,17 @@ function deleteRelease() {
location = "/admin"; location = "/admin";
}); });
}
edited.onUpdate(edited => {
saveBtn.disabled = !edited;
})
titleInput.addEventListener("change", () => {
releaseData = updateData(releaseData);
}); });
[titleInput, typeInput, descInput, dateInput, buynameInput, buylinkInput, copyrightInput, copyrightURLInput, visInput].forEach(input => {
input.addEventListener("change", () => {
saveBtn.disabled = false;
});
input.addEventListener("keypress", () => {
saveBtn.disabled = false;
});
});
artworkImg.addEventListener("click", () => { artworkImg.addEventListener("click", () => {
artworkInput.addEventListener("change", () => { artworkInput.addEventListener("change", () => {
if (artworkInput.files.length > 0) { if (artworkInput.files.length > 0) {
@ -87,41 +82,17 @@ artworkImg.addEventListener("click", () => {
const data = e.target.result; const data = e.target.result;
artworkImg.src = data; artworkImg.src = data;
artworkData = data; artworkData = data;
releaseData = updateData(releaseData); saveBtn.disabled = false;
}; };
reader.readAsDataURL(artworkInput.files[0]); reader.readAsDataURL(artworkInput.files[0]);
} }
}); });
artworkInput.click(); artworkInput.click();
}); });
typeInput.addEventListener("change", () => {
releaseData = updateData(releaseData);
});
descInput.addEventListener("change", () => {
releaseData = updateData(releaseData);
});
dateInput.addEventListener("change", () => {
releaseData = updateData(releaseData);
});
buynameInput.addEventListener("change", () => {
releaseData = updateData(releaseData);
});
buylinkInput.addEventListener("change", () => {
releaseData = updateData(releaseData);
});
visInput.addEventListener("change", () => {
releaseData = updateData(releaseData);
});
saveBtn.addEventListener("click", () => {
if (!edited.get()) return;
saveRelease();
});
deleteBtn.addEventListener("click", () => { removeArtworkBtn.addEventListener("click", () => {
if (releaseID != prompt( artworkImg.src = "/img/default-cover-art.png"
"You are about to permanently delete " + releaseID + ". " + artworkData = "";
"This action is irreversible. " + saveBtn.disabled = false;
"Please enter \"" + releaseID + "\" to continue.")) return;
deleteRelease();
}); });

View file

@ -6,7 +6,7 @@ h1 {
#track { #track {
margin-bottom: 1em; margin-bottom: 1em;
padding: 1.5em; padding: .5em 1.5em 1.5em 1.5em;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
gap: 1.2em; gap: 1.2em;
@ -34,7 +34,7 @@ h1 {
} }
#title { #title {
width: 100%; width: calc(100% - .4em);
padding: .1em .2em; padding: .1em .2em;
} }

View file

@ -1,4 +1,5 @@
const newReleaseBtn = document.getElementById("create-release"); const newReleaseBtn = document.getElementById("create-release");
const newArtistBtn = document.getElementById("create-artist");
const newTrackBtn = document.getElementById("create-track"); const newTrackBtn = document.getElementById("create-track");
newReleaseBtn.addEventListener("click", event => { newReleaseBtn.addEventListener("click", event => {
@ -24,6 +25,30 @@ newReleaseBtn.addEventListener("click", event => {
}); });
}); });
newArtistBtn.addEventListener("click", event => {
event.preventDefault();
const id = prompt("Enter an ID for this artist:");
if (id == null || id == "") return;
fetch("/api/v1/artist", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({id})
}).then(res => {
res.text().then(text => {
if (res.ok) {
location = "/admin/artist/" + id;
} else {
alert("Request failed: " + text);
console.error(text);
}
})
}).catch(err => {
alert("Failed to create artist. Check the console for details.");
console.error(err);
});
});
newTrackBtn.addEventListener("click", event => { newTrackBtn.addEventListener("click", event => {
event.preventDefault(); event.preventDefault();
const title = prompt("Enter an title for this track:"); const title = prompt("Enter an title for this track:");
@ -43,7 +68,7 @@ newTrackBtn.addEventListener("click", event => {
} }
}) })
}).catch(err => { }).catch(err => {
alert("Failed to create release. Check the console for details."); alert("Failed to create track. Check the console for details.");
console.error(err); console.error(err);
}); });
}); });

View file

@ -29,6 +29,11 @@ var pages = map[string]*template.Template{
filepath.Join("views", "prideflag.html"), filepath.Join("views", "prideflag.html"),
filepath.Join("admin", "views", "edit-release.html"), filepath.Join("admin", "views", "edit-release.html"),
)), )),
"artist": template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"),
filepath.Join("views", "prideflag.html"),
filepath.Join("admin", "views", "edit-artist.html"),
)),
"track": template.Must(template.ParseFiles( "track": template.Must(template.ParseFiles(
filepath.Join("admin", "views", "layout.html"), filepath.Join("admin", "views", "layout.html"),
filepath.Join("views", "prideflag.html"), filepath.Join("views", "prideflag.html"),

View file

@ -5,9 +5,9 @@ import (
"net/http" "net/http"
"strings" "strings"
"arimelody.me/arimelody.me/global" "arimelody-web/global"
"arimelody.me/arimelody.me/music/model" "arimelody-web/music/model"
"arimelody.me/arimelody.me/music/controller" "arimelody-web/music/controller"
) )
func serveTrack() http.Handler { func serveTrack() http.Handler {
@ -25,26 +25,16 @@ func serveTrack() http.Handler {
return return
} }
dbReleases, err := music.GetTrackReleases(global.DB, track) releases, err := music.GetTrackReleases(global.DB, track.ID, true)
if err != nil { if err != nil {
fmt.Printf("Error rendering admin track page for %s: %s\n", id, err) fmt.Printf("FATAL: Failed to pull releases for %s: %s\n", id, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
releases := []model.FullRelease{}
for _, release := range dbReleases {
fullRelease, err := music.GetFullRelease(global.DB, release)
if err != nil {
fmt.Printf("FATAL: Failed to pull full release data for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
releases = append(releases, *fullRelease)
}
type Track struct { type Track struct {
*model.Track *model.Track
Releases []model.FullRelease Releases []*model.Release
} }
err = pages["track"].Execute(w, Track{ Track: track, Releases: releases }) err = pages["track"].Execute(w, Track{ Track: track, Releases: releases })

View file

@ -0,0 +1,72 @@
{{define "head"}}
<title>Editing {{.Name}} - ari melody 💫</title>
<link rel="stylesheet" href="/admin/static/edit-artist.css">
{{end}}
{{define "content"}}
<main>
<h1>Editing Artist</h1>
<div id="artist" data-id="{{.ID}}">
<div class="artist-avatar">
<img src="{{.Avatar}}" alt="" width="256" loading="lazy" id="avatar">
<input type="file" id="avatar-file" name="Artwork" accept=".png,.jpg,.jpeg" hidden>
<button id="remove-avatar">Remove</button>
</div>
<div class="artist-info">
<p class="attribute-header">Name</p>
<h2 class="artist-name">
<input type="text" id="name" name="artist-name" value="{{.Name}}">
</h2>
<p class="attribute-header">Website</p>
<input type="text" id="website" name="website" value="{{.Website}}">
<div class="artist-actions">
<button type="submit" class="save" id="save" disabled>Save</button>
</div>
</div>
</div>
<div class="card-title">
<h2>Featured in</h2>
</div>
<div class="card releases">
{{if .Credits}}
{{range .Credits}}
<div class="credit">
<img src="{{.Release.Artwork}}" alt="" width="64" loading="lazy" class="release-artwork">
<div class="credit-info">
<h3 class="credit-name"><a href="/admin/release/{{.Release.ID}}">{{.Release.Title}}</a></h3>
<p class="credit-artists">{{.Release.PrintArtists true true}}</p>
<p class="artist-role">
Role: {{.Role}}
{{if .Primary}}
<small>(Primary)</small>
{{end}}
</p>
</div>
</div>
{{end}}
{{else}}
<p>This artist has no credits.</p>
{{end}}
</div>
<div class="card-title">
<h2>Danger Zone</h2>
</div>
<div class="card danger">
<p>
Clicking the button below will delete this artist.
This action is <strong>irreversible</strong>.
You will be prompted to confirm this decision.
</p>
<button class="delete" id="delete">Delete Artist</button>
</div>
</main>
<script type="module" src="/admin/static/edit-artist.js" defer></script>
{{end}}

View file

@ -12,10 +12,11 @@
<div class="release-artwork"> <div class="release-artwork">
<img src="{{.Artwork}}" alt="" width="256" loading="lazy" id="artwork"> <img src="{{.Artwork}}" alt="" width="256" loading="lazy" id="artwork">
<input type="file" id="artwork-file" name="Artwork" accept=".png,.jpg,.jpeg" hidden> <input type="file" id="artwork-file" name="Artwork" accept=".png,.jpg,.jpeg" hidden>
<button id="remove-artwork">Remove</button>
</div> </div>
<div class="release-info"> <div class="release-info">
<h1 class="release-title"> <h1 class="release-title">
<input type="text" id="title" name="Title" value="{{.Title}}"> <input type="text" id="title" name="Title" value="{{.Title}}" autocomplete="on">
</h1> </h1>
<table> <table>
<tr> <tr>
@ -53,19 +54,31 @@
<tr> <tr>
<td>Release Date</td> <td>Release Date</td>
<td> <td>
<input type="datetime-local" name="Release Date" id="release-date" value="{{.TextReleaseDate}}"> <input type="datetime-local" name="release-date" id="release-date" value="{{.TextReleaseDate}}">
</td> </td>
</tr> </tr>
<tr> <tr>
<td>Buy Name</td> <td>Buy Name</td>
<td> <td>
<input type="text" name="Buy Name" id="buyname" value="{{.Buyname}}"> <input type="text" name="buyname" id="buyname" value="{{.Buyname}}" autocomplete="on">
</td> </td>
</tr> </tr>
<tr> <tr>
<td>Buy Link</td> <td>Buy Link</td>
<td> <td>
<input type="text" name="Buy Link" id="buylink" value="{{.Buylink}}"> <input type="text" name="buylink" id="buylink" value="{{.Buylink}}" autocomplete="on">
</td>
</tr>
<tr>
<td>Copyright</td>
<td>
<input type="text" name="copyright" id="copyright" value="{{.Copyright}}" autocomplete="on">
</td>
</tr>
<tr>
<td>Copyright URL</td>
<td>
<input type="text" name="copyright-url" id="copyright-url" value="{{.CopyrightURL}}" autocomplete="on">
</td> </td>
</tr> </tr>
<tr> <tr>
@ -99,7 +112,7 @@
<div class="credit"> <div class="credit">
<img src="{{.Artist.GetAvatar}}" alt="" width="64" loading="lazy" class="artist-avatar"> <img src="{{.Artist.GetAvatar}}" alt="" width="64" loading="lazy" class="artist-avatar">
<div class="credit-info"> <div class="credit-info">
<p class="artist-name"><a href="/admin/artists/{{.Artist.ID}}">{{.Artist.Name}}</a></p> <p class="artist-name"><a href="/admin/artist/{{.Artist.ID}}">{{.Artist.Name}}</a></p>
<p class="artist-role"> <p class="artist-role">
{{.Role}} {{.Role}}
{{if .Primary}} {{if .Primary}}
@ -139,23 +152,23 @@
>Edit</a> >Edit</a>
</div> </div>
<div class="card tracks"> <div class="card tracks">
{{range .Tracks}} {{range $i, $track := .Tracks}}
<div class="track" data-id="{{.ID}}"> <div class="track" data-id="{{$track.ID}}">
<h2 class="track-title"> <h2 class="track-title">
<span class="track-number">{{.Number}}</span> <span class="track-number">{{.Add $i 1}}</span>
<a href="/admin/track/{{.ID}}">{{.Title}}</a> <a href="/admin/track/{{$track.ID}}">{{$track.Title}}</a>
</h2> </h2>
<h3>Description</h3> <h3>Description</h3>
{{if .Description}} {{if $track.Description}}
<p class="track-description">{{.Description}}</p> <p class="track-description">{{$track.GetDescriptionHTML}}</p>
{{else}} {{else}}
<p class="track-description empty">No description provided.</p> <p class="track-description empty">No description provided.</p>
{{end}} {{end}}
<h3>Lyrics</h3> <h3>Lyrics</h3>
{{if .Lyrics}} {{if $track.Lyrics}}
<p class="track-lyrics">{{.Lyrics}}</p> <p class="track-lyrics">{{$track.GetLyricsHTML}}</p>
{{else}} {{else}}
<p class="track-lyrics empty">There are no lyrics.</p> <p class="track-lyrics empty">There are no lyrics.</p>
{{end}} {{end}}

View file

@ -22,13 +22,13 @@
<div class="card-title"> <div class="card-title">
<h1>Artists</h1> <h1>Artists</h1>
<a class="create-btn">Create New</a> <a class="create-btn" id="create-artist">Create New</a>
</div> </div>
<div class="card artists"> <div class="card artists">
{{range $Artist := .Artists}} {{range $Artist := .Artists}}
<div class="artist"> <div class="artist">
<img src="{{$Artist.GetAvatar}}" alt="" width="64" loading="lazy" class="artist-avatar"> <img src="{{$Artist.GetAvatar}}" alt="" width="64" loading="lazy" class="artist-avatar">
<a href="/admin/artists/{{$Artist.ID}}" class="artist-name">{{$Artist.Name}}</a> <a href="/admin/artist/{{$Artist.ID}}" class="artist-name">{{$Artist.Name}}</a>
</div> </div>
{{end}} {{end}}
{{if not .Artists}} {{if not .Artists}}
@ -49,12 +49,12 @@
<a href="/admin/track/{{$Track.ID}}">{{$Track.Title}}</a> <a href="/admin/track/{{$Track.ID}}">{{$Track.Title}}</a>
</h2> </h2>
{{if $Track.Description}} {{if $Track.Description}}
<p class="track-description">{{$Track.Description}}</p> <p class="track-description">{{$Track.GetDescriptionHTML}}</p>
{{else}} {{else}}
<p class="track-description empty">No description provided.</p> <p class="track-description empty">No description provided.</p>
{{end}} {{end}}
{{if $Track.Lyrics}} {{if $Track.Lyrics}}
<p class="track-lyrics">{{$Track.Lyrics}}</p> <p class="track-lyrics">{{$Track.GetLyricsHTML}}</p>
{{else}} {{else}}
<p class="track-lyrics empty">There are no lyrics.</p> <p class="track-lyrics empty">There are no lyrics.</p>
{{end}} {{end}}

View file

@ -5,10 +5,10 @@ import (
"net/http" "net/http"
"strings" "strings"
"arimelody.me/arimelody.me/admin" "arimelody-web/admin"
"arimelody.me/arimelody.me/global" "arimelody-web/global"
"arimelody.me/arimelody.me/music/model" music "arimelody-web/music/controller"
music "arimelody.me/arimelody.me/music/view" musicView "arimelody-web/music/view"
) )
func Handler() http.Handler { func Handler() http.Handler {
@ -18,11 +18,14 @@ func Handler() http.Handler {
mux.Handle("/v1/artist/", http.StripPrefix("/v1/artist", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mux.Handle("/v1/artist/", http.StripPrefix("/v1/artist", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var artistID = strings.Split(r.URL.Path[1:], "/")[0] var artistID = strings.Split(r.URL.Path[1:], "/")[0]
var artist model.Artist artist, err := music.GetArtist(global.DB, artistID)
err := global.DB.Get(&artist, "SELECT * FROM artist WHERE id=$1", artistID)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r)
return
}
fmt.Printf("FATAL: Error while retrieving artist %s: %s\n", artistID, err) fmt.Printf("FATAL: Error while retrieving artist %s: %s\n", artistID, err)
http.NotFound(w, r) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
@ -57,18 +60,21 @@ func Handler() http.Handler {
mux.Handle("/v1/music/", http.StripPrefix("/v1/music", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mux.Handle("/v1/music/", http.StripPrefix("/v1/music", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var releaseID = strings.Split(r.URL.Path[1:], "/")[0] var releaseID = strings.Split(r.URL.Path[1:], "/")[0]
var release model.Release release, err := music.GetRelease(global.DB, releaseID, true)
err := global.DB.Get(&release, "SELECT * FROM musicrelease WHERE id=$1", releaseID)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r)
return
}
fmt.Printf("FATAL: Error while retrieving release %s: %s\n", releaseID, err) fmt.Printf("FATAL: Error while retrieving release %s: %s\n", releaseID, err)
http.NotFound(w, r) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
// GET /api/v1/music/{id} // GET /api/v1/music/{id}
music.ServeRelease(release).ServeHTTP(w, r) musicView.ServeRelease(release).ServeHTTP(w, r)
case http.MethodPut: case http.MethodPut:
// PUT /api/v1/music/{id} (admin) // PUT /api/v1/music/{id} (admin)
admin.MustAuthorise(UpdateRelease(release)).ServeHTTP(w, r) admin.MustAuthorise(UpdateRelease(release)).ServeHTTP(w, r)
@ -96,11 +102,14 @@ func Handler() http.Handler {
mux.Handle("/v1/track/", http.StripPrefix("/v1/track", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mux.Handle("/v1/track/", http.StripPrefix("/v1/track", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var trackID = strings.Split(r.URL.Path[1:], "/")[0] var trackID = strings.Split(r.URL.Path[1:], "/")[0]
var track model.Track track, err := music.GetTrack(global.DB, trackID)
err := global.DB.Get(&track, "SELECT * FROM musictrack WHERE id=$1", trackID)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r)
return
}
fmt.Printf("FATAL: Error while retrieving track %s: %s\n", trackID, err) fmt.Printf("FATAL: Error while retrieving track %s: %s\n", trackID, err)
http.NotFound(w, r) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }

View file

@ -3,24 +3,23 @@ package api
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io/fs"
"net/http" "net/http"
"os"
"path/filepath"
"strings" "strings"
"arimelody.me/arimelody.me/global" "arimelody-web/admin"
"arimelody.me/arimelody.me/music/model" "arimelody-web/global"
db "arimelody-web/music/controller"
music "arimelody-web/music/controller"
"arimelody-web/music/model"
) )
type artistJSON struct {
ID string `json:"id"`
Name *string `json:"name"`
Website *string `json:"website"`
Avatar *string `json:"avatar"`
}
func ServeAllArtists() http.Handler { func ServeAllArtists() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var artists = []*model.Artist{} var artists = []*model.Artist{}
err := global.DB.Select(&artists, "SELECT * FROM artist") artists, err := db.GetAllArtists(global.DB)
if err != nil { if err != nil {
fmt.Printf("FATAL: Failed to serve all artists: %s\n", err) fmt.Printf("FATAL: Failed to serve all artists: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -35,28 +34,37 @@ func ServeAllArtists() http.Handler {
}) })
} }
func ServeArtist(artist model.Artist) http.Handler { func ServeArtist(artist *model.Artist) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
type ( type (
creditJSON struct { creditJSON struct {
Release string `json:"release"`
Role string `json:"role"` Role string `json:"role"`
Primary bool `json:"primary"` Primary bool `json:"primary"`
} }
artistJSON struct { artistJSON struct {
model.Artist *model.Artist
Credits map[string]creditJSON `json:"credits"` Credits map[string]creditJSON `json:"credits"`
} }
) )
var credits = map[string]creditJSON{} show_hidden_releases := admin.GetSession(r) != nil
err := global.DB.Select(&credits, "SELECT release,role,is_primary FROM musiccredit WHERE id=$1", artist.ID)
var dbCredits []*model.Credit
dbCredits, err := db.GetArtistCredits(global.DB, artist.ID, show_hidden_releases)
if err != nil { if err != nil {
fmt.Printf("FATAL: Failed to retrieve artist credits for %s: %s\n", artist.ID, err) fmt.Printf("FATAL: Failed to retrieve artist credits for %s: %s\n", artist.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
var credits = map[string]creditJSON{}
for _, credit := range dbCredits {
credits[credit.Release.ID] = creditJSON{
Role: credit.Role,
Primary: credit.Primary,
}
}
w.Header().Add("Content-Type", "application/json") w.Header().Add("Content-Type", "application/json")
err = json.NewEncoder(w).Encode(artistJSON{ err = json.NewEncoder(w).Encode(artistJSON{
Artist: artist, Artist: artist,
@ -70,39 +78,23 @@ func ServeArtist(artist model.Artist) http.Handler {
func CreateArtist() http.Handler { func CreateArtist() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var data artistJSON var artist model.Artist
err := json.NewDecoder(r.Body).Decode(&data) err := json.NewDecoder(r.Body).Decode(&artist)
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 data.ID == "" { if artist.ID == "" {
http.Error(w, "Artist ID cannot be blank\n", http.StatusBadRequest) http.Error(w, "Artist ID cannot be blank\n", http.StatusBadRequest)
return return
} }
if data.Name == nil || *data.Name == "" { if artist.Name == "" { artist.Name = artist.ID }
http.Error(w, "Artist name cannot be blank\n", http.StatusBadRequest)
return
}
var artist = model.Artist{ err = music.CreateArtist(global.DB, &artist)
ID: data.ID,
Name: *data.Name,
Website: *data.Website,
Avatar: *data.Avatar,
}
_, err = global.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 { if err != nil {
if strings.Contains(err.Error(), "duplicate key") { if strings.Contains(err.Error(), "duplicate key") {
http.Error(w, fmt.Sprintf("Artist %s already exists\n", data.ID), http.StatusBadRequest) http.Error(w, fmt.Sprintf("Artist %s already exists\n", artist.ID), http.StatusBadRequest)
return return
} }
fmt.Printf("FATAL: Failed to create artist %s: %s\n", artist.ID, err) fmt.Printf("FATAL: Failed to create artist %s: %s\n", artist.ID, err)
@ -114,43 +106,59 @@ func CreateArtist() http.Handler {
}) })
} }
func UpdateArtist(artist model.Artist) http.Handler { func UpdateArtist(artist *model.Artist) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var data artistJSON err := json.NewDecoder(r.Body).Decode(&artist)
err := json.NewDecoder(r.Body).Decode(&data)
if err != nil { if err != nil {
fmt.Printf("FATAL: Failed to update artist: %s\n", err) fmt.Printf("FATAL: Failed to update artist: %s\n", err)
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return return
} }
if data.ID != "" { artist.ID = data.ID } if artist.Avatar == "" {
if data.Name != nil { artist.Name = *data.Name } artist.Avatar = "/img/default-avatar.png"
if data.Website != nil { artist.Website = *data.Website } } else {
if data.Avatar != nil { artist.Avatar = *data.Avatar } if strings.Contains(artist.Avatar, ";base64,") {
var artworkDirectory = filepath.Join("uploads", "avatar")
filename, err := HandleImageUpload(&artist.Avatar, artworkDirectory, artist.ID)
_, err = global.DB.Exec( // clean up files with this ID and different extensions
"UPDATE artist "+ err = filepath.Walk(artworkDirectory, func(path string, info fs.FileInfo, err error) error {
"SET name=$2, website=$3, avatar=$4 "+ if path == filepath.Join(artworkDirectory, filename) { return nil }
"WHERE id=$1",
artist.ID, withoutExt := strings.TrimSuffix(path, filepath.Ext(path))
artist.Name, if withoutExt != filepath.Join(artworkDirectory, artist.ID) { return nil }
artist.Website,
artist.Avatar) return os.Remove(path)
})
if err != nil {
fmt.Printf("WARN: Error while cleaning up avatar files: %s\n", err)
}
artist.Avatar = fmt.Sprintf("/uploads/avatar/%s", filename)
}
}
err = music.UpdateArtist(global.DB, artist)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r)
return
}
fmt.Printf("FATAL: Failed to update artist %s: %s\n", artist.ID, err) fmt.Printf("FATAL: Failed to update artist %s: %s\n", artist.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
} }
}) })
} }
func DeleteArtist(artist model.Artist) http.Handler { func DeleteArtist(artist *model.Artist) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, err := global.DB.Exec( err := music.DeleteArtist(global.DB, artist.ID)
"DELETE FROM artist "+
"WHERE id=$1",
artist.ID)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r)
return
}
fmt.Printf("FATAL: Failed to delete artist %s: %s\n", artist.ID, err) fmt.Printf("FATAL: Failed to delete artist %s: %s\n", artist.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
} }

View file

@ -10,54 +10,51 @@ import (
"strings" "strings"
"time" "time"
"arimelody.me/arimelody.me/admin" "arimelody-web/admin"
"arimelody.me/arimelody.me/global" "arimelody-web/global"
"arimelody.me/arimelody.me/music/model" music "arimelody-web/music/controller"
"arimelody-web/music/model"
) )
type releaseBodyJSON struct {
ID string `json:"id"`
Visible *bool `json:"visible"`
Title *string `json:"title"`
Description *string `json:"description"`
ReleaseType *model.ReleaseType `json:"type"`
ReleaseDate *string `json:"releaseDate"`
Artwork *string `json:"artwork"`
Buyname *string `json:"buyname"`
Buylink *string `json:"buylink"`
}
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) {
type catalogItem struct { releases, err := music.GetAllReleases(global.DB, false, 0, true)
ID string `json:"id"`
Title string `json:"title"`
ReleaseType model.ReleaseType `json:"type"`
ReleaseDate time.Time `json:"releaseDate"`
Artwork string `json:"artwork"`
Buylink string `json:"buylink"`
}
releases := []*model.Release{}
err := global.DB.Select(&releases, "SELECT * FROM musicrelease ORDER BY release_date DESC")
if err != nil { if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
catalog := []catalogItem{} type Release struct {
ID string `json:"id"`
Title string `json:"title"`
Artists []string `json:"artists"`
ReleaseType model.ReleaseType `json:"type" db:"type"`
ReleaseDate time.Time `json:"releaseDate" db:"release_date"`
Artwork string `json:"artwork"`
Buylink string `json:"buylink"`
Copyright string `json:"copyright" db:"copyright"`
}
catalog := []Release{}
authorised := admin.GetSession(r) != nil authorised := admin.GetSession(r) != nil
for _, release := range releases { for _, release := range releases {
if !release.Visible && !authorised { if !release.Visible && !authorised {
continue continue
} }
catalog = append(catalog, catalogItem{ artists := []string{}
for _, credit := range release.Credits {
if !credit.Primary { continue }
artists = append(artists, credit.Artist.Name)
}
catalog = append(catalog, Release{
ID: release.ID, ID: release.ID,
Title: release.Title, Title: release.Title,
Artists: artists,
ReleaseType: release.ReleaseType, ReleaseType: release.ReleaseType,
ReleaseDate: release.ReleaseDate, ReleaseDate: release.ReleaseDate,
Artwork: release.Artwork, Artwork: release.Artwork,
Buylink: release.Buylink, Buylink: release.Buylink,
Copyright: release.Copyright,
}) })
} }
@ -77,80 +74,34 @@ func CreateRelease() http.Handler {
return return
} }
var data releaseBodyJSON var release model.Release
err := json.NewDecoder(r.Body).Decode(&data) err := json.NewDecoder(r.Body).Decode(&release)
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 data.ID == "" { if release.ID == "" {
http.Error(w, "Release ID cannot be empty\n", http.StatusBadRequest) http.Error(w, "Release ID cannot be empty\n", http.StatusBadRequest)
return return
} }
title := data.ID if release.Title == "" { release.Title = release.ID }
if data.Title != nil && *data.Title != "" { if release.ReleaseType == "" { release.ReleaseType = model.Single }
title = *data.Title
}
description := ""
if data.Description != nil && *data.Description != "" { description = *data.Description }
releaseType := model.Single if release.ReleaseDate != time.Unix(0, 0) {
if data.ReleaseType != nil && *data.ReleaseType != "" { releaseType = *data.ReleaseType } release.ReleaseDate = time.Date(time.Now().Year(), time.Now().Month(), time.Now().Day(), 0, 0, 0, 0, time.UTC)
releaseDate := time.Time{}
if data.ReleaseDate != nil && *data.ReleaseDate != "" {
releaseDate, err = time.Parse("2006-01-02T15:04", *data.ReleaseDate)
if err != nil {
http.Error(w, "Invalid release date", http.StatusBadRequest)
return
}
} else {
releaseDate = time.Date(time.Now().Year(), time.Now().Month(), time.Now().Day(), 0, 0, 0, 0, time.UTC)
} }
artwork := "/img/default-cover-art.png" if release.Artwork == "" { release.Artwork = "/img/default-cover-art.png" }
if data.Artwork != nil && *data.Artwork != "" { artwork = *data.Artwork }
buyname := "" err = music.CreateRelease(global.DB, &release)
if data.Buyname != nil && *data.Buyname != "" { buyname = *data.Buyname }
buylink := ""
if data.Buylink != nil && *data.Buylink != "" { buylink = *data.Buylink }
var release = model.Release{
ID: data.ID,
Visible: false,
Title: title,
Description: description,
ReleaseType: releaseType,
ReleaseDate: releaseDate,
Artwork: artwork,
Buyname: buyname,
Buylink: buylink,
}
_, err = global.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("2006-01-02 15:04:05"),
release.Artwork,
release.Buyname,
release.Buylink)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "duplicate key") { if strings.Contains(err.Error(), "duplicate key") {
http.Error(w, fmt.Sprintf("Release %s already exists\n", data.ID), http.StatusBadRequest) http.Error(w, fmt.Sprintf("Release %s already exists\n", release.ID), http.StatusBadRequest)
return return
} }
fmt.Printf("Failed to create release %s: %s\n", release.ID, err) fmt.Printf("FATAL: Failed to create release %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
@ -165,7 +116,7 @@ func CreateRelease() http.Handler {
}) })
} }
func UpdateRelease(release model.Release) http.Handler { func UpdateRelease(release *model.Release) 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 == "/" {
http.NotFound(w, r) http.NotFound(w, r)
@ -173,14 +124,6 @@ func UpdateRelease(release model.Release) http.Handler {
} }
segments := strings.Split(r.URL.Path[1:], "/") segments := strings.Split(r.URL.Path[1:], "/")
var releaseID = segments[0]
var exists int
err := global.DB.Get(&exists, "SELECT count(*) FROM musicrelease WHERE id=$1", releaseID)
if err != nil {
fmt.Printf("Failed to update release: %s\n", err)
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
if len(segments) == 2 { if len(segments) == 2 {
switch segments[1] { switch segments[1] {
@ -199,30 +142,19 @@ func UpdateRelease(release model.Release) http.Handler {
return return
} }
var data releaseBodyJSON err := json.NewDecoder(r.Body).Decode(&release)
err = json.NewDecoder(r.Body).Decode(&data)
if err != nil { if err != nil {
fmt.Printf("WARN: Failed to update release %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return return
} }
if data.ID != "" { release.ID = data.ID } if release.Artwork == "" {
if data.Visible != nil { release.Visible = *data.Visible } release.Artwork = "/img/default-cover-art.png"
if data.Title != nil { release.Title = *data.Title } } else {
if data.Description != nil { release.Description = *data.Description } if strings.Contains(release.Artwork, ";base64,") {
if data.ReleaseType != nil { release.ReleaseType = *data.ReleaseType }
if data.ReleaseDate != nil {
newDate, err := time.Parse("2006-01-02T15:04", *data.ReleaseDate)
if err != nil {
http.Error(w, "Invalid release date", http.StatusBadRequest)
return
}
release.ReleaseDate = newDate
}
if data.Artwork != nil {
if strings.Contains(*data.Artwork, ";base64,") {
var artworkDirectory = filepath.Join("uploads", "musicart") var artworkDirectory = filepath.Join("uploads", "musicart")
filename, err := HandleImageUpload(data.Artwork, artworkDirectory, data.ID) filename, err := HandleImageUpload(&release.Artwork, artworkDirectory, release.ID)
// clean up files with this ID and different extensions // clean up files with this ID and different extensions
err = filepath.Walk(artworkDirectory, func(path string, info fs.FileInfo, err error) error { err = filepath.Walk(artworkDirectory, func(path string, info fs.FileInfo, err error) error {
@ -238,35 +170,22 @@ func UpdateRelease(release model.Release) http.Handler {
} }
release.Artwork = fmt.Sprintf("/uploads/musicart/%s", filename) release.Artwork = fmt.Sprintf("/uploads/musicart/%s", filename)
} else {
release.Artwork = *data.Artwork
} }
} }
if data.Buyname != nil { release.Buyname = *data.Buyname } err = music.UpdateRelease(global.DB, release)
if data.Buylink != nil { release.Buylink = *data.Buylink }
_, err = global.DB.Exec(
"UPDATE musicrelease SET "+
"visible=$2, title=$3, description=$4, type=$5, release_date=$6, artwork=$7, buyname=$8, buylink=$9 "+
"WHERE id=$1",
release.ID,
release.Visible,
release.Title,
release.Description,
release.ReleaseType,
release.ReleaseDate.Format("2006-01-02 15:04:05"),
release.Artwork,
release.Buyname,
release.Buylink)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r)
return
}
fmt.Printf("FATAL: Failed to update release %s: %s\n", release.ID, err) fmt.Printf("FATAL: Failed to update release %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
} }
}) })
} }
func UpdateReleaseTracks(release model.Release) http.Handler { func UpdateReleaseTracks(release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var trackIDs = []string{} var trackIDs = []string{}
err := json.NewDecoder(r.Body).Decode(&trackIDs) err := json.NewDecoder(r.Body).Decode(&trackIDs)
@ -275,26 +194,19 @@ func UpdateReleaseTracks(release model.Release) http.Handler {
return return
} }
tx := global.DB.MustBegin() err = music.UpdateReleaseTracks(global.DB, release.ID, trackIDs)
tx.MustExec("DELETE FROM musicreleasetrack WHERE release=$1", release.ID)
for i, trackID := range trackIDs {
tx.MustExec(
"INSERT INTO musicreleasetrack "+
"(release, track, number) "+
"VALUES ($1, $2, $3)",
release.ID,
trackID,
i)
}
err = tx.Commit()
if err != nil { if err != nil {
fmt.Printf("Failed to update tracks for %s: %s\n", release.ID, err) if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r)
return
}
fmt.Printf("FATAL: Failed to update tracks for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
} }
}) })
} }
func UpdateReleaseCredits(release model.Release) http.Handler { func UpdateReleaseCredits(release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
type creditJSON struct { type creditJSON struct {
Artist string Artist string
@ -308,52 +220,34 @@ func UpdateReleaseCredits(release model.Release) http.Handler {
return return
} }
// clear duplicates var credits []*model.Credit
type Credit struct {
Role string
Primary bool
}
var credits = map[string]Credit{}
for _, credit := range data { for _, credit := range data {
credits[credit.Artist] = Credit{ credits = append(credits, &model.Credit{
Artist: model.Artist{
ID: credit.Artist,
},
Role: credit.Role, Role: credit.Role,
Primary: credit.Primary, Primary: credit.Primary,
} })
} }
tx := global.DB.MustBegin() err = music.UpdateReleaseCredits(global.DB, release.ID, credits)
tx.MustExec("DELETE FROM musiccredit WHERE release=$1", release.ID)
for artistID := range credits {
if credits[artistID].Role == "" {
http.Error(w, fmt.Sprintf("Artist role cannot be blank (%s)", artistID), http.StatusBadRequest)
return
}
var exists int
_ = global.DB.Get(&exists, "SELECT count(*) FROM artist WHERE id=$1", artistID)
if exists == 0 {
http.Error(w, fmt.Sprintf("Artist %s does not exist\n", artistID), http.StatusBadRequest)
return
}
tx.MustExec(
"INSERT INTO musiccredit "+
"(release, artist, role, is_primary) "+
"VALUES ($1, $2, $3, $4)",
release.ID,
artistID,
credits[artistID].Role,
credits[artistID].Primary)
}
err = tx.Commit()
if err != nil { if err != nil {
fmt.Printf("Failed to update links for %s: %s\n", release.ID, err) if strings.Contains(err.Error(), "duplicate key") {
http.Error(w, "Artists may only be credited once\n", http.StatusBadRequest)
return
}
if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r)
return
}
fmt.Printf("FATAL: Failed to update links for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
} }
}) })
} }
func UpdateReleaseLinks(release model.Release) http.Handler { func UpdateReleaseLinks(release *model.Release) 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.MethodPut { if r.Method != http.MethodPut {
http.NotFound(w, r) http.NotFound(w, r)
@ -367,30 +261,27 @@ func UpdateReleaseLinks(release model.Release) http.Handler {
return return
} }
tx := global.DB.MustBegin() err = music.UpdateReleaseLinks(global.DB, release.ID, links)
tx.MustExec("DELETE FROM musiclink WHERE release=$1", release.ID)
for _, link := range links {
tx.MustExec(
"INSERT INTO musiclink "+
"(release, name, url) "+
"VALUES ($1, $2, $3)",
release.ID,
link.Name,
link.URL)
}
err = tx.Commit()
if err != nil { if err != nil {
fmt.Printf("Failed to update links for %s: %s\n", release.ID, err) if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r)
return
}
fmt.Printf("FATAL: Failed to update links for %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
} }
}) })
} }
func DeleteRelease(release model.Release) http.Handler { func DeleteRelease(release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, err := global.DB.Exec("DELETE FROM musicrelease WHERE id=$1", release.ID) err := music.DeleteRelease(global.DB, release.ID)
if err != nil { if err != nil {
fmt.Printf("Failed to delete release %s: %s\n", release.ID, err) if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r)
return
}
fmt.Printf("FATAL: Failed to delete release %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
} }
}) })

View file

@ -5,24 +5,40 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"arimelody.me/arimelody.me/global" "arimelody-web/global"
"arimelody.me/arimelody.me/music/model" music "arimelody-web/music/controller"
"arimelody-web/music/model"
)
type (
Track struct {
*model.Track
Releases []string `json:"releases"`
}
) )
func ServeAllTracks() http.Handler { func ServeAllTracks() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
type track struct { type Track struct {
ID string `json:"id"` ID string `json:"id"`
Title string `json:"title"` Title string `json:"title"`
} }
var tracks = []track{} var tracks = []Track{}
err := global.DB.Select(&tracks, "SELECT id, title FROM musictrack") var dbTracks = []*model.Track{}
dbTracks, err := music.GetAllTracks(global.DB)
if err != nil { if err != nil {
fmt.Printf("FATAL: Failed to pull tracks from DB: %s\n", err) fmt.Printf("FATAL: Failed to pull tracks from DB: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
} }
for _, track := range dbTracks {
tracks = append(tracks, Track{
ID: track.ID,
Title: track.Title,
})
}
w.Header().Add("Content-Type", "application/json") w.Header().Add("Content-Type", "application/json")
err = json.NewEncoder(w).Encode(tracks) err = json.NewEncoder(w).Encode(tracks)
if err != nil { if err != nil {
@ -32,42 +48,23 @@ func ServeAllTracks() http.Handler {
}) })
} }
func ServeTrack(track model.Track) http.Handler { func ServeTrack(track *model.Track) 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 == "/" { dbReleases, err := music.GetTrackReleases(global.DB, track.ID, false)
ServeAllTracks().ServeHTTP(w, r)
return
}
var trackID = r.URL.Path[1:]
var track = model.Track{}
err := global.DB.Get(&track, "SELECT * from musictrack WHERE id=$1", trackID)
if err != nil {
http.NotFound(w, r)
return
}
var releases = []*model.Release{}
err = global.DB.Select(&releases,
"SELECT * FROM musicrelease JOIN musicreleasetrack AS mrt "+
"WHERE mrt.track=$1 "+
"ORDER BY release_date",
track.ID,
)
if err != nil { if err != nil {
fmt.Printf("FATAL: Failed to pull track releases for %s from DB: %s\n", trackID, err) fmt.Printf("FATAL: Failed to pull track releases for %s from DB: %s\n", track.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
} }
type response struct { releases := []string{}
model.Track for _, release := range dbReleases {
Releases []*model.Release releases = append(releases, release.ID)
} }
w.Header().Add("Content-Type", "application/json") w.Header().Add("Content-Type", "application/json")
err = json.NewEncoder(w).Encode(response{ track, releases }) err = json.NewEncoder(w).Encode(Track{ track, releases })
if err != nil { if err != nil {
fmt.Printf("FATAL: Failed to serve track %s: %s\n", trackID, err) fmt.Printf("FATAL: Failed to serve track %s: %s\n", track.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
} }
}) })
@ -92,15 +89,7 @@ func CreateTrack() http.Handler {
return return
} }
var trackID string id, err := music.CreateTrack(global.DB, &track)
err = global.DB.Get(&trackID,
"INSERT INTO musictrack (title, description, lyrics, preview_url) "+
"VALUES ($1, $2, $3, $4) "+
"RETURNING id",
track.Title,
track.Description,
track.Lyrics,
track.PreviewURL)
if err != nil { if err != nil {
fmt.Printf("FATAL: Failed to create track: %s\n", err) fmt.Printf("FATAL: Failed to create track: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -109,46 +98,29 @@ func CreateTrack() http.Handler {
w.Header().Add("Content-Type", "text/plain") w.Header().Add("Content-Type", "text/plain")
w.WriteHeader(http.StatusCreated) w.WriteHeader(http.StatusCreated)
w.Write([]byte(trackID)) w.Write([]byte(id))
}) })
} }
func UpdateTrack(track model.Track) http.Handler { func UpdateTrack(track *model.Track) 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.MethodPut || r.URL.Path == "/" { if r.Method != http.MethodPut || r.URL.Path == "/" {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
var update model.Track err := json.NewDecoder(r.Body).Decode(&track)
err := json.NewDecoder(r.Body).Decode(&update)
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 update.Title == "" { if track.Title == "" {
http.Error(w, "Track title cannot be empty\n", http.StatusBadRequest) http.Error(w, "Track title cannot be empty\n", http.StatusBadRequest)
return return
} }
var trackID = r.URL.Path[1:] err = music.UpdateTrack(global.DB, track)
var track = model.Track{}
err = global.DB.Get(&track, "SELECT * from musictrack WHERE id=$1", trackID)
if err != nil {
http.NotFound(w, r)
return
}
_, err = global.DB.Exec(
"UPDATE musictrack "+
"SET title=$2, description=$3, lyrics=$4, preview_url=$5 "+
"WHERE id=$1",
track.ID,
track.Title,
track.Description,
track.Lyrics,
track.PreviewURL)
if err != nil { if err != nil {
fmt.Printf("Failed to update track %s: %s\n", track.ID, err) fmt.Printf("Failed to update track %s: %s\n", track.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -163,7 +135,7 @@ func UpdateTrack(track model.Track) http.Handler {
}) })
} }
func DeleteTrack(track model.Track) http.Handler { func DeleteTrack(track *model.Track) 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.MethodDelete || r.URL.Path == "/" { if r.Method != http.MethodDelete || r.URL.Path == "/" {
http.NotFound(w, r) http.NotFound(w, r)
@ -171,10 +143,7 @@ func DeleteTrack(track model.Track) http.Handler {
} }
var trackID = r.URL.Path[1:] var trackID = r.URL.Path[1:]
_, err := global.DB.Exec( err := music.DeleteTrack(global.DB, trackID)
"DELETE FROM musictrack "+
"WHERE id=$1",
trackID)
if err != nil { if err != nil {
fmt.Printf("Failed to delete track %s: %s\n", trackID, err) fmt.Printf("Failed to delete track %s: %s\n", trackID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)

View file

@ -35,6 +35,8 @@ func HandleImageUpload(data *string, directory string, filename string) (string,
} }
defer file.Close() defer file.Close()
// TODO: generate compressed versions of image (512x512?)
buffer := bufio.NewWriter(file) buffer := bufio.NewWriter(file)
_, err = buffer.Write(imageData) _, err = buffer.Write(imageData)
if err != nil { if err != nil {

View file

@ -6,26 +6,27 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
"os"
"strings" "strings"
"arimelody.me/arimelody.me/global" "arimelody-web/global"
) )
const API_ENDPOINT = "https://discord.com/api/v10" const API_ENDPOINT = "https://discord.com/api/v10"
var CREDENTIALS_PROVIDED = true var CREDENTIALS_PROVIDED = true
var CLIENT_ID = func() string { var CLIENT_ID = func() string {
id := global.Args["discordClient"] id := os.Getenv("DISCORD_CLIENT")
if id == "" { if id == "" {
fmt.Printf("WARN: Discord client ID (-discordClient) was not provided. Admin login will be unavailable.\n") fmt.Printf("WARN: Discord client ID (DISCORD_CLIENT) was not provided. Admin login will be unavailable.\n")
CREDENTIALS_PROVIDED = false CREDENTIALS_PROVIDED = false
} }
return id return id
}() }()
var CLIENT_SECRET = func() string { var CLIENT_SECRET = func() string {
secret := global.Args["discordSecret"] secret := os.Getenv("DISCORD_SECRET")
if secret== "" { if secret == "" {
fmt.Printf("WARN: Discord secret (-discordSecret) was not provided. Admin login will be unavailable.\n") fmt.Printf("WARN: Discord secret (DISCORD_SECRET) was not provided. Admin login will be unavailable.\n")
CREDENTIALS_PROVIDED = false CREDENTIALS_PROVIDED = false
} }
return secret return secret
@ -107,7 +108,6 @@ func GetDiscordUserFromAuth(token string) (DiscordUser, error) {
} }
auth_info := AuthInfoResponse{} auth_info := AuthInfoResponse{}
err = json.NewDecoder(res.Body).Decode(&auth_info) err = json.NewDecoder(res.Body).Decode(&auth_info)
if err != nil { if err != nil {
return DiscordUser{}, errors.New(fmt.Sprintf("Failed to parse auth information from discord: %s\n", err)) return DiscordUser{}, errors.New(fmt.Sprintf("Failed to parse auth information from discord: %s\n", err))

View file

@ -1,18 +0,0 @@
version: '3.9'
services:
db:
image: postgres:16.1-alpine3.18
container_name: arimelody.me-db
ports:
- 5432:5432
volumes:
- arimelody-db:/var/lib/postgresql/data
environment:
POSTGRES_DB: arimelody
POSTGRES_USER: arimelody
POSTGRES_PASSWORD: fuckingpassword
volumes:
arimelody-db:
external: true

22
docker-compose.yml Normal file
View file

@ -0,0 +1,22 @@
services:
web:
image: docker.arimelody.me/arimelody.me:latest
build: .
ports:
- 8080:8080
volumes:
- ./uploads:/app/uploads
environment:
HTTP_DOMAIN: "https://arimelody.me"
ARIMELODY_DB_HOST: db
DISCORD_ADMIN: # your discord user ID.
DISCORD_CLIENT: # your discord OAuth client ID.
DISCORD_SECRET: # your discord OAuth secret.
db:
image: postgres:16.1-alpine3.18
volumes:
- ./db:/var/lib/postgresql/data
environment:
POSTGRES_DB: arimelody
POSTGRES_USER: arimelody
POSTGRES_PASSWORD: fuckingpassword

View file

@ -35,11 +35,11 @@ var Args = func() map[string]string {
}() }()
var HTTP_DOMAIN = func() string { var HTTP_DOMAIN = func() string {
domain := Args["httpDomain"] domain := os.Getenv("HTTP_DOMAIN")
if domain != "" { if domain == "" {
return domain return "https://arimelody.me"
} }
return "https://arimelody.me" return domain
}() }()
var DB *sqlx.DB var DB *sqlx.DB

View file

@ -6,7 +6,7 @@ import (
"strconv" "strconv"
"time" "time"
"arimelody.me/arimelody.me/colour" "arimelody-web/colour"
) )
func DefaultHeaders(next http.Handler) http.Handler { func DefaultHeaders(next http.Handler) http.Handler {

15
go.mod
View file

@ -1,19 +1,8 @@
module arimelody.me/arimelody.me module arimelody-web
go 1.22 go 1.22
require ( require (
github.com/gomarkdown/markdown v0.0.0-20231222211730-1d6d20845b47 github.com/jmoiron/sqlx v1.4.0
github.com/jmoiron/sqlx v1.3.5
github.com/lib/pq v1.10.9 github.com/lib/pq v1.10.9
) )
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/pgx/v5 v5.5.5 // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
golang.org/x/crypto v0.17.0 // indirect
golang.org/x/sync v0.1.0 // indirect
golang.org/x/text v0.14.0 // indirect
)

36
go.sum
View file

@ -1,30 +1,10 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/gomarkdown/markdown v0.0.0-20231222211730-1d6d20845b47 h1:k4Tw0nt6lwro3Uin8eqoET7MDA4JnT8YgbCjc/g5E3k= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/gomarkdown/markdown v0.0.0-20231222211730-1d6d20845b47/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g=
github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ=
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

19
main.go
View file

@ -1,6 +1,7 @@
package main package main
import ( import (
"errors"
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
@ -8,11 +9,11 @@ import (
"path/filepath" "path/filepath"
"time" "time"
"arimelody.me/arimelody.me/admin" "arimelody-web/admin"
"arimelody.me/arimelody.me/api" "arimelody-web/api"
"arimelody.me/arimelody.me/global" "arimelody-web/global"
musicView "arimelody.me/arimelody.me/music/view" musicView "arimelody-web/music/view"
"arimelody.me/arimelody.me/templates" "arimelody-web/templates"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
_ "github.com/lib/pq" _ "github.com/lib/pq"
@ -22,8 +23,11 @@ const DEFAULT_PORT int = 8080
func main() { func main() {
// initialise database connection // initialise database connection
var dbHost = os.Getenv("ARIMELODY_DB_HOST")
if dbHost == "" { dbHost = "127.0.0.1" }
var err error var err error
global.DB, err = sqlx.Connect("postgres", "user=arimelody dbname=arimelody password=fuckingpassword sslmode=disable") global.DB, err = sqlx.Connect("postgres", "host=" + dbHost + " user=arimelody dbname=arimelody password=fuckingpassword sslmode=disable")
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "FATAL: Unable to create database connection pool: %v\n", err) fmt.Fprintf(os.Stderr, "FATAL: Unable to create database connection pool: %v\n", err)
os.Exit(1) os.Exit(1)
@ -64,9 +68,10 @@ func createServeMux() *http.ServeMux {
func staticHandler(directory string) http.Handler { func staticHandler(directory string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
info, err := os.Stat(filepath.Join(directory, filepath.Clean(r.URL.Path))) info, err := os.Stat(filepath.Join(directory, filepath.Clean(r.URL.Path)))
// does the file exist? // does the file exist?
if err != nil { if err != nil {
if os.IsNotExist(err) { if errors.Is(err, os.ErrNotExist) {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }

View file

@ -1,7 +1,7 @@
package music package music
import ( import (
"arimelody.me/arimelody.me/music/model" "arimelody-web/music/model"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
) )
@ -29,14 +29,14 @@ func GetAllArtists(db *sqlx.DB) ([]*model.Artist, error) {
return artists, nil return artists, nil
} }
func GetArtistsNotOnRelease(db *sqlx.DB, release *model.Release) ([]*model.Artist, error) { func GetArtistsNotOnRelease(db *sqlx.DB, releaseID string) ([]*model.Artist, error) {
var artists = []*model.Artist{} var artists = []*model.Artist{}
err := db.Select(&artists, err := db.Select(&artists,
"SELECT * FROM artist "+ "SELECT * FROM artist "+
"WHERE id NOT IN "+ "WHERE id NOT IN "+
"(SELECT artist FROM musiccredit WHERE release=$1)", "(SELECT artist FROM musiccredit WHERE release=$1)",
release.ID) releaseID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -44,6 +44,60 @@ func GetArtistsNotOnRelease(db *sqlx.DB, release *model.Release) ([]*model.Artis
return artists, nil return artists, nil
} }
func GetArtistCredits(db *sqlx.DB, artistID string, show_hidden bool) ([]*model.Credit, error) {
var query string = "SELECT release.id,release.title,release.artwork,artist.id,artist.name,artist.website,artist.avatar,role,is_primary "+
"FROM musiccredit "+
"JOIN musicrelease AS release ON release=release.id "+
"JOIN artist ON artist=artist.id "+
"WHERE artist=$1 "
if !show_hidden { query += "AND visible=true " }
query += "ORDER BY release_date DESC"
rows, err := db.Query(query, artistID)
if err != nil {
return nil, err
}
defer rows.Close()
type NamePrimary struct {
Name string `json:"name"`
Primary bool `json:"primary" db:"is_primary"`
}
var credits []*model.Credit
for rows.Next() {
var credit model.Credit
err = rows.Scan(
&credit.Release.ID,
&credit.Release.Title,
&credit.Release.Artwork,
&credit.Artist.ID,
&credit.Artist.Name,
&credit.Artist.Website,
&credit.Artist.Avatar,
&credit.Role,
&credit.Primary,
)
otherArtists := []NamePrimary{}
err = db.Select(&otherArtists,
"SELECT name,is_primary FROM artist "+
"JOIN musiccredit ON artist=id "+
"WHERE release=$1",
credit.Release.ID)
for _, otherCredit := range otherArtists {
credit.Release.Credits = append(credit.Release.Credits, &model.Credit{
Artist: model.Artist{
Name: otherCredit.Name,
},
Primary: otherCredit.Primary,
})
}
credits = append(credits, &credit)
}
return credits, nil
}
func CreateArtist(db *sqlx.DB, artist *model.Artist) error { func CreateArtist(db *sqlx.DB, artist *model.Artist) error {
_, err := db.Exec( _, err := db.Exec(
"INSERT INTO artist (id, name, website, avatar) "+ "INSERT INTO artist (id, name, website, avatar) "+
@ -77,11 +131,11 @@ func UpdateArtist(db *sqlx.DB, artist *model.Artist) error {
return nil return nil
} }
func DeleteArtist(db *sqlx.DB, artist *model.Artist) error { func DeleteArtist(db *sqlx.DB, artistID string) error {
_, err := db.Exec( _, err := db.Exec(
"DELETE FROM artist "+ "DELETE FROM artist "+
"WHERE id=$1", "WHERE id=$1",
artist.ID, artistID,
) )
if err != nil { if err != nil {
return err return err

View file

@ -1,71 +0,0 @@
package music
import (
"arimelody.me/arimelody.me/music/model"
"github.com/jmoiron/sqlx"
)
// DATABASE
func GetReleaseCredits(db *sqlx.DB, release *model.Release) ([]model.Credit, error) {
var credits = []model.Credit{}
err := db.Select(&credits,
"SELECT artist.*,role,is_primary FROM musiccredit "+
"JOIN artist ON artist=id "+
"WHERE release=$1",
release.ID,
)
if err != nil {
return nil, err
}
return credits, nil
}
func CreateCredit(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 UpdateCredit(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 DeleteCredit(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
}

View file

@ -1,64 +0,0 @@
package music
import (
"arimelody.me/arimelody.me/music/model"
"github.com/jmoiron/sqlx"
)
// DATABASE
func GetReleaseLinks(db *sqlx.DB, release *model.Release) ([]model.Link, error) {
var links = []model.Link{}
err := db.Select(&links, "SELECT name,url FROM musiclink WHERE release=$1", release.ID)
if err != nil {
return nil, err
}
return links, nil
}
func CreateLink(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 UpdateLink(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 DeleteLink(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

@ -1,54 +1,110 @@
package music package music
import ( import (
"arimelody.me/arimelody.me/music/model" "errors"
"fmt"
"arimelody-web/music/model"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
) )
func GetRelease(db *sqlx.DB, id string) (*model.Release, error) { func GetRelease(db *sqlx.DB, id string, full bool) (*model.Release, error) {
var releases = model.Release{} var release = model.Release{}
err := db.Get(&releases, "SELECT * FROM musicrelease WHERE id=$1", id) err := db.Get(&release, "SELECT * FROM musicrelease WHERE id=$1", id)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &releases, nil if full {
// get credits
credits, err := GetReleaseCredits(db, id)
if err != nil {
return nil, errors.New(fmt.Sprintf("Credits: %s", err))
}
for _, credit := range credits {
release.Credits = append(release.Credits, credit)
}
// get tracks
tracks, err := GetReleaseTracks(db, id)
if err != nil {
return nil, errors.New(fmt.Sprintf("Tracks: %s", err))
}
for _, track := range tracks {
release.Tracks = append(release.Tracks, track)
}
// get links
links, err := GetReleaseLinks(db, id)
if err != nil {
return nil, errors.New(fmt.Sprintf("Links: %s", err))
}
for _, link := range links {
release.Links = append(release.Links, link)
}
}
return &release, nil
} }
func GetAllReleases(db *sqlx.DB) ([]*model.Release, error) { func GetAllReleases(db *sqlx.DB, onlyVisible bool, limit int, full bool) ([]*model.Release, error) {
var releases = []*model.Release{} var releases = []*model.Release{}
err := db.Select(&releases, "SELECT * FROM musicrelease ORDER BY release_date DESC") query := "SELECT * FROM musicrelease"
if onlyVisible {
query += " WHERE visible=true"
}
query += " ORDER BY release_date DESC"
var err error
if limit > 0 {
err = db.Select(&releases, query + " LIMIT $1", limit)
} else {
err = db.Select(&releases, query)
}
if err != nil { if err != nil {
return nil, err return nil, err
} }
for _, release := range releases {
// get credits
credits, err := GetReleaseCredits(db, release.ID)
if err != nil {
return nil, errors.New(fmt.Sprintf("Credits: %s", err))
}
for _, credit := range credits {
release.Credits = append(release.Credits, credit)
}
if full {
// get tracks
tracks, err := GetReleaseTracks(db, release.ID)
if err != nil {
return nil, errors.New(fmt.Sprintf("Tracks: %s", err))
}
for _, track := range tracks {
release.Tracks = append(release.Tracks, track)
}
// get links
links, err := GetReleaseLinks(db, release.ID)
if err != nil {
return nil, errors.New(fmt.Sprintf("Links: %s", err))
}
for _, link := range links {
release.Links = append(release.Links, link)
}
}
}
return releases, nil return releases, nil
} }
func GetReleaseTracks(db *sqlx.DB, release *model.Release) ([]*model.Track, error) {
var tracks = []*model.Track{}
err := db.Select(&tracks,
"SELECT musictrack.* FROM musictrack "+
"JOIN musicreleasetrack ON track=id "+
"WHERE release=$1 "+
"ORDER BY number ASC",
release.ID,
)
if err != nil {
return nil, err
}
return tracks, nil
}
func CreateRelease(db *sqlx.DB, release *model.Release) error { func CreateRelease(db *sqlx.DB, release *model.Release) error {
_, err := db.Exec( _, err := db.Exec(
"INSERT INTO musicrelease "+ "INSERT INTO musicrelease "+
"(id, visible, title, description, type, release_date, artwork, buyname, buylink) "+ "(id, visible, title, description, type, release_date, artwork, buyname, buylink, copyright, copyrighturl) "+
"VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", "VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)",
release.ID, release.ID,
release.Visible, release.Visible,
release.Title, release.Title,
@ -58,6 +114,8 @@ func CreateRelease(db *sqlx.DB, release *model.Release) error {
release.Artwork, release.Artwork,
release.Buyname, release.Buyname,
release.Buylink, release.Buylink,
release.Copyright,
release.CopyrightURL,
) )
if err != nil { if err != nil {
return err return err
@ -69,7 +127,7 @@ func CreateRelease(db *sqlx.DB, release *model.Release) error {
func UpdateRelease(db *sqlx.DB, release *model.Release) error { func UpdateRelease(db *sqlx.DB, release *model.Release) error {
_, err := db.Exec( _, err := db.Exec(
"UPDATE musicrelease SET "+ "UPDATE musicrelease SET "+
"visible=$2, title=$3, description=$4, type=$5, release_date=$6, artwork=$7, buyname=$8, buylink=$9 "+ "visible=$2, title=$3, description=$4, type=$5, release_date=$6, artwork=$7, buyname=$8, buylink=$9, copyright=$10, copyrighturl=$11 "+
"WHERE id=$1", "WHERE id=$1",
release.ID, release.ID,
release.Visible, release.Visible,
@ -80,6 +138,8 @@ func UpdateRelease(db *sqlx.DB, release *model.Release) error {
release.Artwork, release.Artwork,
release.Buyname, release.Buyname,
release.Buylink, release.Buylink,
release.Copyright,
release.CopyrightURL,
) )
if err != nil { if err != nil {
return err return err
@ -88,49 +148,53 @@ func UpdateRelease(db *sqlx.DB, release *model.Release) error {
return nil return nil
} }
func UpdateReleaseTracks(db *sqlx.DB, release *model.Release, new_tracks []*model.Track) error { func UpdateReleaseTracks(db *sqlx.DB, releaseID string, new_tracks []string) error {
_, err := db.Exec( tx, err := db.Begin()
"DELETE FROM musicreleasetrack "+
"WHERE release=$1",
release.ID,
)
if err != nil { if err != nil {
return err return err
} }
for i, track := range new_tracks { _, err = tx.Exec("DELETE FROM musicreleasetrack WHERE release=$1", releaseID)
_, err = db.Exec( if err != nil {
return err
}
for i, trackID := range new_tracks {
_, err = tx.Exec(
"INSERT INTO musicreleasetrack "+ "INSERT INTO musicreleasetrack "+
"(release, track, number) "+ "(release, track, number) "+
"VALUES ($1, $2, $3)", "VALUES ($1, $2, $3)",
release.ID, releaseID,
track.ID, trackID,
i, i)
)
if err != nil { if err != nil {
return err return err
} }
} }
return nil err = tx.Commit()
}
func UpdateReleaseCredits(db *sqlx.DB, release *model.Release, new_credits []*model.Credit) error {
_, err := db.Exec(
"DELETE FROM musiccredit "+
"WHERE release=$1",
release.ID,
)
if err != nil { if err != nil {
return err return err
} }
return nil
}
func UpdateReleaseCredits(db *sqlx.DB, releaseID string, new_credits []*model.Credit) error {
tx, err := db.Begin()
if err != nil {
return err
}
_, err = tx.Exec("DELETE FROM musiccredit WHERE release=$1", releaseID)
if err != nil {
return err
}
for _, credit := range new_credits { for _, credit := range new_credits {
_, err = db.Exec( _, err = tx.Exec(
"INSERT INTO musiccredit "+ "INSERT INTO musiccredit "+
"(release, artist, role, is_primary) "+ "(release, artist, role, is_primary) "+
"VALUES ($1, $2, $3, $4)", "VALUES ($1, $2, $3, $4)",
release.ID, releaseID,
credit.Artist.ID, credit.Artist.ID,
credit.Role, credit.Role,
credit.Primary, credit.Primary,
@ -140,25 +204,31 @@ func UpdateReleaseCredits(db *sqlx.DB, release *model.Release, new_credits []*mo
} }
} }
return nil err = tx.Commit()
}
func UpdateReleaseLinks(db *sqlx.DB, release *model.Release, new_links []*model.Link) error {
_, err := db.Exec(
"DELETE FROM musiclink "+
"WHERE release=$1",
release.ID,
)
if err != nil { if err != nil {
return err return err
} }
return nil
}
func UpdateReleaseLinks(db *sqlx.DB, releaseID string, new_links []*model.Link) error {
tx, err := db.Begin()
if err != nil {
return err
}
_, err = tx.Exec("DELETE FROM musiclink WHERE release=$1", releaseID)
if err != nil {
return err
}
for _, link := range new_links { for _, link := range new_links {
_, err = db.Exec( fmt.Printf("%s: %s\n", link.Name, link.URL)
_, err := tx.Exec(
"INSERT INTO musiclink "+ "INSERT INTO musiclink "+
"(release, name, url) "+ "(release, name, url) "+
"VALUES ($1, $2, $3)", "VALUES ($1, $2, $3)",
release.ID, releaseID,
link.Name, link.Name,
link.URL, link.URL,
) )
@ -167,14 +237,19 @@ func UpdateReleaseLinks(db *sqlx.DB, release *model.Release, new_links []*model.
} }
} }
err = tx.Commit()
if err != nil {
return err
}
return nil return nil
} }
func DeleteRelease(db *sqlx.DB, release *model.Release) error { func DeleteRelease(db *sqlx.DB, releaseID string) error {
_, err := db.Exec( _, err := db.Exec(
"DELETE FROM musicrelease "+ "DELETE FROM musicrelease "+
"WHERE id=$1", "WHERE id=$1",
release.ID, releaseID,
) )
if err != nil { if err != nil {
return err return err
@ -183,33 +258,60 @@ func DeleteRelease(db *sqlx.DB, release *model.Release) error {
return nil return nil
} }
func GetFullRelease(db *sqlx.DB, release *model.Release) (*model.FullRelease, error) { func GetReleaseTracks(db *sqlx.DB, releaseID string) ([]*model.Track, error) {
// get credits var tracks = []*model.Track{}
credits, err := GetReleaseCredits(db, release)
err := db.Select(&tracks,
"SELECT musictrack.* FROM musictrack "+
"JOIN musicreleasetrack ON track=id "+
"WHERE release=$1 "+
"ORDER BY number ASC",
releaseID,
)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// get tracks return tracks, nil
dbTracks, err := GetReleaseTracks(db, release) }
if err != nil {
return nil, err func GetReleaseCredits(db *sqlx.DB, releaseID string) ([]*model.Credit, error) {
} rows, err := db.Query(
tracks := []model.DisplayTrack{} "SELECT artist.id,artist.name,artist.website,artist.avatar,role,is_primary "+
for i, track := range dbTracks { "FROM musiccredit "+
tracks = append(tracks, track.MakeDisplay(i + 1)) "JOIN artist ON artist=artist.id "+
} "JOIN musicrelease ON release=musicrelease.id "+
"WHERE musicrelease.id=$1 "+
// get links "ORDER BY is_primary DESC",
links, err := GetReleaseLinks(db, release) releaseID,
if err != nil { )
return nil, err if err != nil {
} return nil, err
}
return &model.FullRelease{
Release: release, var credits []*model.Credit
Tracks: tracks, for rows.Next() {
Credits: credits, credit := model.Credit{}
Links: links, rows.Scan(
}, nil &credit.Artist.ID,
&credit.Artist.Name,
&credit.Artist.Website,
&credit.Artist.Avatar,
&credit.Role,
&credit.Primary)
credits = append(credits, &credit)
}
return credits, nil
}
func GetReleaseLinks(db *sqlx.DB, releaseID string) ([]*model.Link, error) {
var links = []*model.Link{}
err := db.Select(&links, "SELECT name,url FROM musiclink WHERE release=$1", releaseID)
if err != nil {
return nil, err
}
return links, nil
} }

View file

@ -1,7 +1,7 @@
package music package music
import ( import (
"arimelody.me/arimelody.me/music/model" "arimelody-web/music/model"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
) )
@ -40,14 +40,14 @@ func GetOrphanTracks(db *sqlx.DB) ([]*model.Track, error) {
return tracks, nil return tracks, nil
} }
func GetTracksNotOnRelease(db *sqlx.DB, release *model.Release) ([]*model.Track, error) { func GetTracksNotOnRelease(db *sqlx.DB, releaseID string) ([]*model.Track, error) {
var tracks = []*model.Track{} var tracks = []*model.Track{}
err := db.Select(&tracks, err := db.Select(&tracks,
"SELECT * FROM musictrack "+ "SELECT * FROM musictrack "+
"WHERE id NOT IN "+ "WHERE id NOT IN "+
"(SELECT track FROM musicreleasetrack WHERE release=$1)", "(SELECT track FROM musicreleasetrack WHERE release=$1)",
release.ID) releaseID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -55,20 +55,58 @@ func GetTracksNotOnRelease(db *sqlx.DB, release *model.Release) ([]*model.Track,
return tracks, nil return tracks, nil
} }
func GetTrackReleases(db *sqlx.DB, track *model.Track) ([]*model.Release, error) { func GetTrackReleases(db *sqlx.DB, trackID string, full bool) ([]*model.Release, error) {
var releases = []*model.Release{} var releases = []*model.Release{}
err := db.Select(&releases, err := db.Select(&releases,
"SELECT musicrelease.* FROM musicrelease "+ "SELECT id,title,type,release_date,artwork,buylink "+
"FROM musicrelease "+
"JOIN musicreleasetrack ON release=id "+ "JOIN musicreleasetrack ON release=id "+
"WHERE track=$1 "+ "WHERE track=$1 "+
"ORDER BY release_date", "ORDER BY release_date",
track.ID, trackID,
) )
if err != nil { if err != nil {
return nil, err return nil, err
} }
type NamePrimary struct {
Name string `json:"name"`
Primary bool `json:"primary" db:"is_primary"`
}
for _, release := range releases {
// get artists
credits := []NamePrimary{}
err := db.Select(&credits,
"SELECT name,is_primary FROM artist "+
"JOIN musiccredit ON artist=artist.id "+
"JOIN musicrelease ON release=musicrelease.id "+
"WHERE musicrelease.id=$1", release.ID)
if err != nil {
return nil, err
}
for _, credit := range credits {
release.Credits = append(release.Credits, &model.Credit{
Artist: model.Artist{
Name: credit.Name,
},
Primary: credit.Primary,
})
}
// get tracks
tracks := []string{}
err = db.Select(&tracks, "SELECT track FROM musicreleasetrack WHERE release=$1", release.ID)
if err != nil {
return nil, err
}
for _, trackID := range tracks {
release.Tracks = append(release.Tracks, &model.Track{
ID: trackID,
})
}
}
return releases, nil return releases, nil
} }
@ -123,11 +161,11 @@ func UpdateTrack(db *sqlx.DB, track *model.Track) error {
return nil return nil
} }
func DeleteTrack(db *sqlx.DB, track *model.Track) error { func DeleteTrack(db *sqlx.DB, trackID string) error {
_, err := db.Exec( _, err := db.Exec(
"DELETE FROM musictrack "+ "DELETE FROM musictrack "+
"WHERE id=$1", "WHERE id=$1",
track.ID, trackID,
) )
if err != nil { if err != nil {
return err return err

View file

@ -1,7 +1,5 @@
package model package model
import "strings"
type ( type (
Artist struct { Artist struct {
ID string `json:"id"` ID string `json:"id"`
@ -21,56 +19,3 @@ func (artist Artist) GetAvatar() string {
} }
return artist.Avatar return artist.Avatar
} }
func (release FullRelease) 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 FullRelease) GetUniqueArtistNames(only_primary bool) []string {
var names = []string{}
for _, artist := range release.GetUniqueArtists(only_primary) {
names = append(names, artist.Name)
}
return names
}
func (release FullRelease) 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[:], ", ")
}
}

View file

@ -1,7 +1,10 @@
package model package model
type Credit struct { type (
Artist `json:"artist"` Credit struct {
Role string `json:"role"` Release Release `json:"release"`
Primary bool `json:"primary" db:"is_primary"` Artist Artist `json:"artist"`
} Role string `json:"role"`
Primary bool `json:"primary" db:"is_primary"`
}
)

View file

@ -1,6 +1,8 @@
package model package model
import ( import (
"html/template"
"strings"
"time" "time"
) )
@ -8,24 +10,20 @@ type (
ReleaseType string ReleaseType string
Release struct { Release struct {
ID string `json:"id"` ID string `json:"id"`
Visible bool `json:"visible"` Visible bool `json:"visible"`
Title string `json:"title"` Title string `json:"title"`
Description string `json:"description"` Description string `json:"description"`
ReleaseType ReleaseType `json:"type" db:"type"` ReleaseType ReleaseType `json:"type" db:"type"`
ReleaseDate time.Time `json:"releaseDate" db:"release_date"` ReleaseDate time.Time `json:"releaseDate" db:"release_date"`
Artwork string `json:"artwork"` Artwork string `json:"artwork"`
Buyname string `json:"buyname"` Buyname string `json:"buyname"`
Buylink string `json:"buylink"` Buylink string `json:"buylink"`
Copyright string `json:"copyright" db:"copyright"` Copyright string `json:"copyright" db:"copyright"`
CopyrightURL string `json:"copyrightURL" db:"copyrighturl"` CopyrightURL string `json:"copyrightURL" db:"copyrighturl"`
} Tracks []*Track `json:"tracks"`
Credits []*Credit `json:"credits"`
FullRelease struct { Links []*Link `json:"links"`
*Release
Tracks []DisplayTrack
Credits []Credit
Links []Link
} }
) )
@ -39,6 +37,10 @@ const (
// GETTERS // GETTERS
func (release Release) GetDescriptionHTML() template.HTML {
return template.HTML(strings.Replace(release.Description, "\n", "<br>", -1))
}
func (release Release) TextReleaseDate() string { func (release Release) TextReleaseDate() string {
return release.ReleaseDate.Format("2006-01-02T15:04") return release.ReleaseDate.Format("2006-01-02T15:04")
} }
@ -58,10 +60,39 @@ func (release Release) GetArtwork() string {
return release.Artwork return release.Artwork
} }
func (release FullRelease) IsSingle() bool { func (release Release) IsSingle() bool {
return len(release.Tracks) == 1; return len(release.Tracks) == 1;
} }
func (release Release) IsReleased() bool { func (release Release) IsReleased() bool {
return release.ReleaseDate.Before(time.Now()) return release.ReleaseDate.Before(time.Now())
} }
func (release Release) GetUniqueArtistNames(only_primary bool) []string {
names := []string{}
for _, credit := range release.Credits {
if only_primary && !credit.Primary { continue }
names = append(names, credit.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[:], ", ")
}
}

View file

@ -7,24 +7,23 @@ import (
type ( type (
Track struct { Track 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"`
Lyrics string `json:"lyrics"` Lyrics string `json:"lyrics" db:"lyrics"`
PreviewURL string `json:"previewURL" db:"preview_url"` PreviewURL string `json:"previewURL" db:"preview_url"`
}
DisplayTrack struct {
*Track
Lyrics template.HTML
Number int
} }
) )
func (track Track) MakeDisplay(number int) DisplayTrack { func (track Track) GetDescriptionHTML() template.HTML {
return DisplayTrack{ return template.HTML(strings.Replace(track.Description, "\n", "<br>", -1))
Track: &track, }
Lyrics: template.HTML(strings.Replace(track.Lyrics, "\n", "<br>", -1)),
Number: number, func (track Track) GetLyricsHTML() template.HTML {
} return template.HTML(strings.Replace(track.Lyrics, "\n", "<br>", -1))
}
// this function is stupid and i hate that i need it
func (track Track) Add(a int, b int) int {
return a + b
} }

View file

@ -4,10 +4,10 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"arimelody.me/arimelody.me/global" "arimelody-web/global"
music "arimelody.me/arimelody.me/music/controller" music "arimelody-web/music/controller"
"arimelody.me/arimelody.me/music/model" "arimelody-web/music/model"
"arimelody.me/arimelody.me/templates" "arimelody-web/templates"
) )
// HTTP HANDLER METHODS // HTTP HANDLER METHODS
@ -21,8 +21,7 @@ func Handler() http.Handler {
return return
} }
var release model.Release release, err := music.GetRelease(global.DB, r.URL.Path[1:], true)
err := global.DB.Get(&release, "SELECT * FROM musicrelease WHERE id=$1", r.URL.Path[1:])
if err != nil { if err != nil {
http.NotFound(w, r) http.NotFound(w, r)
return return
@ -36,25 +35,17 @@ func Handler() http.Handler {
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) {
dbReleases, err := music.GetAllReleases(global.DB) releases, err := music.GetAllReleases(global.DB, true, 0, true)
if err != nil { if err != nil {
fmt.Printf("FATAL: Failed to pull releases for catalog: %s\n", err) fmt.Printf("FATAL: Failed to pull releases for catalog: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
releases := []*model.FullRelease{}
for _, dbRelease := range dbReleases { for _, release := range releases {
if !dbRelease.Visible { continue } if !release.IsReleased() {
if !dbRelease.IsReleased() { release.ReleaseType = model.Upcoming
dbRelease.ReleaseType = model.Upcoming
} }
release, err := music.GetFullRelease(global.DB, dbRelease)
if err != nil {
fmt.Printf("FATAL: Failed to pull full release for %s: %s\n", dbRelease.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
releases = append(releases, release)
} }
err = templates.Pages["music"].Execute(w, releases) err = templates.Pages["music"].Execute(w, releases)

View file

@ -5,16 +5,35 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"arimelody.me/arimelody.me/admin" "arimelody-web/admin"
"arimelody.me/arimelody.me/global" "arimelody-web/global"
"arimelody.me/arimelody.me/music/model" "arimelody-web/music/model"
db "arimelody.me/arimelody.me/music/controller" db "arimelody-web/music/controller"
"arimelody.me/arimelody.me/templates" "arimelody-web/templates"
) )
// HTTP HANDLERS type (
Track struct {
Title string `json:"title"`
Description string `json:"description"`
Lyrics string `json:"lyrics"`
}
func ServeRelease(release model.Release) http.Handler { Credit struct {
*model.Artist
Role string `json:"role"`
Primary bool `json:"primary"`
}
Release struct {
*model.Release
Tracks []Track `json:"tracks"`
Credits []Credit `json:"credits"`
Links map[string]string `json:"links"`
}
)
func ServeRelease(release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// only allow authorised users to view hidden releases // only allow authorised users to view hidden releases
authorised := admin.GetSession(r) != nil authorised := admin.GetSession(r) != nil
@ -23,22 +42,65 @@ func ServeRelease(release model.Release) http.Handler {
return return
} }
fullRelease := &model.FullRelease{ response := Release{
Release: &release, Release: release,
Tracks: []Track{},
Credits: []Credit{},
Links: make(map[string]string),
} }
if authorised || release.IsReleased() { if authorised || release.IsReleased() {
fullerRelease, err := db.GetFullRelease(global.DB, &release) // get credits
credits, err := db.GetReleaseCredits(global.DB, release.ID)
if err != nil { if err != nil {
fmt.Printf("FATAL: Failed to pull full release data for %s: %s\n", release.ID, err) fmt.Printf("FATAL: Failed to serve release %s: Credits: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
fullRelease = fullerRelease for _, credit := range credits {
artist, err := db.GetArtist(global.DB, credit.Artist.ID)
if err != nil {
fmt.Printf("FATAL: Failed to serve release %s: Artists: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
response.Credits = append(response.Credits, Credit{
Artist: artist,
Role: credit.Role,
Primary: credit.Primary,
})
}
// get tracks
tracks, err := db.GetReleaseTracks(global.DB, release.ID)
if err != nil {
fmt.Printf("FATAL: Failed to serve release %s: Tracks: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
for _, track := range tracks {
response.Tracks = append(response.Tracks, Track{
Title: track.Title,
Description: track.Description,
Lyrics: track.Lyrics,
})
}
// get links
links, err := db.GetReleaseLinks(global.DB, release.ID)
if err != nil {
fmt.Printf("FATAL: Failed to serve release %s: Links: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
for _, link := range links {
response.Links[link.Name] = link.URL
}
} }
w.Header().Add("Content-Type", "application/json") w.Header().Add("Content-Type", "application/json")
err := json.NewEncoder(w).Encode(fullRelease) err := json.NewEncoder(w).Encode(response)
if err != nil { if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
@ -46,7 +108,7 @@ func ServeRelease(release model.Release) http.Handler {
}) })
} }
func ServeGateway(release model.Release) http.Handler { func ServeGateway(release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// only allow authorised users to view hidden releases // only allow authorised users to view hidden releases
authorised := admin.GetSession(r) != nil authorised := admin.GetSession(r) != nil
@ -55,21 +117,15 @@ func ServeGateway(release model.Release) http.Handler {
return return
} }
fullRelease := &model.FullRelease{ response := *release
Release: &release,
}
if authorised || release.IsReleased() { if authorised || release.IsReleased() {
fullerRelease, err := db.GetFullRelease(global.DB, &release) response.Tracks = release.Tracks
if err != nil { response.Credits = release.Credits
fmt.Printf("FATAL: Failed to pull full release data for %s: %s\n", release.ID, err) response.Links = release.Links
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
fullRelease = fullerRelease
} }
err := templates.Pages["music-gateway"].Execute(w, fullRelease) err := templates.Pages["music-gateway"].Execute(w, response)
if err != nil { if err != nil {
fmt.Printf("Error rendering music gateway for %s: %s\n", release.ID, err) fmt.Printf("Error rendering music gateway for %s: %s\n", release.ID, err)

View file

@ -296,6 +296,13 @@ div#info p {
display: inline-block; display: inline-block;
} }
#upcoming-release {
width: fit-content;
padding: .3em 1em;
font-size: 1em;
background: #101010;
}
ul#links { ul#links {
width: 100%; width: 100%;
margin: 1rem 0; margin: 1rem 0;
@ -311,6 +318,7 @@ ul#links li {
flex-grow: 1; flex-grow: 1;
} }
#buylink,
ul#links a { ul#links a {
width: calc(100% - 1.6em); width: calc(100% - 1.6em);
padding: .5em .8em; padding: .5em .8em;
@ -324,7 +332,9 @@ ul#links a {
transition: filter .1s,-webkit-filter .1s transition: filter .1s,-webkit-filter .1s
} }
ul#links a.buy { #buylink {
margin-top: 1em;
margin-bottom: -.5em;
background-color: #ff94e9 background-color: #ff94e9
} }
@ -354,7 +364,12 @@ ul#links a:hover {
} }
#description { #description {
font-size: 1.2em; font-size: 1.1em;
}
#copyright {
margin-bottom: 0;
font-size: .8em;
} }
#share { #share {
@ -390,7 +405,7 @@ ul#links a:hover {
animation: share-after 2s cubic-bezier(.5,0,1,.5) forwards animation: share-after 2s cubic-bezier(.5,0,1,.5) forwards
} }
h2 { #info h2 {
width: fit-content; width: fit-content;
padding: .3em 1em; padding: .3em 1em;
font-size: 1em; font-size: 1em;
@ -517,12 +532,11 @@ footer a:hover {
#art-container { #art-container {
width: 100%; width: 100%;
margin-bottom: 2rem;
} }
#artwork { #artwork {
width: auto; width: auto;
max-width: 50vw; max-width: 60vw;
height: auto; height: auto;
max-height: 50vh; max-height: 50vh;
} }
@ -536,21 +550,25 @@ footer a:hover {
gap: 2rem; gap: 2rem;
height: auto; height: auto;
overflow-y: auto; overflow-y: auto;
mask-image: none;
} }
div#info > div { div#info > div {
min-width: auto; min-width: auto;
min-height: auto; min-height: auto;
padding: 0; padding: 0;
margin: 0; margin: 2em 0 0 0;
overflow-y: unset; overflow-y: unset;
mask-image: none;
} }
div#info p { div#info p {
margin: 0 auto; margin: 0 auto;
} }
div#overview p {
margin: .5em auto;
}
div#extras { div#extras {
display: none; display: none;
} }
@ -583,10 +601,18 @@ footer a:hover {
list-style: none; list-style: none;
} }
#share.active: : after { #share.active::after {
transform: translate(calc(-50% - .6em),1.5em); transform: translate(calc(-50% - .6em),1.5em);
} }
#credits h2 {
margin: 0 auto;
}
#lyrics p.album-track-subheading {
margin-bottom: 1em;
}
footer { footer {
height: 4rem; height: 4rem;
font-size: .8rem; font-size: .8rem;
@ -596,6 +622,9 @@ footer a:hover {
display: none; display: none;
} }
#overlay {
background-size: 100vw 4px;
}
} }
@keyframes background-init { @keyframes background-init {

View file

@ -29,7 +29,7 @@
{{end}} {{end}}
{{define "content"}} {{define "content"}}
<main> <main >
<script type="module" src="/script/music-gateway.js"></script> <script type="module" src="/script/music-gateway.js"></script>
<div id="background" style="background-image: url({{.GetArtwork}})"></div> <div id="background" style="background-image: url({{.GetArtwork}})"></div>
@ -61,17 +61,14 @@
<p id="type" class="{{.ReleaseType}}">{{.ReleaseType}}</p> <p id="type" class="{{.ReleaseType}}">{{.ReleaseType}}</p>
{{else}} {{else}}
<p id="type" class="upcoming">upcoming</p> <p id="type" class="upcoming">upcoming</p>
<p>Releases: {{.PrintReleaseDate}}</p> <p id="upcoming-release">Releases: {{.PrintReleaseDate}}</p>
{{end}} {{end}}
{{if .IsReleased}} {{if .IsReleased}}
{{if .Buylink}}
<a href="{{.Buylink}}" id="buylink">{{or .Buyname "buy"}}</a>
{{end}}
<ul id="links"> <ul id="links">
{{if .Buylink}}
<li>
<a href="{{.Buylink}}" class="buy">{{or .Buyname "buy"}}</a>
</li>
{{end}}
{{range .Links}} {{range .Links}}
<li> <li>
<a class="{{.NormaliseName}}" href="{{.URL}}">{{.Name}}</a> <a class="{{.NormaliseName}}" href="{{.URL}}">{{.Name}}</a>
@ -82,7 +79,7 @@
{{if .Description}} {{if .Description}}
<p id="description">{{.Description}}</p> <p id="description">{{.GetDescriptionHTML}}</p>
{{else if .IsSingle}} {{else if .IsSingle}}
@ -93,6 +90,10 @@
{{end}} {{end}}
{{if and .Copyright .CopyrightURL}}
<p id="copyright">{{.Title}} &copy; {{.GetReleaseYear}} by {{.PrintArtists true true}} is licensed under <a href="{{.CopyrightURL}}" target="_blank">{{.Copyright}}</a></p>
{{end}}
<button id="share">share</button> <button id="share">share</button>
</div> </div>
@ -121,7 +122,7 @@
<div id="lyrics"> <div id="lyrics">
<p class="album-track-subheading">LYRICS</p> <p class="album-track-subheading">LYRICS</p>
{{if $Track.Lyrics}} {{if $Track.Lyrics}}
{{$Track.Lyrics}} {{$Track.GetLyricsHTML}}
{{else}} {{else}}
<span class="empty">No lyrics.</span> <span class="empty">No lyrics.</span>
{{end}} {{end}}
@ -133,7 +134,7 @@
<h2>TRACKS</h2> <h2>TRACKS</h2>
{{range $i, $track := .Tracks}} {{range $i, $track := .Tracks}}
<details> <details>
<summary class="album-track-title">{{$track.Number}}. {{$track.Title}}</summary> <summary class="album-track-title">{{$track.Add $i 1}}. {{$track.Title}}</summary>
{{if $track.Description}} {{if $track.Description}}
<p class="album-track-subheading">DESCRIPTION</p> <p class="album-track-subheading">DESCRIPTION</p>
@ -142,7 +143,7 @@
<p class="album-track-subheading">LYRICS</p> <p class="album-track-subheading">LYRICS</p>
{{if $track.Lyrics}} {{if $track.Lyrics}}
{{$track.Lyrics}} {{$track.GetLyricsHTML}}
{{else}} {{else}}
<span class="empty">No lyrics.</span> <span class="empty">No lyrics.</span>
{{end}} {{end}}