Compare commits

..

No commits in common. "main" and "fullDB" have entirely different histories.
main ... fullDB

48 changed files with 980 additions and 1364 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", "test", "db"]
exclude_dir = ["admin\\static", "public", "uploads"]
exclude_file = []
exclude_regex = ["_test.go"]
exclude_unchanged = false

View file

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

2
.gitignore vendored
View file

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

View file

@ -1,23 +0,0 @@
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"]

View file

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

View file

@ -1,47 +0,0 @@
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,7 +61,6 @@
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="{{.GetArtwork}}" alt="" width="128" loading="lazy">
<img src="{{.Artwork}}" alt="" width="128" loading="lazy">
</div>
<div class="release-info">
<h3 class="release-title">

View file

@ -3,16 +3,17 @@ package admin
import (
"context"
"fmt"
"html/template"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"arimelody-web/discord"
"arimelody-web/global"
musicDB "arimelody-web/music/controller"
musicModel "arimelody-web/music/model"
"arimelody.me/arimelody.me/discord"
"arimelody.me/arimelody.me/global"
musicModel "arimelody.me/arimelody.me/music/model"
musicDB "arimelody.me/arimelody.me/music/controller"
)
type loginData struct {
@ -27,7 +28,6 @@ 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,12 +41,30 @@ func Handler() http.Handler {
return
}
releases, err := musicDB.GetAllReleases(global.DB, false, 0, true)
type (
IndexData struct {
Releases []musicModel.FullRelease
Artists []*musicModel.Artist
Tracks []musicModel.DisplayTrack
}
)
dbReleases, err := musicDB.GetAllReleases(global.DB)
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 {
@ -55,17 +73,19 @@ func Handler() http.Handler {
return
}
tracks, err := musicDB.GetOrphanTracks(global.DB)
dbTracks, 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
}
type IndexData struct {
Releases []*musicModel.Release
Artists []*musicModel.Artist
Tracks []*musicModel.Track
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)),
})
}
err = pages["index"].Execute(w, IndexData{
@ -105,9 +125,8 @@ 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.(*Session).Token
token = ctx_session.(string)
}
// okay, is it in the auth header?
if token == "" {
if strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") {
@ -149,15 +168,11 @@ 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 == "" {
@ -194,9 +209,8 @@ func LoginHandler() http.Handler {
cookie.Name = "token"
cookie.Value = session.Token
cookie.Expires = time.Now().Add(24 * time.Hour)
if strings.HasPrefix(global.HTTP_DOMAIN, "https") {
cookie.Secure = true
}
// TODO: uncomment this probably that might be nice i think
// cookie.Secure = true
cookie.HttpOnly = true
cookie.Path = "/"
http.SetCookie(w, &cookie)

View file

@ -5,24 +5,23 @@ import (
"net/http"
"strings"
"arimelody-web/global"
db "arimelody-web/music/controller"
"arimelody-web/music/model"
"arimelody.me/arimelody.me/global"
"arimelody.me/arimelody.me/music/model"
controller "arimelody.me/arimelody.me/music/controller"
)
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 := db.GetRelease(global.DB, releaseID, true)
release, err := controller.GetRelease(global.DB, releaseID)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r)
fmt.Printf("FATAL: Failed to pull release %s: %s\n", releaseID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
fmt.Printf("FATAL: Failed to pull full release data for %s: %s\n", releaseID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
if release == nil {
http.NotFound(w, r)
return
}
@ -32,25 +31,32 @@ 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(release).ServeHTTP(w, r)
serveEditCredits(fullRelease).ServeHTTP(w, r)
return
case "addcredit":
serveAddCredit(release).ServeHTTP(w, r)
serveAddCredit(fullRelease).ServeHTTP(w, r)
return
case "newcredit":
serveNewCredit().ServeHTTP(w, r)
return
case "editlinks":
serveEditLinks(release).ServeHTTP(w, r)
serveEditLinks(fullRelease).ServeHTTP(w, r)
return
case "edittracks":
serveEditTracks(release).ServeHTTP(w, r)
serveEditTracks(fullRelease).ServeHTTP(w, r)
return
case "addtrack":
serveAddTrack(release).ServeHTTP(w, r)
serveAddTrack(fullRelease).ServeHTTP(w, r)
return
case "newtrack":
serveNewTrack().ServeHTTP(w, r)
@ -60,7 +66,7 @@ func serveRelease() http.Handler {
return
}
err = pages["release"].Execute(w, release)
err = pages["release"].Execute(w, fullRelease)
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)
@ -68,7 +74,7 @@ func serveRelease() http.Handler {
})
}
func serveEditCredits(release *model.Release) http.Handler {
func serveEditCredits(release *model.FullRelease) 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)
@ -79,9 +85,9 @@ func serveEditCredits(release *model.Release) http.Handler {
})
}
func serveAddCredit(release *model.Release) http.Handler {
func serveAddCredit(release *model.FullRelease) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
artists, err := db.GetArtistsNotOnRelease(global.DB, release.ID)
artists, err := controller.GetArtistsNotOnRelease(global.DB, release.Release)
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)
@ -108,7 +114,7 @@ func serveAddCredit(release *model.Release) 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 := db.GetArtist(global.DB, artistID)
artist, err := controller.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)
@ -128,7 +134,7 @@ func serveNewCredit() http.Handler {
})
}
func serveEditLinks(release *model.Release) http.Handler {
func serveEditLinks(release *model.FullRelease) 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)
@ -139,7 +145,7 @@ func serveEditLinks(release *model.Release) http.Handler {
})
}
func serveEditTracks(release *model.Release) http.Handler {
func serveEditTracks(release *model.FullRelease) 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)
@ -150,9 +156,9 @@ func serveEditTracks(release *model.Release) http.Handler {
})
}
func serveAddTrack(release *model.Release) http.Handler {
func serveAddTrack(release *model.FullRelease) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tracks, err := db.GetTracksNotOnRelease(global.DB, release.ID)
tracks, err := controller.GetTracksNotOnRelease(global.DB, release.Release)
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)
@ -180,7 +186,7 @@ func serveAddTrack(release *model.Release) 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 := db.GetTrack(global.DB, trackID)
track, err := controller.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

@ -1,151 +0,0 @@
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

@ -1,79 +0,0 @@
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,9 +28,6 @@ input[type="text"] {
outline: 1px solid #808080;
cursor: pointer;
}
.release-artwork #remove-artwork {
padding: .3em .4em;
}
.release-info {
width: 0;
@ -345,7 +342,7 @@ dialog div.dialog-actions {
background-color: #8cff83
}
.card.links a.button[data-name="apple music"] {
.card.links a.button[data-name="applemusic"] {
background-color: #8cd9ff
}
@ -551,14 +548,3 @@ 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,36 +1,47 @@
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);
saveBtn.addEventListener("click", () => {
fetch("/api/v1/music/" + releaseID, {
method: "PUT",
body: JSON.stringify({
function updateData(old) {
var releaseData = {
visible: visInput.value === "true",
title: titleInput.value,
description: descInput.value,
type: typeInput.value,
releaseDate: dateInput.value + ":00Z",
releaseDate: dateInput.value,
artwork: artworkData,
buyname: buynameInput.value,
buylink: buylinkInput.value,
copyright: copyrightInput.value,
copyrightURL: copyrightURLInput.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),
headers: { "Content-Type": "application/json" }
}).then(res => {
if (!res.ok) {
@ -43,13 +54,9 @@ saveBtn.addEventListener("click", () => {
location = location;
});
});
}
deleteBtn.addEventListener("click", () => {
if (releaseID != prompt(
"You are about to permanently delete " + releaseID + ". " +
"This action is irreversible. " +
"Please enter \"" + releaseID + "\" to continue.")) return;
function deleteRelease() {
fetch("/api/v1/music/" + releaseID, {
method: "DELETE",
}).then(res => {
@ -63,17 +70,15 @@ deleteBtn.addEventListener("click", () => {
location = "/admin";
});
});
}
[titleInput, typeInput, descInput, dateInput, buynameInput, buylinkInput, copyrightInput, copyrightURLInput, visInput].forEach(input => {
input.addEventListener("change", () => {
saveBtn.disabled = false;
});
input.addEventListener("keypress", () => {
saveBtn.disabled = false;
});
});
edited.onUpdate(edited => {
saveBtn.disabled = !edited;
})
titleInput.addEventListener("change", () => {
releaseData = updateData(releaseData);
});
artworkImg.addEventListener("click", () => {
artworkInput.addEventListener("change", () => {
if (artworkInput.files.length > 0) {
@ -82,17 +87,41 @@ artworkImg.addEventListener("click", () => {
const data = e.target.result;
artworkImg.src = data;
artworkData = data;
saveBtn.disabled = false;
releaseData = updateData(releaseData);
};
reader.readAsDataURL(artworkInput.files[0]);
}
});
artworkInput.click();
});
removeArtworkBtn.addEventListener("click", () => {
artworkImg.src = "/img/default-cover-art.png"
artworkData = "";
saveBtn.disabled = false;
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();
});

View file

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

View file

@ -1,5 +1,4 @@
const newReleaseBtn = document.getElementById("create-release");
const newArtistBtn = document.getElementById("create-artist");
const newTrackBtn = document.getElementById("create-track");
newReleaseBtn.addEventListener("click", event => {
@ -25,30 +24,6 @@ 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:");
@ -68,7 +43,7 @@ newTrackBtn.addEventListener("click", event => {
}
})
}).catch(err => {
alert("Failed to create track. Check the console for details.");
alert("Failed to create release. Check the console for details.");
console.error(err);
});
});

View file

@ -29,11 +29,6 @@ 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-web/global"
"arimelody-web/music/model"
"arimelody-web/music/controller"
"arimelody.me/arimelody.me/global"
"arimelody.me/arimelody.me/music/model"
"arimelody.me/arimelody.me/music/controller"
)
func serveTrack() http.Handler {
@ -25,16 +25,26 @@ func serveTrack() http.Handler {
return
}
releases, err := music.GetTrackReleases(global.DB, track.ID, true)
dbReleases, err := music.GetTrackReleases(global.DB, track)
if err != nil {
fmt.Printf("FATAL: Failed to pull releases for %s: %s\n", id, err)
fmt.Printf("Error rendering admin track page 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.Release
Releases []model.FullRelease
}
err = pages["track"].Execute(w, Track{ Track: track, Releases: releases })

View file

@ -1,72 +0,0 @@
{{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,11 +12,10 @@
<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}}" autocomplete="on">
<input type="text" id="title" name="Title" value="{{.Title}}">
</h1>
<table>
<tr>
@ -54,31 +53,19 @@
<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="buyname" id="buyname" value="{{.Buyname}}" autocomplete="on">
<input type="text" name="Buy Name" id="buyname" value="{{.Buyname}}">
</td>
</tr>
<tr>
<td>Buy Link</td>
<td>
<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">
<input type="text" name="Buy Link" id="buylink" value="{{.Buylink}}">
</td>
</tr>
<tr>
@ -112,7 +99,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/artist/{{.Artist.ID}}">{{.Artist.Name}}</a></p>
<p class="artist-name"><a href="/admin/artists/{{.Artist.ID}}">{{.Artist.Name}}</a></p>
<p class="artist-role">
{{.Role}}
{{if .Primary}}
@ -152,23 +139,23 @@
>Edit</a>
</div>
<div class="card tracks">
{{range $i, $track := .Tracks}}
<div class="track" data-id="{{$track.ID}}">
{{range .Tracks}}
<div class="track" data-id="{{.ID}}">
<h2 class="track-title">
<span class="track-number">{{.Add $i 1}}</span>
<a href="/admin/track/{{$track.ID}}">{{$track.Title}}</a>
<span class="track-number">{{.Number}}</span>
<a href="/admin/track/{{.ID}}">{{.Title}}</a>
</h2>
<h3>Description</h3>
{{if $track.Description}}
<p class="track-description">{{$track.GetDescriptionHTML}}</p>
{{if .Description}}
<p class="track-description">{{.Description}}</p>
{{else}}
<p class="track-description empty">No description provided.</p>
{{end}}
<h3>Lyrics</h3>
{{if $track.Lyrics}}
<p class="track-lyrics">{{$track.GetLyricsHTML}}</p>
{{if .Lyrics}}
<p class="track-lyrics">{{.Lyrics}}</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" id="create-artist">Create New</a>
<a class="create-btn">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/artist/{{$Artist.ID}}" class="artist-name">{{$Artist.Name}}</a>
<a href="/admin/artists/{{$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.GetDescriptionHTML}}</p>
<p class="track-description">{{$Track.Description}}</p>
{{else}}
<p class="track-description empty">No description provided.</p>
{{end}}
{{if $Track.Lyrics}}
<p class="track-lyrics">{{$Track.GetLyricsHTML}}</p>
<p class="track-lyrics">{{$Track.Lyrics}}</p>
{{else}}
<p class="track-lyrics empty">There are no lyrics.</p>
{{end}}

View file

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

View file

@ -3,23 +3,24 @@ package api
import (
"encoding/json"
"fmt"
"io/fs"
"net/http"
"os"
"path/filepath"
"strings"
"arimelody-web/admin"
"arimelody-web/global"
db "arimelody-web/music/controller"
music "arimelody-web/music/controller"
"arimelody-web/music/model"
"arimelody.me/arimelody.me/global"
"arimelody.me/arimelody.me/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{}
artists, err := db.GetAllArtists(global.DB)
err := global.DB.Select(&artists, "SELECT * FROM artist")
if err != nil {
fmt.Printf("FATAL: Failed to serve all artists: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -34,37 +35,28 @@ 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"`
}
)
show_hidden_releases := admin.GetSession(r) != nil
var dbCredits []*model.Credit
dbCredits, err := db.GetArtistCredits(global.DB, artist.ID, show_hidden_releases)
var credits = map[string]creditJSON{}
err := global.DB.Select(&credits, "SELECT release,role,is_primary FROM musiccredit WHERE id=$1", artist.ID)
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,
@ -78,23 +70,39 @@ func ServeArtist(artist *model.Artist) http.Handler {
func CreateArtist() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var artist model.Artist
err := json.NewDecoder(r.Body).Decode(&artist)
var data artistJSON
err := json.NewDecoder(r.Body).Decode(&data)
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
if artist.ID == "" {
if data.ID == "" {
http.Error(w, "Artist ID cannot be blank\n", http.StatusBadRequest)
return
}
if artist.Name == "" { artist.Name = artist.ID }
if data.Name == nil || *data.Name == "" {
http.Error(w, "Artist name cannot be blank\n", http.StatusBadRequest)
return
}
err = music.CreateArtist(global.DB, &artist)
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)
if err != nil {
if strings.Contains(err.Error(), "duplicate key") {
http.Error(w, fmt.Sprintf("Artist %s already exists\n", artist.ID), http.StatusBadRequest)
http.Error(w, fmt.Sprintf("Artist %s already exists\n", data.ID), http.StatusBadRequest)
return
}
fmt.Printf("FATAL: Failed to create artist %s: %s\n", artist.ID, err)
@ -106,59 +114,43 @@ 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) {
err := json.NewDecoder(r.Body).Decode(&artist)
var data artistJSON
err := json.NewDecoder(r.Body).Decode(&data)
if err != nil {
fmt.Printf("FATAL: Failed to update artist: %s\n", err)
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
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)
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 }
// 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)
})
_, err = global.DB.Exec(
"UPDATE artist "+
"SET name=$2, website=$3, avatar=$4 "+
"WHERE id=$1",
artist.ID,
artist.Name,
artist.Website,
artist.Avatar)
if err != nil {
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 := music.DeleteArtist(global.DB, artist.ID)
_, err := global.DB.Exec(
"DELETE FROM artist "+
"WHERE id=$1",
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,51 +10,54 @@ import (
"strings"
"time"
"arimelody-web/admin"
"arimelody-web/global"
music "arimelody-web/music/controller"
"arimelody-web/music/model"
"arimelody.me/arimelody.me/admin"
"arimelody.me/arimelody.me/global"
"arimelody.me/arimelody.me/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) {
releases, err := music.GetAllReleases(global.DB, false, 0, true)
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")
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
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{}
catalog := []catalogItem{}
authorised := admin.GetSession(r) != nil
for _, release := range releases {
if !release.Visible && !authorised {
continue
}
artists := []string{}
for _, credit := range release.Credits {
if !credit.Primary { continue }
artists = append(artists, credit.Artist.Name)
}
catalog = append(catalog, Release{
catalog = append(catalog, catalogItem{
ID: release.ID,
Title: release.Title,
Artists: artists,
ReleaseType: release.ReleaseType,
ReleaseDate: release.ReleaseDate,
Artwork: release.Artwork,
Buylink: release.Buylink,
Copyright: release.Copyright,
})
}
@ -74,34 +77,80 @@ func CreateRelease() http.Handler {
return
}
var release model.Release
err := json.NewDecoder(r.Body).Decode(&release)
var data releaseBodyJSON
err := json.NewDecoder(r.Body).Decode(&data)
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
if release.ID == "" {
if data.ID == "" {
http.Error(w, "Release ID cannot be empty\n", http.StatusBadRequest)
return
}
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)
title := data.ID
if data.Title != nil && *data.Title != "" {
title = *data.Title
}
if release.Artwork == "" { release.Artwork = "/img/default-cover-art.png" }
description := ""
if data.Description != nil && *data.Description != "" { description = *data.Description }
err = music.CreateRelease(global.DB, &release)
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 {
if strings.Contains(err.Error(), "duplicate key") {
http.Error(w, fmt.Sprintf("Release %s already exists\n", release.ID), http.StatusBadRequest)
http.Error(w, "Invalid release date", http.StatusBadRequest)
return
}
fmt.Printf("FATAL: Failed to create release %s: %s\n", release.ID, err)
} 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)
if err != nil {
if strings.Contains(err.Error(), "duplicate key") {
http.Error(w, fmt.Sprintf("Release %s already exists\n", data.ID), http.StatusBadRequest)
return
}
fmt.Printf("Failed to create release %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
@ -116,7 +165,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)
@ -124,6 +173,14 @@ 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] {
@ -142,19 +199,30 @@ func UpdateRelease(release *model.Release) http.Handler {
return
}
err := json.NewDecoder(r.Body).Decode(&release)
var data releaseBodyJSON
err = json.NewDecoder(r.Body).Decode(&data)
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 release.Artwork == "" {
release.Artwork = "/img/default-cover-art.png"
} else {
if strings.Contains(release.Artwork, ";base64,") {
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,") {
var artworkDirectory = filepath.Join("uploads", "musicart")
filename, err := HandleImageUpload(&release.Artwork, artworkDirectory, release.ID)
filename, err := HandleImageUpload(data.Artwork, artworkDirectory, data.ID)
// clean up files with this ID and different extensions
err = filepath.Walk(artworkDirectory, func(path string, info fs.FileInfo, err error) error {
@ -170,22 +238,35 @@ func UpdateRelease(release *model.Release) http.Handler {
}
release.Artwork = fmt.Sprintf("/uploads/musicart/%s", filename)
} else {
release.Artwork = *data.Artwork
}
}
err = music.UpdateRelease(global.DB, release)
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)
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)
@ -194,19 +275,26 @@ func UpdateReleaseTracks(release *model.Release) http.Handler {
return
}
err = music.UpdateReleaseTracks(global.DB, release.ID, trackIDs)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r)
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)
}
fmt.Printf("FATAL: Failed to update tracks for %s: %s\n", release.ID, err)
err = tx.Commit()
if err != nil {
fmt.Printf("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
@ -220,34 +308,52 @@ func UpdateReleaseCredits(release *model.Release) http.Handler {
return
}
var credits []*model.Credit
// clear duplicates
type Credit struct {
Role string
Primary bool
}
var credits = map[string]Credit{}
for _, credit := range data {
credits = append(credits, &model.Credit{
Artist: model.Artist{
ID: credit.Artist,
},
credits[credit.Artist] = Credit{
Role: credit.Role,
Primary: credit.Primary,
})
}
}
err = music.UpdateReleaseCredits(global.DB, release.ID, credits)
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()
if err != nil {
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)
fmt.Printf("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)
@ -261,27 +367,30 @@ func UpdateReleaseLinks(release *model.Release) http.Handler {
return
}
err = music.UpdateReleaseLinks(global.DB, release.ID, links)
if err != nil {
if strings.Contains(err.Error(), "no rows") {
http.NotFound(w, r)
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)
}
fmt.Printf("FATAL: Failed to update links for %s: %s\n", release.ID, err)
err = tx.Commit()
if err != nil {
fmt.Printf("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 := music.DeleteRelease(global.DB, release.ID)
_, err := global.DB.Exec("DELETE FROM musicrelease WHERE id=$1", release.ID)
if err != nil {
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)
fmt.Printf("Failed to delete release %s: %s\n", release.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})

View file

@ -5,40 +5,24 @@ import (
"fmt"
"net/http"
"arimelody-web/global"
music "arimelody-web/music/controller"
"arimelody-web/music/model"
)
type (
Track struct {
*model.Track
Releases []string `json:"releases"`
}
"arimelody.me/arimelody.me/global"
"arimelody.me/arimelody.me/music/model"
)
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{}
var dbTracks = []*model.Track{}
dbTracks, err := music.GetAllTracks(global.DB)
err := global.DB.Select(&tracks, "SELECT id, title FROM musictrack")
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 {
@ -48,23 +32,42 @@ 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) {
dbReleases, err := music.GetTrackReleases(global.DB, track.ID, false)
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)
if err != nil {
fmt.Printf("FATAL: Failed to pull track releases for %s from DB: %s\n", track.ID, err)
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)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
releases := []string{}
for _, release := range dbReleases {
releases = append(releases, release.ID)
type response struct {
model.Track
Releases []*model.Release
}
w.Header().Add("Content-Type", "application/json")
err = json.NewEncoder(w).Encode(Track{ track, releases })
err = json.NewEncoder(w).Encode(response{ track, releases })
if err != nil {
fmt.Printf("FATAL: Failed to serve track %s: %s\n", track.ID, err)
fmt.Printf("FATAL: Failed to serve track %s: %s\n", trackID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
})
@ -89,7 +92,15 @@ func CreateTrack() http.Handler {
return
}
id, err := music.CreateTrack(global.DB, &track)
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)
if err != nil {
fmt.Printf("FATAL: Failed to create track: %s\n", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -98,29 +109,46 @@ func CreateTrack() http.Handler {
w.Header().Add("Content-Type", "text/plain")
w.WriteHeader(http.StatusCreated)
w.Write([]byte(id))
w.Write([]byte(trackID))
})
}
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
}
err := json.NewDecoder(r.Body).Decode(&track)
var update model.Track
err := json.NewDecoder(r.Body).Decode(&update)
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
if track.Title == "" {
if update.Title == "" {
http.Error(w, "Track title cannot be empty\n", http.StatusBadRequest)
return
}
err = music.UpdateTrack(global.DB, track)
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)
if err != nil {
fmt.Printf("Failed to update track %s: %s\n", track.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -135,7 +163,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)
@ -143,7 +171,10 @@ func DeleteTrack(track *model.Track) http.Handler {
}
var trackID = r.URL.Path[1:]
err := music.DeleteTrack(global.DB, trackID)
_, err := global.DB.Exec(
"DELETE FROM musictrack "+
"WHERE id=$1",
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,8 +35,6 @@ 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,27 +6,26 @@ import (
"fmt"
"net/http"
"net/url"
"os"
"strings"
"arimelody-web/global"
"arimelody.me/arimelody.me/global"
)
const API_ENDPOINT = "https://discord.com/api/v10"
var CREDENTIALS_PROVIDED = true
var CLIENT_ID = func() string {
id := os.Getenv("DISCORD_CLIENT")
id := global.Args["discordClient"]
if id == "" {
fmt.Printf("WARN: Discord client ID (DISCORD_CLIENT) was not provided. Admin login will be unavailable.\n")
fmt.Printf("WARN: Discord client ID (-discordClient) was not provided. Admin login will be unavailable.\n")
CREDENTIALS_PROVIDED = false
}
return id
}()
var CLIENT_SECRET = func() string {
secret := os.Getenv("DISCORD_SECRET")
if secret == "" {
fmt.Printf("WARN: Discord secret (DISCORD_SECRET) was not provided. Admin login will be unavailable.\n")
secret := global.Args["discordSecret"]
if secret== "" {
fmt.Printf("WARN: Discord secret (-discordSecret) was not provided. Admin login will be unavailable.\n")
CREDENTIALS_PROVIDED = false
}
return secret
@ -108,6 +107,7 @@ 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))

18
docker-compose-db.yml Normal file
View file

@ -0,0 +1,18 @@
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

View file

@ -1,22 +0,0 @@
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 := os.Getenv("HTTP_DOMAIN")
if domain == "" {
return "https://arimelody.me"
}
domain := Args["httpDomain"]
if domain != "" {
return domain
}
return "https://arimelody.me"
}()
var DB *sqlx.DB

View file

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

15
go.mod
View file

@ -1,8 +1,19 @@
module arimelody-web
module arimelody.me/arimelody.me
go 1.22
require (
github.com/jmoiron/sqlx v1.4.0
github.com/gomarkdown/markdown v0.0.0-20231222211730-1d6d20845b47
github.com/jmoiron/sqlx v1.3.5
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,10 +1,30 @@
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/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=
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.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
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=

19
main.go
View file

@ -1,7 +1,6 @@
package main
import (
"errors"
"fmt"
"log"
"net/http"
@ -9,11 +8,11 @@ import (
"path/filepath"
"time"
"arimelody-web/admin"
"arimelody-web/api"
"arimelody-web/global"
musicView "arimelody-web/music/view"
"arimelody-web/templates"
"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"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
@ -23,11 +22,8 @@ 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", "host=" + dbHost + " user=arimelody dbname=arimelody password=fuckingpassword sslmode=disable")
global.DB, err = sqlx.Connect("postgres", "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)
@ -68,10 +64,9 @@ 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 errors.Is(err, os.ErrNotExist) {
if os.IsNotExist(err) {
http.NotFound(w, r)
return
}

View file

@ -1,7 +1,7 @@
package music
import (
"arimelody-web/music/model"
"arimelody.me/arimelody.me/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, releaseID string) ([]*model.Artist, error) {
func GetArtistsNotOnRelease(db *sqlx.DB, release *model.Release) ([]*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)",
releaseID)
release.ID)
if err != nil {
return nil, err
}
@ -44,60 +44,6 @@ func GetArtistsNotOnRelease(db *sqlx.DB, releaseID string) ([]*model.Artist, err
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) "+
@ -131,11 +77,11 @@ func UpdateArtist(db *sqlx.DB, artist *model.Artist) error {
return nil
}
func DeleteArtist(db *sqlx.DB, artistID string) error {
func DeleteArtist(db *sqlx.DB, artist *model.Artist) error {
_, err := db.Exec(
"DELETE FROM artist "+
"WHERE id=$1",
artistID,
artist.ID,
)
if err != nil {
return err

View file

@ -0,0 +1,71 @@
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
}

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

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

View file

@ -1,7 +1,7 @@
package music
import (
"arimelody-web/music/model"
"arimelody.me/arimelody.me/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, releaseID string) ([]*model.Track, error) {
func GetTracksNotOnRelease(db *sqlx.DB, release *model.Release) ([]*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)",
releaseID)
release.ID)
if err != nil {
return nil, err
}
@ -55,58 +55,20 @@ func GetTracksNotOnRelease(db *sqlx.DB, releaseID string) ([]*model.Track, error
return tracks, nil
}
func GetTrackReleases(db *sqlx.DB, trackID string, full bool) ([]*model.Release, error) {
func GetTrackReleases(db *sqlx.DB, track *model.Track) ([]*model.Release, error) {
var releases = []*model.Release{}
err := db.Select(&releases,
"SELECT id,title,type,release_date,artwork,buylink "+
"FROM musicrelease "+
"SELECT musicrelease.* FROM musicrelease "+
"JOIN musicreleasetrack ON release=id "+
"WHERE track=$1 "+
"ORDER BY release_date",
trackID,
track.ID,
)
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
}
@ -161,11 +123,11 @@ func UpdateTrack(db *sqlx.DB, track *model.Track) error {
return nil
}
func DeleteTrack(db *sqlx.DB, trackID string) error {
func DeleteTrack(db *sqlx.DB, track *model.Track) error {
_, err := db.Exec(
"DELETE FROM musictrack "+
"WHERE id=$1",
trackID,
track.ID,
)
if err != nil {
return err

View file

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

View file

@ -1,8 +1,6 @@
package model
import (
"html/template"
"strings"
"time"
)
@ -21,9 +19,13 @@ type (
Buylink string `json:"buylink"`
Copyright string `json:"copyright" db:"copyright"`
CopyrightURL string `json:"copyrightURL" db:"copyrighturl"`
Tracks []*Track `json:"tracks"`
Credits []*Credit `json:"credits"`
Links []*Link `json:"links"`
}
FullRelease struct {
*Release
Tracks []DisplayTrack
Credits []Credit
Links []Link
}
)
@ -37,10 +39,6 @@ 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")
}
@ -60,39 +58,10 @@ func (release Release) GetArtwork() string {
return release.Artwork
}
func (release Release) IsSingle() bool {
func (release FullRelease) 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,20 +10,21 @@ type (
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Lyrics string `json:"lyrics" db:"lyrics"`
Lyrics string `json:"lyrics"`
PreviewURL string `json:"previewURL" db:"preview_url"`
}
DisplayTrack struct {
*Track
Lyrics template.HTML
Number int
}
)
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
func (track Track) MakeDisplay(number int) DisplayTrack {
return DisplayTrack{
Track: &track,
Lyrics: template.HTML(strings.Replace(track.Lyrics, "\n", "<br>", -1)),
Number: number,
}
}

View file

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

View file

@ -5,35 +5,16 @@ import (
"fmt"
"net/http"
"arimelody-web/admin"
"arimelody-web/global"
"arimelody-web/music/model"
db "arimelody-web/music/controller"
"arimelody-web/templates"
"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"
)
type (
Track struct {
Title string `json:"title"`
Description string `json:"description"`
Lyrics string `json:"lyrics"`
}
// HTTP HANDLERS
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 {
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
@ -42,65 +23,22 @@ func ServeRelease(release *model.Release) http.Handler {
return
}
response := Release{
Release: release,
Tracks: []Track{},
Credits: []Credit{},
Links: make(map[string]string),
fullRelease := &model.FullRelease{
Release: &release,
}
if authorised || release.IsReleased() {
// get credits
credits, err := db.GetReleaseCredits(global.DB, release.ID)
fullerRelease, err := db.GetFullRelease(global.DB, &release)
if err != nil {
fmt.Printf("FATAL: Failed to serve release %s: Credits: %s\n", release.ID, err)
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
}
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
}
fullRelease = fullerRelease
}
w.Header().Add("Content-Type", "application/json")
err := json.NewEncoder(w).Encode(response)
err := json.NewEncoder(w).Encode(fullRelease)
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
@ -108,7 +46,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
@ -117,15 +55,21 @@ func ServeGateway(release *model.Release) http.Handler {
return
}
response := *release
if authorised || release.IsReleased() {
response.Tracks = release.Tracks
response.Credits = release.Credits
response.Links = release.Links
fullRelease := &model.FullRelease{
Release: &release,
}
err := templates.Pages["music-gateway"].Execute(w, response)
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
}
err := templates.Pages["music-gateway"].Execute(w, fullRelease)
if err != nil {
fmt.Printf("Error rendering music gateway for %s: %s\n", release.ID, err)

View file

@ -296,13 +296,6 @@ 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;
@ -318,7 +311,6 @@ ul#links li {
flex-grow: 1;
}
#buylink,
ul#links a {
width: calc(100% - 1.6em);
padding: .5em .8em;
@ -332,9 +324,7 @@ ul#links a {
transition: filter .1s,-webkit-filter .1s
}
#buylink {
margin-top: 1em;
margin-bottom: -.5em;
ul#links a.buy {
background-color: #ff94e9
}
@ -364,12 +354,7 @@ ul#links a:hover {
}
#description {
font-size: 1.1em;
}
#copyright {
margin-bottom: 0;
font-size: .8em;
font-size: 1.2em;
}
#share {
@ -405,7 +390,7 @@ ul#links a:hover {
animation: share-after 2s cubic-bezier(.5,0,1,.5) forwards
}
#info h2 {
h2 {
width: fit-content;
padding: .3em 1em;
font-size: 1em;
@ -532,11 +517,12 @@ footer a:hover {
#art-container {
width: 100%;
margin-bottom: 2rem;
}
#artwork {
width: auto;
max-width: 60vw;
max-width: 50vw;
height: auto;
max-height: 50vh;
}
@ -550,25 +536,21 @@ 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: 2em 0 0 0;
margin: 0;
overflow-y: unset;
mask-image: none;
}
div#info p {
margin: 0 auto;
}
div#overview p {
margin: .5em auto;
}
div#extras {
display: none;
}
@ -601,18 +583,10 @@ 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;
@ -622,9 +596,6 @@ 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,14 +61,17 @@
<p id="type" class="{{.ReleaseType}}">{{.ReleaseType}}</p>
{{else}}
<p id="type" class="upcoming">upcoming</p>
<p id="upcoming-release">Releases: {{.PrintReleaseDate}}</p>
<p>Releases: {{.PrintReleaseDate}}</p>
{{end}}
{{if .IsReleased}}
{{if .Buylink}}
<a href="{{.Buylink}}" id="buylink">{{or .Buyname "buy"}}</a>
{{end}}
<ul id="links">
{{if .Buylink}}
<li>
<a href="{{.Buylink}}" class="buy">{{or .Buyname "buy"}}</a>
</li>
{{end}}
{{range .Links}}
<li>
<a class="{{.NormaliseName}}" href="{{.URL}}">{{.Name}}</a>
@ -79,7 +82,7 @@
{{if .Description}}
<p id="description">{{.GetDescriptionHTML}}</p>
<p id="description">{{.Description}}</p>
{{else if .IsSingle}}
@ -90,10 +93,6 @@
{{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>
@ -122,7 +121,7 @@
<div id="lyrics">
<p class="album-track-subheading">LYRICS</p>
{{if $Track.Lyrics}}
{{$Track.GetLyricsHTML}}
{{$Track.Lyrics}}
{{else}}
<span class="empty">No lyrics.</span>
{{end}}
@ -134,7 +133,7 @@
<h2>TRACKS</h2>
{{range $i, $track := .Tracks}}
<details>
<summary class="album-track-title">{{$track.Add $i 1}}. {{$track.Title}}</summary>
<summary class="album-track-title">{{$track.Number}}. {{$track.Title}}</summary>
{{if $track.Description}}
<p class="album-track-subheading">DESCRIPTION</p>
@ -143,7 +142,7 @@
<p class="album-track-subheading">LYRICS</p>
{{if $track.Lyrics}}
{{$track.GetLyricsHTML}}
{{$track.Lyrics}}
{{else}}
<span class="empty">No lyrics.</span>
{{end}}