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"
cmd = "go build -o ./tmp/main ."
delay = 1000
exclude_dir = ["admin\\static", "public", "uploads"]
exclude_dir = ["admin/static", "public", "uploads", "test", "db"]
exclude_file = []
exclude_regex = ["_test.go"]
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
.idea/
db/
tmp/
test/
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 (
"fmt"
"math/rand"
"os"
"time"
"arimelody.me/arimelody.me/global"
"arimelody-web/global"
)
type (
@ -28,9 +29,9 @@ var ADMIN_BYPASS = func() bool {
}()
var ADMIN_ID_DISCORD = func() string {
id := global.Args["discordAdmin"]
id := os.Getenv("DISCORD_ADMIN")
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
}()

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.draggable = true;
el.addEventListener("dragstart", () => { el.classList.add("moving") });
el.addEventListener("dragend", () => { el.classList.remove("moving") });
}

View file

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

View file

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

View file

@ -5,23 +5,24 @@ import (
"net/http"
"strings"
"arimelody.me/arimelody.me/global"
"arimelody.me/arimelody.me/music/model"
controller "arimelody.me/arimelody.me/music/controller"
"arimelody-web/global"
db "arimelody-web/music/controller"
"arimelody-web/music/model"
)
func serveRelease() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
slices := strings.Split(r.URL.Path[1:], "/")
releaseID := slices[0]
release, err := controller.GetRelease(global.DB, releaseID)
release, err := db.GetRelease(global.DB, releaseID, true)
if err != nil {
fmt.Printf("FATAL: Failed to pull release %s: %s\n", releaseID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r)
return
}
if release == nil {
http.NotFound(w, r)
fmt.Printf("FATAL: Failed to pull full release data for %s: %s\n", releaseID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@ -31,32 +32,25 @@ func serveRelease() http.Handler {
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 {
switch slices[1] {
case "editcredits":
serveEditCredits(fullRelease).ServeHTTP(w, r)
serveEditCredits(release).ServeHTTP(w, r)
return
case "addcredit":
serveAddCredit(fullRelease).ServeHTTP(w, r)
serveAddCredit(release).ServeHTTP(w, r)
return
case "newcredit":
serveNewCredit().ServeHTTP(w, r)
return
case "editlinks":
serveEditLinks(fullRelease).ServeHTTP(w, r)
serveEditLinks(release).ServeHTTP(w, r)
return
case "edittracks":
serveEditTracks(fullRelease).ServeHTTP(w, r)
serveEditTracks(release).ServeHTTP(w, r)
return
case "addtrack":
serveAddTrack(fullRelease).ServeHTTP(w, r)
serveAddTrack(release).ServeHTTP(w, r)
return
case "newtrack":
serveNewTrack().ServeHTTP(w, r)
@ -66,7 +60,7 @@ func serveRelease() http.Handler {
return
}
err = pages["release"].Execute(w, fullRelease)
err = pages["release"].Execute(w, release)
if err != nil {
fmt.Printf("Error rendering admin release page for %s: %s\n", release.ID, err)
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) {
w.Header().Set("Content-Type", "text/html")
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) {
artists, err := controller.GetArtistsNotOnRelease(global.DB, release.Release)
artists, err := db.GetArtistsNotOnRelease(global.DB, release.ID)
if err != nil {
fmt.Printf("FATAL: Failed to pull artists not on %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -114,7 +108,7 @@ func serveAddCredit(release *model.FullRelease) http.Handler {
func serveNewCredit() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
artistID := strings.Split(r.URL.Path, "/")[3]
artist, err := controller.GetArtist(global.DB, artistID)
artist, err := db.GetArtist(global.DB, artistID)
if err != nil {
fmt.Printf("FATAL: Failed to pull artists %s: %s\n", artistID, err)
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) {
w.Header().Set("Content-Type", "text/html")
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) {
w.Header().Set("Content-Type", "text/html")
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) {
tracks, err := controller.GetTracksNotOnRelease(global.DB, release.Release)
tracks, err := db.GetTracksNotOnRelease(global.DB, release.ID)
if err != nil {
fmt.Printf("FATAL: Failed to pull tracks not on %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -186,7 +180,7 @@ func serveAddTrack(release *model.FullRelease) http.Handler {
func serveNewTrack() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
trackID := strings.Split(r.URL.Path, "/")[3]
track, err := controller.GetTrack(global.DB, trackID)
track, err := db.GetTrack(global.DB, trackID)
if err != nil {
fmt.Printf("Error rendering new track component for %s: %s\n", trackID, err)
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 {
width: 200px;
text-align: center;
}
.release-artwork img {
width: 100%;
aspect-ratio: 1;
@ -28,6 +28,9 @@ input[type="text"] {
outline: 1px solid #808080;
cursor: pointer;
}
.release-artwork #remove-artwork {
padding: .3em .4em;
}
.release-info {
width: 0;
@ -342,7 +345,7 @@ dialog div.dialog-actions {
background-color: #8cff83
}
.card.links a.button[data-name="applemusic"] {
.card.links a.button[data-name="apple music"] {
background-color: #8cd9ff
}
@ -548,3 +551,14 @@ dialog div.dialog-actions {
#addtrack ul li.new-track:hover {
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 titleInput = document.getElementById("title");
const artworkImg = document.getElementById("artwork");
const removeArtworkBtn = document.getElementById("remove-artwork");
const artworkInput = document.getElementById("artwork-file");
const typeInput = document.getElementById("type");
const descInput = document.getElementById("description");
const dateInput = document.getElementById("release-date");
const buynameInput = document.getElementById("buyname");
const buylinkInput = document.getElementById("buylink");
const copyrightInput = document.getElementById("copyright");
const copyrightURLInput = document.getElementById("copyright-url");
const visInput = document.getElementById("visibility");
const saveBtn = document.getElementById("save");
const deleteBtn = document.getElementById("delete");
var artworkData = artworkImg.attributes.src.value;
var edited = new Stateful(false);
var releaseData = updateData(undefined);
function updateData(old) {
var releaseData = {
saveBtn.addEventListener("click", () => {
fetch("/api/v1/music/" + releaseID, {
method: "PUT",
body: JSON.stringify({
visible: visInput.value === "true",
title: titleInput.value,
description: descInput.value,
type: typeInput.value,
releaseDate: dateInput.value,
releaseDate: dateInput.value + ":00Z",
artwork: artworkData,
buyname: buynameInput.value,
buylink: buylinkInput.value,
};
if (releaseData && releaseData != old) {
edited.set(true);
}
return releaseData;
}
function saveRelease() {
console.table(releaseData);
fetch("/api/v1/music/" + releaseID, {
method: "PUT",
body: JSON.stringify(releaseData),
copyright: copyrightInput.value,
copyrightURL: copyrightURLInput.value,
}),
headers: { "Content-Type": "application/json" }
}).then(res => {
if (!res.ok) {
@ -54,9 +43,13 @@ function saveRelease() {
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, {
method: "DELETE",
}).then(res => {
@ -70,15 +63,17 @@ function deleteRelease() {
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", () => {
artworkInput.addEventListener("change", () => {
if (artworkInput.files.length > 0) {
@ -87,41 +82,17 @@ artworkImg.addEventListener("click", () => {
const data = e.target.result;
artworkImg.src = data;
artworkData = data;
releaseData = updateData(releaseData);
saveBtn.disabled = false;
};
reader.readAsDataURL(artworkInput.files[0]);
}
});
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", () => {
if (releaseID != prompt(
"You are about to permanently delete " + releaseID + ". " +
"This action is irreversible. " +
"Please enter \"" + releaseID + "\" to continue.")) return;
deleteRelease();
removeArtworkBtn.addEventListener("click", () => {
artworkImg.src = "/img/default-cover-art.png"
artworkData = "";
saveBtn.disabled = false;
});

View file

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

View file

@ -1,4 +1,5 @@
const newReleaseBtn = document.getElementById("create-release");
const newArtistBtn = document.getElementById("create-artist");
const newTrackBtn = document.getElementById("create-track");
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 => {
event.preventDefault();
const title = prompt("Enter an title for this track:");
@ -43,7 +68,7 @@ newTrackBtn.addEventListener("click", event => {
}
})
}).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);
});
});

View file

@ -29,6 +29,11 @@ var pages = map[string]*template.Template{
filepath.Join("views", "prideflag.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(
filepath.Join("admin", "views", "layout.html"),
filepath.Join("views", "prideflag.html"),

View file

@ -5,9 +5,9 @@ import (
"net/http"
"strings"
"arimelody.me/arimelody.me/global"
"arimelody.me/arimelody.me/music/model"
"arimelody.me/arimelody.me/music/controller"
"arimelody-web/global"
"arimelody-web/music/model"
"arimelody-web/music/controller"
)
func serveTrack() http.Handler {
@ -25,26 +25,16 @@ func serveTrack() http.Handler {
return
}
dbReleases, err := music.GetTrackReleases(global.DB, track)
releases, err := music.GetTrackReleases(global.DB, track.ID, true)
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)
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 {
*model.Track
Releases []model.FullRelease
Releases []*model.Release
}
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">
<img src="{{.Artwork}}" alt="" width="256" loading="lazy" id="artwork">
<input type="file" id="artwork-file" name="Artwork" accept=".png,.jpg,.jpeg" hidden>
<button id="remove-artwork">Remove</button>
</div>
<div class="release-info">
<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>
<table>
<tr>
@ -53,19 +54,31 @@
<tr>
<td>Release Date</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>
</tr>
<tr>
<td>Buy Name</td>
<td>
<input type="text" name="Buy Name" id="buyname" value="{{.Buyname}}">
<input type="text" name="buyname" id="buyname" value="{{.Buyname}}" autocomplete="on">
</td>
</tr>
<tr>
<td>Buy Link</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>
</tr>
<tr>
@ -99,7 +112,7 @@
<div class="credit">
<img src="{{.Artist.GetAvatar}}" alt="" width="64" loading="lazy" class="artist-avatar">
<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">
{{.Role}}
{{if .Primary}}
@ -139,23 +152,23 @@
>Edit</a>
</div>
<div class="card tracks">
{{range .Tracks}}
<div class="track" data-id="{{.ID}}">
{{range $i, $track := .Tracks}}
<div class="track" data-id="{{$track.ID}}">
<h2 class="track-title">
<span class="track-number">{{.Number}}</span>
<a href="/admin/track/{{.ID}}">{{.Title}}</a>
<span class="track-number">{{.Add $i 1}}</span>
<a href="/admin/track/{{$track.ID}}">{{$track.Title}}</a>
</h2>
<h3>Description</h3>
{{if .Description}}
<p class="track-description">{{.Description}}</p>
{{if $track.Description}}
<p class="track-description">{{$track.GetDescriptionHTML}}</p>
{{else}}
<p class="track-description empty">No description provided.</p>
{{end}}
<h3>Lyrics</h3>
{{if .Lyrics}}
<p class="track-lyrics">{{.Lyrics}}</p>
{{if $track.Lyrics}}
<p class="track-lyrics">{{$track.GetLyricsHTML}}</p>
{{else}}
<p class="track-lyrics empty">There are no lyrics.</p>
{{end}}

View file

@ -22,13 +22,13 @@
<div class="card-title">
<h1>Artists</h1>
<a class="create-btn">Create New</a>
<a class="create-btn" id="create-artist">Create New</a>
</div>
<div class="card artists">
{{range $Artist := .Artists}}
<div class="artist">
<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>
{{end}}
{{if not .Artists}}
@ -49,12 +49,12 @@
<a href="/admin/track/{{$Track.ID}}">{{$Track.Title}}</a>
</h2>
{{if $Track.Description}}
<p class="track-description">{{$Track.Description}}</p>
<p class="track-description">{{$Track.GetDescriptionHTML}}</p>
{{else}}
<p class="track-description empty">No description provided.</p>
{{end}}
{{if $Track.Lyrics}}
<p class="track-lyrics">{{$Track.Lyrics}}</p>
<p class="track-lyrics">{{$Track.GetLyricsHTML}}</p>
{{else}}
<p class="track-lyrics empty">There are no lyrics.</p>
{{end}}

View file

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

View file

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

View file

@ -10,54 +10,51 @@ import (
"strings"
"time"
"arimelody.me/arimelody.me/admin"
"arimelody.me/arimelody.me/global"
"arimelody.me/arimelody.me/music/model"
"arimelody-web/admin"
"arimelody-web/global"
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 {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
type catalogItem struct {
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")
releases, err := music.GetAllReleases(global.DB, false, 0, true)
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
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
for _, release := range releases {
if !release.Visible && !authorised {
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,
Title: release.Title,
Artists: artists,
ReleaseType: release.ReleaseType,
ReleaseDate: release.ReleaseDate,
Artwork: release.Artwork,
Buylink: release.Buylink,
Copyright: release.Copyright,
})
}
@ -77,80 +74,34 @@ func CreateRelease() http.Handler {
return
}
var data releaseBodyJSON
err := json.NewDecoder(r.Body).Decode(&data)
var release model.Release
err := json.NewDecoder(r.Body).Decode(&release)
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
if data.ID == "" {
if release.ID == "" {
http.Error(w, "Release ID cannot be empty\n", http.StatusBadRequest)
return
}
title := data.ID
if data.Title != nil && *data.Title != "" {
title = *data.Title
if release.Title == "" { release.Title = release.ID }
if release.ReleaseType == "" { release.ReleaseType = model.Single }
if release.ReleaseDate != time.Unix(0, 0) {
release.ReleaseDate = time.Date(time.Now().Year(), time.Now().Month(), time.Now().Day(), 0, 0, 0, 0, time.UTC)
}
description := ""
if data.Description != nil && *data.Description != "" { description = *data.Description }
if release.Artwork == "" { release.Artwork = "/img/default-cover-art.png" }
releaseType := model.Single
if data.ReleaseType != nil && *data.ReleaseType != "" { releaseType = *data.ReleaseType }
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 data.Artwork != nil && *data.Artwork != "" { artwork = *data.Artwork }
buyname := ""
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)
err = music.CreateRelease(global.DB, &release)
if err != nil {
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
}
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)
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) {
if r.URL.Path == "/" {
http.NotFound(w, r)
@ -173,14 +124,6 @@ func UpdateRelease(release model.Release) http.Handler {
}
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 {
switch segments[1] {
@ -199,30 +142,19 @@ func UpdateRelease(release model.Release) http.Handler {
return
}
var data releaseBodyJSON
err = json.NewDecoder(r.Body).Decode(&data)
err := json.NewDecoder(r.Body).Decode(&release)
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)
return
}
if data.ID != "" { release.ID = data.ID }
if data.Visible != nil { release.Visible = *data.Visible }
if data.Title != nil { release.Title = *data.Title }
if data.Description != nil { release.Description = *data.Description }
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,") {
if release.Artwork == "" {
release.Artwork = "/img/default-cover-art.png"
} else {
if strings.Contains(release.Artwork, ";base64,") {
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
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)
} else {
release.Artwork = *data.Artwork
}
}
if data.Buyname != nil { release.Buyname = *data.Buyname }
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)
err = music.UpdateRelease(global.DB, release)
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)
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) {
var trackIDs = []string{}
err := json.NewDecoder(r.Body).Decode(&trackIDs)
@ -275,26 +194,19 @@ func UpdateReleaseTracks(release model.Release) http.Handler {
return
}
tx := global.DB.MustBegin()
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()
err = music.UpdateReleaseTracks(global.DB, release.ID, trackIDs)
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)
}
})
}
func UpdateReleaseCredits(release model.Release) http.Handler {
func UpdateReleaseCredits(release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
type creditJSON struct {
Artist string
@ -308,52 +220,34 @@ func UpdateReleaseCredits(release model.Release) http.Handler {
return
}
// clear duplicates
type Credit struct {
Role string
Primary bool
}
var credits = map[string]Credit{}
var credits []*model.Credit
for _, credit := range data {
credits[credit.Artist] = Credit{
credits = append(credits, &model.Credit{
Artist: model.Artist{
ID: credit.Artist,
},
Role: credit.Role,
Primary: credit.Primary,
}
})
}
tx := global.DB.MustBegin()
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()
err = music.UpdateReleaseCredits(global.DB, release.ID, credits)
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)
}
})
}
func UpdateReleaseLinks(release model.Release) http.Handler {
func UpdateReleaseLinks(release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
http.NotFound(w, r)
@ -367,30 +261,27 @@ func UpdateReleaseLinks(release model.Release) http.Handler {
return
}
tx := global.DB.MustBegin()
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()
err = music.UpdateReleaseLinks(global.DB, release.ID, links)
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)
}
})
}
func DeleteRelease(release model.Release) http.Handler {
func DeleteRelease(release *model.Release) http.Handler {
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 {
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)
}
})

View file

@ -5,24 +5,40 @@ import (
"fmt"
"net/http"
"arimelody.me/arimelody.me/global"
"arimelody.me/arimelody.me/music/model"
"arimelody-web/global"
music "arimelody-web/music/controller"
"arimelody-web/music/model"
)
type (
Track struct {
*model.Track
Releases []string `json:"releases"`
}
)
func ServeAllTracks() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
type track struct {
type Track struct {
ID string `json:"id"`
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 {
fmt.Printf("FATAL: Failed to pull tracks from DB: %s\n", err)
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")
err = json.NewEncoder(w).Encode(tracks)
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) {
if r.URL.Path == "/" {
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)
dbReleases, err := music.GetTrackReleases(global.DB, track.ID, false)
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 {
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)
}
type response struct {
model.Track
Releases []*model.Release
releases := []string{}
for _, release := range dbReleases {
releases = append(releases, release.ID)
}
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 {
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)
}
})
@ -92,15 +89,7 @@ func CreateTrack() http.Handler {
return
}
var trackID string
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)
id, err := music.CreateTrack(global.DB, &track)
if err != nil {
fmt.Printf("FATAL: Failed to create track: %s\n", err)
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.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) {
if r.Method != http.MethodPut || r.URL.Path == "/" {
http.NotFound(w, r)
return
}
var update model.Track
err := json.NewDecoder(r.Body).Decode(&update)
err := json.NewDecoder(r.Body).Decode(&track)
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
if update.Title == "" {
if track.Title == "" {
http.Error(w, "Track title cannot be empty\n", http.StatusBadRequest)
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
}
_, 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)
err = music.UpdateTrack(global.DB, track)
if err != nil {
fmt.Printf("Failed to update track %s: %s\n", track.ID, err)
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) {
if r.Method != http.MethodDelete || r.URL.Path == "/" {
http.NotFound(w, r)
@ -171,10 +143,7 @@ func DeleteTrack(track model.Track) http.Handler {
}
var trackID = r.URL.Path[1:]
_, err := global.DB.Exec(
"DELETE FROM musictrack "+
"WHERE id=$1",
trackID)
err := music.DeleteTrack(global.DB, trackID)
if err != nil {
fmt.Printf("Failed to delete track %s: %s\n", trackID, err)
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()
// TODO: generate compressed versions of image (512x512?)
buffer := bufio.NewWriter(file)
_, err = buffer.Write(imageData)
if err != nil {

View file

@ -6,26 +6,27 @@ import (
"fmt"
"net/http"
"net/url"
"os"
"strings"
"arimelody.me/arimelody.me/global"
"arimelody-web/global"
)
const API_ENDPOINT = "https://discord.com/api/v10"
var CREDENTIALS_PROVIDED = true
var CLIENT_ID = func() string {
id := global.Args["discordClient"]
id := os.Getenv("DISCORD_CLIENT")
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
}
return id
}()
var CLIENT_SECRET = func() string {
secret := global.Args["discordSecret"]
if secret== "" {
fmt.Printf("WARN: Discord secret (-discordSecret) was not provided. Admin login will be unavailable.\n")
secret := os.Getenv("DISCORD_SECRET")
if secret == "" {
fmt.Printf("WARN: Discord secret (DISCORD_SECRET) was not provided. Admin login will be unavailable.\n")
CREDENTIALS_PROVIDED = false
}
return secret
@ -107,7 +108,6 @@ func GetDiscordUserFromAuth(token string) (DiscordUser, error) {
}
auth_info := AuthInfoResponse{}
err = json.NewDecoder(res.Body).Decode(&auth_info)
if err != nil {
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 {
domain := Args["httpDomain"]
if domain != "" {
return domain
}
domain := os.Getenv("HTTP_DOMAIN")
if domain == "" {
return "https://arimelody.me"
}
return domain
}()
var DB *sqlx.DB

View file

@ -6,7 +6,7 @@ import (
"strconv"
"time"
"arimelody.me/arimelody.me/colour"
"arimelody-web/colour"
)
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
require (
github.com/gomarkdown/markdown v0.0.0-20231222211730-1d6d20845b47
github.com/jmoiron/sqlx v1.3.5
github.com/jmoiron/sqlx v1.4.0
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=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/gomarkdown/markdown v0.0.0-20231222211730-1d6d20845b47 h1:k4Tw0nt6lwro3Uin8eqoET7MDA4JnT8YgbCjc/g5E3k=
github.com/gomarkdown/markdown v0.0.0-20231222211730-1d6d20845b47/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
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=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
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/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
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=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=

19
main.go
View file

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

View file

@ -1,7 +1,7 @@
package music
import (
"arimelody.me/arimelody.me/music/model"
"arimelody-web/music/model"
"github.com/jmoiron/sqlx"
)
@ -29,14 +29,14 @@ func GetAllArtists(db *sqlx.DB) ([]*model.Artist, error) {
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{}
err := db.Select(&artists,
"SELECT * FROM artist "+
"WHERE id NOT IN "+
"(SELECT artist FROM musiccredit WHERE release=$1)",
release.ID)
releaseID)
if err != nil {
return nil, err
}
@ -44,6 +44,60 @@ func GetArtistsNotOnRelease(db *sqlx.DB, release *model.Release) ([]*model.Artis
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 {
_, err := db.Exec(
"INSERT INTO artist (id, name, website, avatar) "+
@ -77,11 +131,11 @@ func UpdateArtist(db *sqlx.DB, artist *model.Artist) error {
return nil
}
func DeleteArtist(db *sqlx.DB, artist *model.Artist) error {
func DeleteArtist(db *sqlx.DB, artistID string) error {
_, err := db.Exec(
"DELETE FROM artist "+
"WHERE id=$1",
artist.ID,
artistID,
)
if err != nil {
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
import (
"arimelody.me/arimelody.me/music/model"
"errors"
"fmt"
"arimelody-web/music/model"
"github.com/jmoiron/sqlx"
)
func GetRelease(db *sqlx.DB, id string) (*model.Release, error) {
var releases = model.Release{}
func GetRelease(db *sqlx.DB, id string, full bool) (*model.Release, error) {
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 {
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{}
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 {
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
}
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 {
_, err := db.Exec(
"INSERT INTO musicrelease "+
"(id, visible, title, description, type, release_date, artwork, buyname, buylink) "+
"VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
"(id, visible, title, description, type, release_date, artwork, buyname, buylink, copyright, copyrighturl) "+
"VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)",
release.ID,
release.Visible,
release.Title,
@ -58,6 +114,8 @@ func CreateRelease(db *sqlx.DB, release *model.Release) error {
release.Artwork,
release.Buyname,
release.Buylink,
release.Copyright,
release.CopyrightURL,
)
if err != nil {
return err
@ -69,7 +127,7 @@ func CreateRelease(db *sqlx.DB, release *model.Release) error {
func UpdateRelease(db *sqlx.DB, release *model.Release) error {
_, err := db.Exec(
"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",
release.ID,
release.Visible,
@ -80,6 +138,8 @@ func UpdateRelease(db *sqlx.DB, release *model.Release) error {
release.Artwork,
release.Buyname,
release.Buylink,
release.Copyright,
release.CopyrightURL,
)
if err != nil {
return err
@ -88,49 +148,53 @@ func UpdateRelease(db *sqlx.DB, release *model.Release) error {
return nil
}
func UpdateReleaseTracks(db *sqlx.DB, release *model.Release, new_tracks []*model.Track) error {
_, err := db.Exec(
"DELETE FROM musicreleasetrack "+
"WHERE release=$1",
release.ID,
)
func UpdateReleaseTracks(db *sqlx.DB, releaseID string, new_tracks []string) error {
tx, err := db.Begin()
if err != nil {
return err
}
for i, track := range new_tracks {
_, err = db.Exec(
_, err = tx.Exec("DELETE FROM musicreleasetrack WHERE release=$1", releaseID)
if err != nil {
return err
}
for i, trackID := range new_tracks {
_, err = tx.Exec(
"INSERT INTO musicreleasetrack "+
"(release, track, number) "+
"VALUES ($1, $2, $3)",
release.ID,
track.ID,
i,
)
releaseID,
trackID,
i)
if err != nil {
return err
}
}
err = tx.Commit()
if err != nil {
return err
}
return nil
}
func UpdateReleaseCredits(db *sqlx.DB, release *model.Release, new_credits []*model.Credit) error {
_, err := db.Exec(
"DELETE FROM musiccredit "+
"WHERE release=$1",
release.ID,
)
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 {
_, err = db.Exec(
_, err = tx.Exec(
"INSERT INTO musiccredit "+
"(release, artist, role, is_primary) "+
"VALUES ($1, $2, $3, $4)",
release.ID,
releaseID,
credit.Artist.ID,
credit.Role,
credit.Primary,
@ -140,25 +204,31 @@ func UpdateReleaseCredits(db *sqlx.DB, release *model.Release, new_credits []*mo
}
}
return nil
}
func UpdateReleaseLinks(db *sqlx.DB, release *model.Release, new_links []*model.Link) error {
_, err := db.Exec(
"DELETE FROM musiclink "+
"WHERE release=$1",
release.ID,
)
err = tx.Commit()
if err != nil {
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 {
_, err = db.Exec(
fmt.Printf("%s: %s\n", link.Name, link.URL)
_, err := tx.Exec(
"INSERT INTO musiclink "+
"(release, name, url) "+
"VALUES ($1, $2, $3)",
release.ID,
releaseID,
link.Name,
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
}
func DeleteRelease(db *sqlx.DB, release *model.Release) error {
func DeleteRelease(db *sqlx.DB, releaseID string) error {
_, err := db.Exec(
"DELETE FROM musicrelease "+
"WHERE id=$1",
release.ID,
releaseID,
)
if err != nil {
return err
@ -183,33 +258,60 @@ func DeleteRelease(db *sqlx.DB, release *model.Release) error {
return nil
}
func GetFullRelease(db *sqlx.DB, release *model.Release) (*model.FullRelease, error) {
// get credits
credits, err := GetReleaseCredits(db, release)
func GetReleaseTracks(db *sqlx.DB, releaseID string) ([]*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",
releaseID,
)
if err != nil {
return nil, err
}
// get tracks
dbTracks, err := GetReleaseTracks(db, release)
if err != nil {
return nil, err
}
tracks := []model.DisplayTrack{}
for i, track := range dbTracks {
tracks = append(tracks, track.MakeDisplay(i + 1))
}
// get links
links, err := GetReleaseLinks(db, release)
if err != nil {
return nil, err
}
return &model.FullRelease{
Release: release,
Tracks: tracks,
Credits: credits,
Links: links,
}, nil
return tracks, nil
}
func GetReleaseCredits(db *sqlx.DB, releaseID string) ([]*model.Credit, error) {
rows, err := db.Query(
"SELECT artist.id,artist.name,artist.website,artist.avatar,role,is_primary "+
"FROM musiccredit "+
"JOIN artist ON artist=artist.id "+
"JOIN musicrelease ON release=musicrelease.id "+
"WHERE musicrelease.id=$1 "+
"ORDER BY is_primary DESC",
releaseID,
)
if err != nil {
return nil, err
}
var credits []*model.Credit
for rows.Next() {
credit := model.Credit{}
rows.Scan(
&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
import (
"arimelody.me/arimelody.me/music/model"
"arimelody-web/music/model"
"github.com/jmoiron/sqlx"
)
@ -40,14 +40,14 @@ func GetOrphanTracks(db *sqlx.DB) ([]*model.Track, error) {
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{}
err := db.Select(&tracks,
"SELECT * FROM musictrack "+
"WHERE id NOT IN "+
"(SELECT track FROM musicreleasetrack WHERE release=$1)",
release.ID)
releaseID)
if err != nil {
return nil, err
}
@ -55,20 +55,58 @@ func GetTracksNotOnRelease(db *sqlx.DB, release *model.Release) ([]*model.Track,
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{}
err := db.Select(&releases,
"SELECT musicrelease.* FROM musicrelease "+
"SELECT id,title,type,release_date,artwork,buylink "+
"FROM musicrelease "+
"JOIN musicreleasetrack ON release=id "+
"WHERE track=$1 "+
"ORDER BY release_date",
track.ID,
trackID,
)
if err != nil {
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
}
@ -123,11 +161,11 @@ func UpdateTrack(db *sqlx.DB, track *model.Track) error {
return nil
}
func DeleteTrack(db *sqlx.DB, track *model.Track) error {
func DeleteTrack(db *sqlx.DB, trackID string) error {
_, err := db.Exec(
"DELETE FROM musictrack "+
"WHERE id=$1",
track.ID,
trackID,
)
if err != nil {
return err

View file

@ -1,7 +1,5 @@
package model
import "strings"
type (
Artist struct {
ID string `json:"id"`
@ -21,56 +19,3 @@ func (artist Artist) GetAvatar() string {
}
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
type Credit struct {
Artist `json:"artist"`
type (
Credit struct {
Release Release `json:"release"`
Artist Artist `json:"artist"`
Role string `json:"role"`
Primary bool `json:"primary" db:"is_primary"`
}
}
)

View file

@ -1,6 +1,8 @@
package model
import (
"html/template"
"strings"
"time"
)
@ -19,13 +21,9 @@ type (
Buylink string `json:"buylink"`
Copyright string `json:"copyright" db:"copyright"`
CopyrightURL string `json:"copyrightURL" db:"copyrighturl"`
}
FullRelease struct {
*Release
Tracks []DisplayTrack
Credits []Credit
Links []Link
Tracks []*Track `json:"tracks"`
Credits []*Credit `json:"credits"`
Links []*Link `json:"links"`
}
)
@ -39,6 +37,10 @@ const (
// GETTERS
func (release Release) GetDescriptionHTML() template.HTML {
return template.HTML(strings.Replace(release.Description, "\n", "<br>", -1))
}
func (release Release) TextReleaseDate() string {
return release.ReleaseDate.Format("2006-01-02T15:04")
}
@ -58,10 +60,39 @@ func (release Release) GetArtwork() string {
return release.Artwork
}
func (release FullRelease) IsSingle() bool {
func (release Release) IsSingle() bool {
return len(release.Tracks) == 1;
}
func (release Release) IsReleased() bool {
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

@ -10,21 +10,20 @@ type (
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Lyrics string `json:"lyrics"`
Lyrics string `json:"lyrics" db:"lyrics"`
PreviewURL string `json:"previewURL" db:"preview_url"`
}
DisplayTrack struct {
*Track
Lyrics template.HTML
Number int
}
)
func (track Track) MakeDisplay(number int) DisplayTrack {
return DisplayTrack{
Track: &track,
Lyrics: template.HTML(strings.Replace(track.Lyrics, "\n", "<br>", -1)),
Number: number,
}
func (track Track) GetDescriptionHTML() template.HTML {
return template.HTML(strings.Replace(track.Description, "\n", "<br>", -1))
}
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"
"net/http"
"arimelody.me/arimelody.me/global"
music "arimelody.me/arimelody.me/music/controller"
"arimelody.me/arimelody.me/music/model"
"arimelody.me/arimelody.me/templates"
"arimelody-web/global"
music "arimelody-web/music/controller"
"arimelody-web/music/model"
"arimelody-web/templates"
)
// HTTP HANDLER METHODS
@ -21,8 +21,7 @@ func Handler() http.Handler {
return
}
var release model.Release
err := global.DB.Get(&release, "SELECT * FROM musicrelease WHERE id=$1", r.URL.Path[1:])
release, err := music.GetRelease(global.DB, r.URL.Path[1:], true)
if err != nil {
http.NotFound(w, r)
return
@ -36,25 +35,17 @@ func Handler() http.Handler {
func ServeCatalog() http.Handler {
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 {
fmt.Printf("FATAL: Failed to pull releases for catalog: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
releases := []*model.FullRelease{}
for _, dbRelease := range dbReleases {
if !dbRelease.Visible { continue }
if !dbRelease.IsReleased() {
dbRelease.ReleaseType = model.Upcoming
for _, release := range releases {
if !release.IsReleased() {
release.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)

View file

@ -5,16 +5,35 @@ import (
"fmt"
"net/http"
"arimelody.me/arimelody.me/admin"
"arimelody.me/arimelody.me/global"
"arimelody.me/arimelody.me/music/model"
db "arimelody.me/arimelody.me/music/controller"
"arimelody.me/arimelody.me/templates"
"arimelody-web/admin"
"arimelody-web/global"
"arimelody-web/music/model"
db "arimelody-web/music/controller"
"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) {
// only allow authorised users to view hidden releases
authorised := admin.GetSession(r) != nil
@ -23,22 +42,65 @@ func ServeRelease(release model.Release) http.Handler {
return
}
fullRelease := &model.FullRelease{
Release: &release,
response := Release{
Release: release,
Tracks: []Track{},
Credits: []Credit{},
Links: make(map[string]string),
}
if authorised || release.IsReleased() {
fullerRelease, err := db.GetFullRelease(global.DB, &release)
// get credits
credits, err := db.GetReleaseCredits(global.DB, release.ID)
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)
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")
err := json.NewEncoder(w).Encode(fullRelease)
err := json.NewEncoder(w).Encode(response)
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
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) {
// only allow authorised users to view hidden releases
authorised := admin.GetSession(r) != nil
@ -55,21 +117,15 @@ func ServeGateway(release model.Release) http.Handler {
return
}
fullRelease := &model.FullRelease{
Release: &release,
}
response := *release
if authorised || release.IsReleased() {
fullerRelease, err := db.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
}
fullRelease = fullerRelease
response.Tracks = release.Tracks
response.Credits = release.Credits
response.Links = release.Links
}
err := templates.Pages["music-gateway"].Execute(w, fullRelease)
err := templates.Pages["music-gateway"].Execute(w, response)
if err != nil {
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;
}
#upcoming-release {
width: fit-content;
padding: .3em 1em;
font-size: 1em;
background: #101010;
}
ul#links {
width: 100%;
margin: 1rem 0;
@ -311,6 +318,7 @@ ul#links li {
flex-grow: 1;
}
#buylink,
ul#links a {
width: calc(100% - 1.6em);
padding: .5em .8em;
@ -324,7 +332,9 @@ ul#links a {
transition: filter .1s,-webkit-filter .1s
}
ul#links a.buy {
#buylink {
margin-top: 1em;
margin-bottom: -.5em;
background-color: #ff94e9
}
@ -354,7 +364,12 @@ ul#links a:hover {
}
#description {
font-size: 1.2em;
font-size: 1.1em;
}
#copyright {
margin-bottom: 0;
font-size: .8em;
}
#share {
@ -390,7 +405,7 @@ ul#links a:hover {
animation: share-after 2s cubic-bezier(.5,0,1,.5) forwards
}
h2 {
#info h2 {
width: fit-content;
padding: .3em 1em;
font-size: 1em;
@ -517,12 +532,11 @@ footer a:hover {
#art-container {
width: 100%;
margin-bottom: 2rem;
}
#artwork {
width: auto;
max-width: 50vw;
max-width: 60vw;
height: auto;
max-height: 50vh;
}
@ -536,21 +550,25 @@ footer a:hover {
gap: 2rem;
height: auto;
overflow-y: auto;
mask-image: none;
}
div#info > div {
min-width: auto;
min-height: auto;
padding: 0;
margin: 0;
margin: 2em 0 0 0;
overflow-y: unset;
mask-image: none;
}
div#info p {
margin: 0 auto;
}
div#overview p {
margin: .5em auto;
}
div#extras {
display: none;
}
@ -583,10 +601,18 @@ footer a:hover {
list-style: none;
}
#share.active: : after {
#share.active::after {
transform: translate(calc(-50% - .6em),1.5em);
}
#credits h2 {
margin: 0 auto;
}
#lyrics p.album-track-subheading {
margin-bottom: 1em;
}
footer {
height: 4rem;
font-size: .8rem;
@ -596,6 +622,9 @@ footer a:hover {
display: none;
}
#overlay {
background-size: 100vw 4px;
}
}
@keyframes background-init {

View file

@ -29,7 +29,7 @@
{{end}}
{{define "content"}}
<main>
<main >
<script type="module" src="/script/music-gateway.js"></script>
<div id="background" style="background-image: url({{.GetArtwork}})"></div>
@ -61,17 +61,14 @@
<p id="type" class="{{.ReleaseType}}">{{.ReleaseType}}</p>
{{else}}
<p id="type" class="upcoming">upcoming</p>
<p>Releases: {{.PrintReleaseDate}}</p>
<p id="upcoming-release">Releases: {{.PrintReleaseDate}}</p>
{{end}}
{{if .IsReleased}}
<ul id="links">
{{if .Buylink}}
<li>
<a href="{{.Buylink}}" class="buy">{{or .Buyname "buy"}}</a>
</li>
<a href="{{.Buylink}}" id="buylink">{{or .Buyname "buy"}}</a>
{{end}}
<ul id="links">
{{range .Links}}
<li>
<a class="{{.NormaliseName}}" href="{{.URL}}">{{.Name}}</a>
@ -82,7 +79,7 @@
{{if .Description}}
<p id="description">{{.Description}}</p>
<p id="description">{{.GetDescriptionHTML}}</p>
{{else if .IsSingle}}
@ -93,6 +90,10 @@
{{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>
</div>
@ -121,7 +122,7 @@
<div id="lyrics">
<p class="album-track-subheading">LYRICS</p>
{{if $Track.Lyrics}}
{{$Track.Lyrics}}
{{$Track.GetLyricsHTML}}
{{else}}
<span class="empty">No lyrics.</span>
{{end}}
@ -133,7 +134,7 @@
<h2>TRACKS</h2>
{{range $i, $track := .Tracks}}
<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}}
<p class="album-track-subheading">DESCRIPTION</p>
@ -142,7 +143,7 @@
<p class="album-track-subheading">LYRICS</p>
{{if $track.Lyrics}}
{{$track.Lyrics}}
{{$track.GetLyricsHTML}}
{{else}}
<span class="empty">No lyrics.</span>
{{end}}