home to your local SPACEGIRL 💫 arimelody.space
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

my god...it's finally done

+1002 -544
+1 -1
.air.toml
··· 7 7 bin = "./tmp/main" 8 8 cmd = "go build -o ./tmp/main ." 9 9 delay = 1000 10 - exclude_dir = ["admin\\static", "public", "uploads"] 10 + exclude_dir = ["admin\\static", "public", "uploads", "test"] 11 11 exclude_file = [] 12 12 exclude_regex = ["_test.go"] 13 13 exclude_unchanged = false
+11
.dockerignore
··· 1 + **/.DS_Store 2 + .git/ 3 + .air.toml/ 4 + .gitattributes 5 + .gitignore 6 + uploads/* 7 + test/ 8 + tmp/ 9 + docker-compose.yml 10 + Dockerfile 11 + schema.sql
+1
.gitignore
··· 3 3 tmp/ 4 4 test/ 5 5 uploads/* 6 + docker-compose-test.yml
+23
Dockerfile
··· 1 + FROM golang:1.22 AS build-stage 2 + 3 + WORKDIR /app 4 + 5 + COPY go.mod go.sum ./ 6 + RUN go mod download 7 + 8 + COPY . . 9 + 10 + RUN CGO_ENABLED=0 GOOS=linux go build -o /arimelody-web 11 + 12 + # --- 13 + 14 + FROM build-stage AS build-release-stage 15 + 16 + WORKDIR /app 17 + 18 + COPY --from=build-stage /arimelody-web /arimelody-web 19 + COPY . . 20 + 21 + EXPOSE 8080 22 + 23 + CMD ["/arimelody-web"]
+4 -3
admin/admin.go
··· 3 3 import ( 4 4 "fmt" 5 5 "math/rand" 6 + "os" 6 7 "time" 7 8 8 - "arimelody.me/arimelody.me/global" 9 + "arimelody-web/global" 9 10 ) 10 11 11 12 type ( ··· 28 29 }() 29 30 30 31 var ADMIN_ID_DISCORD = func() string { 31 - id := global.Args["discordAdmin"] 32 + id := os.Getenv("DISCORD_ADMIN") 32 33 if id == "" { 33 - fmt.Printf("WARN: Discord admin ID (-discordAdmin) was not provided. Admin login will be unavailable.\n") 34 + fmt.Printf("WARN: Discord admin ID (DISCORD_ADMIN) was not provided. Admin login will be unavailable.\n") 34 35 } 35 36 return id 36 37 }()
+47
admin/artisthttp.go
··· 1 + package admin 2 + 3 + import ( 4 + "fmt" 5 + "net/http" 6 + "strings" 7 + 8 + "arimelody-web/global" 9 + "arimelody-web/music/model" 10 + "arimelody-web/music/controller" 11 + ) 12 + 13 + func serveArtist() http.Handler { 14 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 15 + slices := strings.Split(r.URL.Path[1:], "/") 16 + id := slices[0] 17 + artist, err := music.GetArtist(global.DB, id) 18 + if err != nil { 19 + if artist == nil { 20 + http.NotFound(w, r) 21 + return 22 + } 23 + fmt.Printf("Error rendering admin artist page for %s: %s\n", id, err) 24 + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 25 + return 26 + } 27 + 28 + credits, err := music.GetArtistCredits(global.DB, artist.ID) 29 + if err != nil { 30 + fmt.Printf("Error rendering admin track page for %s: %s\n", id, err) 31 + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 32 + return 33 + } 34 + 35 + type Artist struct { 36 + *model.Artist 37 + Credits []*model.Credit 38 + } 39 + 40 + err = pages["artist"].Execute(w, Artist{ Artist: artist, Credits: credits }) 41 + if err != nil { 42 + fmt.Printf("Error rendering admin track page for %s: %s\n", id, err) 43 + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 44 + } 45 + }) 46 + } 47 +
+1
admin/components/credits/editcredits.html
··· 61 61 el.remove(); 62 62 }); 63 63 64 + el.draggable = true; 64 65 el.addEventListener("dragstart", () => { el.classList.add("moving") }); 65 66 el.addEventListener("dragend", () => { el.classList.remove("moving") }); 66 67 }
+21 -35
admin/http.go
··· 3 3 import ( 4 4 "context" 5 5 "fmt" 6 - "html/template" 7 6 "net/http" 8 7 "os" 9 8 "path/filepath" 10 9 "strings" 11 10 "time" 12 11 13 - "arimelody.me/arimelody.me/discord" 14 - "arimelody.me/arimelody.me/global" 15 - musicModel "arimelody.me/arimelody.me/music/model" 16 - musicDB "arimelody.me/arimelody.me/music/controller" 12 + "arimelody-web/discord" 13 + "arimelody-web/global" 14 + musicDB "arimelody-web/music/controller" 15 + musicModel "arimelody-web/music/model" 17 16 ) 18 17 19 18 type loginData struct { ··· 28 27 mux.Handle("/logout", MustAuthorise(LogoutHandler())) 29 28 mux.Handle("/static/", http.StripPrefix("/static", staticHandler())) 30 29 mux.Handle("/release/", MustAuthorise(http.StripPrefix("/release", serveRelease()))) 30 + mux.Handle("/artist/", MustAuthorise(http.StripPrefix("/artist", serveArtist()))) 31 31 mux.Handle("/track/", MustAuthorise(http.StripPrefix("/track", serveTrack()))) 32 32 mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 33 33 if r.URL.Path != "/" { ··· 41 41 return 42 42 } 43 43 44 - type ( 45 - IndexData struct { 46 - Releases []musicModel.FullRelease 47 - Artists []*musicModel.Artist 48 - Tracks []musicModel.DisplayTrack 49 - } 50 - ) 51 - 52 - dbReleases, err := musicDB.GetAllReleases(global.DB) 44 + releases, err := musicDB.GetAllReleases(global.DB, false, 0, true) 53 45 if err != nil { 54 46 fmt.Printf("FATAL: Failed to pull releases: %s\n", err) 55 47 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 56 48 return 57 49 } 58 - releases := []musicModel.FullRelease{} 59 - for _, release := range dbReleases { 60 - fullRelease, err := musicDB.GetFullRelease(global.DB, release.ID) 61 - if err != nil { 62 - fmt.Printf("FATAL: Failed to pull full release data for %s: %s\n", release.ID, err) 63 - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 64 - return 65 - } 66 - releases = append(releases, *fullRelease) 67 - } 68 50 69 51 artists, err := musicDB.GetAllArtists(global.DB) 70 52 if err != nil { ··· 73 55 return 74 56 } 75 57 76 - dbTracks, err := musicDB.GetOrphanTracks(global.DB) 58 + tracks, err := musicDB.GetOrphanTracks(global.DB) 77 59 if err != nil { 78 60 fmt.Printf("FATAL: Failed to pull orphan tracks: %s\n", err) 79 61 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 80 62 return 81 63 } 82 64 83 - var tracks = []musicModel.DisplayTrack{} 84 - for _, track := range dbTracks { 85 - tracks = append(tracks, musicModel.DisplayTrack{ 86 - Track: track, 87 - Lyrics: template.HTML(strings.Replace(track.Lyrics, "\n", "<br>", -1)), 88 - }) 65 + type IndexData struct { 66 + Releases []*musicModel.Release 67 + Artists []*musicModel.Artist 68 + Tracks []*musicModel.Track 89 69 } 90 70 91 71 err = pages["index"].Execute(w, IndexData{ ··· 125 105 // is the session token in context? 126 106 var ctx_session = r.Context().Value("session") 127 107 if ctx_session != nil { 128 - token = ctx_session.(string) 108 + token = ctx_session.(*Session).Token 129 109 } 110 + 130 111 // okay, is it in the auth header? 131 112 if token == "" { 132 113 if strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") { ··· 168 149 169 150 func LoginHandler() http.Handler { 170 151 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 171 - if discord.CREDENTIALS_PROVIDED && ADMIN_ID_DISCORD == "" { 152 + if !discord.CREDENTIALS_PROVIDED || ADMIN_ID_DISCORD == "" { 172 153 http.Error(w, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable) 173 154 return 174 155 } 175 156 157 + fmt.Println(discord.CLIENT_ID) 158 + fmt.Println(discord.API_ENDPOINT) 159 + fmt.Println(discord.REDIRECT_URI) 160 + 176 161 code := r.URL.Query().Get("code") 177 162 178 163 if code == "" { ··· 209 194 cookie.Name = "token" 210 195 cookie.Value = session.Token 211 196 cookie.Expires = time.Now().Add(24 * time.Hour) 212 - // TODO: uncomment this probably that might be nice i think 213 - // cookie.Secure = true 197 + if strings.HasPrefix(global.HTTP_DOMAIN, "https") { 198 + cookie.Secure = true 199 + } 214 200 cookie.HttpOnly = true 215 201 cookie.Path = "/" 216 202 http.SetCookie(w, &cookie)
+12 -12
admin/releasehttp.go
··· 5 5 "net/http" 6 6 "strings" 7 7 8 - "arimelody.me/arimelody.me/global" 9 - db "arimelody.me/arimelody.me/music/controller" 10 - "arimelody.me/arimelody.me/music/model" 8 + "arimelody-web/global" 9 + db "arimelody-web/music/controller" 10 + "arimelody-web/music/model" 11 11 ) 12 12 13 13 func serveRelease() http.Handler { ··· 15 15 slices := strings.Split(r.URL.Path[1:], "/") 16 16 releaseID := slices[0] 17 17 18 - release, err := db.GetFullRelease(global.DB, releaseID) 18 + release, err := db.GetRelease(global.DB, releaseID, true) 19 19 if err != nil { 20 20 if strings.Contains(err.Error(), "no rows") { 21 21 http.NotFound(w, r) 22 22 return 23 23 } 24 - fmt.Printf("FATAL: Failed to pull full release data for %s: %s\n", release.ID, err) 24 + fmt.Printf("FATAL: Failed to pull full release data for %s: %s\n", releaseID, err) 25 25 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 26 26 return 27 27 } ··· 68 68 }) 69 69 } 70 70 71 - func serveEditCredits(release *model.FullRelease) http.Handler { 71 + func serveEditCredits(release *model.Release) http.Handler { 72 72 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 73 73 w.Header().Set("Content-Type", "text/html") 74 74 err := components["editcredits"].Execute(w, release) ··· 79 79 }) 80 80 } 81 81 82 - func serveAddCredit(release *model.FullRelease) http.Handler { 82 + func serveAddCredit(release *model.Release) http.Handler { 83 83 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 84 - artists, err := db.GetArtistsNotOnRelease(global.DB, release.Release.ID) 84 + artists, err := db.GetArtistsNotOnRelease(global.DB, release.ID) 85 85 if err != nil { 86 86 fmt.Printf("FATAL: Failed to pull artists not on %s: %s\n", release.ID, err) 87 87 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) ··· 128 128 }) 129 129 } 130 130 131 - func serveEditLinks(release *model.FullRelease) http.Handler { 131 + func serveEditLinks(release *model.Release) http.Handler { 132 132 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 133 133 w.Header().Set("Content-Type", "text/html") 134 134 err := components["editlinks"].Execute(w, release) ··· 139 139 }) 140 140 } 141 141 142 - func serveEditTracks(release *model.FullRelease) http.Handler { 142 + func serveEditTracks(release *model.Release) http.Handler { 143 143 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 144 144 w.Header().Set("Content-Type", "text/html") 145 145 err := components["edittracks"].Execute(w, release) ··· 150 150 }) 151 151 } 152 152 153 - func serveAddTrack(release *model.FullRelease) http.Handler { 153 + func serveAddTrack(release *model.Release) http.Handler { 154 154 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 155 - tracks, err := db.GetTracksNotOnRelease(global.DB, release.Release.ID) 155 + tracks, err := db.GetTracksNotOnRelease(global.DB, release.ID) 156 156 if err != nil { 157 157 fmt.Printf("FATAL: Failed to pull tracks not on %s: %s\n", release.ID, err) 158 158 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+151
admin/static/edit-artist.css
··· 1 + h1 { 2 + margin: 0 0 1em 0; 3 + } 4 + 5 + #artist { 6 + margin-bottom: 1em; 7 + padding: 1.5em; 8 + display: flex; 9 + flex-direction: row; 10 + gap: 1.2em; 11 + 12 + border-radius: .5em; 13 + background: #f8f8f8f8; 14 + border: 1px solid #808080; 15 + } 16 + 17 + .artist-avatar { 18 + width: 200px; 19 + text-align: center; 20 + } 21 + .artist-avatar img { 22 + width: 100%; 23 + aspect-ratio: 1; 24 + } 25 + .artist-avatar img:hover { 26 + outline: 1px solid #808080; 27 + cursor: pointer; 28 + } 29 + .artist-avatar #remove-avatar { 30 + padding: .3em .4em; 31 + } 32 + 33 + .artist-info { 34 + margin: -1em 0 0 0; 35 + flex-grow: 1; 36 + display: flex; 37 + flex-direction: column; 38 + } 39 + 40 + .attribute-header { 41 + margin: 1em 0 .2em 0; 42 + opacity: .5; 43 + } 44 + 45 + .artist-name { 46 + margin: 0; 47 + } 48 + 49 + input[type="text"] { 50 + width: calc(100% - .4em); 51 + padding: .1em .2em; 52 + font-size: inherit; 53 + font-family: inherit; 54 + font-weight: inherit; 55 + color: inherit; 56 + background: #ffffff; 57 + border: 1px solid transparent; 58 + border-radius: 4px; 59 + outline: none; 60 + } 61 + input[type="text"]:hover { 62 + border-color: #80808080; 63 + } 64 + input[type="text"]:active, 65 + input[type="text"]:focus { 66 + border-color: #808080; 67 + } 68 + 69 + button, .button { 70 + padding: .5em .8em; 71 + font-family: inherit; 72 + font-size: inherit; 73 + border-radius: .5em; 74 + border: 1px solid #a0a0a0; 75 + background: #f0f0f0; 76 + color: inherit; 77 + } 78 + button:hover, .button:hover { 79 + background: #fff; 80 + border-color: #d0d0d0; 81 + } 82 + button:active, .button:active { 83 + background: #d0d0d0; 84 + border-color: #808080; 85 + } 86 + 87 + button { 88 + color: inherit; 89 + } 90 + button.save { 91 + background: #6fd7ff; 92 + border-color: #6f9eb0; 93 + } 94 + button.delete { 95 + background: #ff7171; 96 + border-color: #7d3535; 97 + } 98 + button:hover { 99 + background: #fff; 100 + border-color: #d0d0d0; 101 + } 102 + button:active { 103 + background: #d0d0d0; 104 + border-color: #808080; 105 + } 106 + button[disabled] { 107 + background: #d0d0d0 !important; 108 + border-color: #808080 !important; 109 + opacity: .5; 110 + cursor: not-allowed !important; 111 + } 112 + 113 + a.delete { 114 + color: #d22828; 115 + } 116 + 117 + .artist-actions { 118 + margin-top: auto; 119 + display: flex; 120 + gap: .5em; 121 + flex-direction: row; 122 + justify-content: right; 123 + } 124 + 125 + .card-title a.button { 126 + text-decoration: none; 127 + } 128 + 129 + .credit { 130 + margin: 1em 0; 131 + padding: .5em; 132 + display: flex; 133 + flex-direction: row; 134 + gap: 1em; 135 + align-items: center; 136 + background: #f8f8f8; 137 + border-radius: 8px; 138 + border: 1px solid #808080; 139 + } 140 + 141 + .release-artwork { 142 + width: 64px; 143 + height: min-content; 144 + border-radius: 4px; 145 + } 146 + 147 + .credit-info h3, 148 + .credit-info p { 149 + margin: 0; 150 + font-size: .9em; 151 + }
+79
admin/static/edit-artist.js
··· 1 + const artistID = document.getElementById("artist").dataset.id; 2 + const nameInput = document.getElementById("name"); 3 + const avatarImg = document.getElementById("avatar"); 4 + const removeAvatarBtn = document.getElementById("remove-avatar"); 5 + const avatarInput = document.getElementById("avatar-file"); 6 + const websiteInput = document.getElementById("website"); 7 + const saveBtn = document.getElementById("save"); 8 + const deleteBtn = document.getElementById("delete"); 9 + 10 + saveBtn.addEventListener("click", () => { 11 + fetch("/api/v1/artist/" + artistID, { 12 + method: "PUT", 13 + body: JSON.stringify({ 14 + name: nameInput.value, 15 + website: websiteInput.value, 16 + avatar: avatarImg.src, 17 + }), 18 + headers: { "Content-Type": "application/json" } 19 + }).then(res => { 20 + if (!res.ok) { 21 + res.text().then(error => { 22 + console.error(error); 23 + alert("Failed to update release: " + error); 24 + }); 25 + return; 26 + } 27 + 28 + location = location; 29 + }); 30 + }); 31 + 32 + deleteBtn.addEventListener("click", () => { 33 + if (artistID != prompt( 34 + "You are about to permanently delete " + artistID + ". " + 35 + "This action is irreversible. " + 36 + "Please enter \"" + artistID + "\" to continue.")) return; 37 + fetch("/api/v1/artist/" + artistID, { 38 + method: "DELETE", 39 + }).then(res => { 40 + if (!res.ok) { 41 + res.text().then(error => { 42 + console.error(error); 43 + alert("Failed to delete release: " + error); 44 + }); 45 + return; 46 + } 47 + 48 + location = "/admin"; 49 + }); 50 + }); 51 + 52 + [nameInput, websiteInput].forEach(input => { 53 + input.addEventListener("change", () => { 54 + saveBtn.disabled = false; 55 + }); 56 + input.addEventListener("keypress", () => { 57 + saveBtn.disabled = false; 58 + }); 59 + }); 60 + 61 + avatarImg.addEventListener("click", () => { 62 + avatarInput.addEventListener("change", () => { 63 + if (avatarInput.files.length > 0) { 64 + const reader = new FileReader(); 65 + reader.onload = e => { 66 + const data = e.target.result; 67 + avatarImg.src = data; 68 + saveBtn.disabled = false; 69 + }; 70 + reader.readAsDataURL(avatarInput.files[0]); 71 + } 72 + }); 73 + avatarInput.click(); 74 + }); 75 + 76 + removeAvatarBtn.addEventListener("click", () => { 77 + avatarImg.src = "/img/default-avatar.png" 78 + saveBtn.disabled = false; 79 + });
-2
admin/static/edit-release.js
··· 1 - import Stateful from "/script/silver.min.js" 2 - 3 1 const releaseID = document.getElementById("release").dataset.id; 4 2 const titleInput = document.getElementById("title"); 5 3 const artworkImg = document.getElementById("artwork");
+2 -2
admin/static/edit-track.css
··· 6 6 7 7 #track { 8 8 margin-bottom: 1em; 9 - padding: 1.5em; 9 + padding: .5em 1.5em 1.5em 1.5em; 10 10 display: flex; 11 11 flex-direction: row; 12 12 gap: 1.2em; ··· 34 34 } 35 35 36 36 #title { 37 - width: 100%; 37 + width: calc(100% - .4em); 38 38 padding: .1em .2em; 39 39 } 40 40
+26 -1
admin/static/index.js
··· 1 1 const newReleaseBtn = document.getElementById("create-release"); 2 + const newArtistBtn = document.getElementById("create-artist"); 2 3 const newTrackBtn = document.getElementById("create-track"); 3 4 4 5 newReleaseBtn.addEventListener("click", event => { ··· 24 25 }); 25 26 }); 26 27 28 + newArtistBtn.addEventListener("click", event => { 29 + event.preventDefault(); 30 + const id = prompt("Enter an ID for this artist:"); 31 + if (id == null || id == "") return; 32 + 33 + fetch("/api/v1/artist", { 34 + method: "POST", 35 + headers: { "Content-Type": "application/json" }, 36 + body: JSON.stringify({id}) 37 + }).then(res => { 38 + res.text().then(text => { 39 + if (res.ok) { 40 + location = "/admin/artist/" + id; 41 + } else { 42 + alert("Request failed: " + text); 43 + console.error(text); 44 + } 45 + }) 46 + }).catch(err => { 47 + alert("Failed to create artist. Check the console for details."); 48 + console.error(err); 49 + }); 50 + }); 51 + 27 52 newTrackBtn.addEventListener("click", event => { 28 53 event.preventDefault(); 29 54 const title = prompt("Enter an title for this track:"); ··· 43 68 } 44 69 }) 45 70 }).catch(err => { 46 - alert("Failed to create release. Check the console for details."); 71 + alert("Failed to create track. Check the console for details."); 47 72 console.error(err); 48 73 }); 49 74 });
+5
admin/templates.go
··· 29 29 filepath.Join("views", "prideflag.html"), 30 30 filepath.Join("admin", "views", "edit-release.html"), 31 31 )), 32 + "artist": template.Must(template.ParseFiles( 33 + filepath.Join("admin", "views", "layout.html"), 34 + filepath.Join("views", "prideflag.html"), 35 + filepath.Join("admin", "views", "edit-artist.html"), 36 + )), 32 37 "track": template.Must(template.ParseFiles( 33 38 filepath.Join("admin", "views", "layout.html"), 34 39 filepath.Join("views", "prideflag.html"),
+6 -16
admin/trackhttp.go
··· 5 5 "net/http" 6 6 "strings" 7 7 8 - "arimelody.me/arimelody.me/global" 9 - "arimelody.me/arimelody.me/music/model" 10 - "arimelody.me/arimelody.me/music/controller" 8 + "arimelody-web/global" 9 + "arimelody-web/music/model" 10 + "arimelody-web/music/controller" 11 11 ) 12 12 13 13 func serveTrack() http.Handler { ··· 25 25 return 26 26 } 27 27 28 - dbReleases, err := music.GetTrackReleases(global.DB, track.ID) 28 + releases, err := music.GetTrackReleases(global.DB, track.ID, true) 29 29 if err != nil { 30 - fmt.Printf("Error rendering admin track page for %s: %s\n", id, err) 30 + fmt.Printf("FATAL: Failed to pull releases for %s: %s\n", id, err) 31 31 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 32 32 return 33 33 } 34 - releases := []model.FullRelease{} 35 - for _, release := range dbReleases { 36 - fullRelease, err := music.GetFullRelease(global.DB, release.ID) 37 - if err != nil { 38 - fmt.Printf("FATAL: Failed to pull full release data for %s: %s\n", release.ID, err) 39 - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 40 - return 41 - } 42 - releases = append(releases, *fullRelease) 43 - } 44 34 45 35 type Track struct { 46 36 *model.Track 47 - Releases []model.FullRelease 37 + Releases []*model.Release 48 38 } 49 39 50 40 err = pages["track"].Execute(w, Track{ Track: track, Releases: releases })
+72
admin/views/edit-artist.html
··· 1 + {{define "head"}} 2 + <title>Editing {{.Name}} - ari melody 💫</title> 3 + 4 + <link rel="stylesheet" href="/admin/static/edit-artist.css"> 5 + {{end}} 6 + 7 + {{define "content"}} 8 + <main> 9 + <h1>Editing Artist</h1> 10 + 11 + <div id="artist" data-id="{{.ID}}"> 12 + <div class="artist-avatar"> 13 + <img src="{{.Avatar}}" alt="" width="256" loading="lazy" id="avatar"> 14 + <input type="file" id="avatar-file" name="Artwork" accept=".png,.jpg,.jpeg" hidden> 15 + <button id="remove-avatar">Remove</button> 16 + </div> 17 + <div class="artist-info"> 18 + <p class="attribute-header">Name</p> 19 + <h2 class="artist-name"> 20 + <input type="text" id="name" name="artist-name" value="{{.Name}}"> 21 + </h2> 22 + 23 + <p class="attribute-header">Website</p> 24 + <input type="text" id="website" name="website" value="{{.Website}}"> 25 + 26 + <div class="artist-actions"> 27 + <button type="submit" class="save" id="save" disabled>Save</button> 28 + </div> 29 + </div> 30 + </div> 31 + 32 + <div class="card-title"> 33 + <h2>Featured in</h2> 34 + </div> 35 + <div class="card releases"> 36 + {{if .Credits}} 37 + {{range .Credits}} 38 + <div class="credit"> 39 + <img src="{{.Release.Artwork}}" alt="" width="64" loading="lazy" class="release-artwork"> 40 + <div class="credit-info"> 41 + <h3 class="credit-name"><a href="/admin/release/{{.Release.ID}}">{{.Release.Title}}</a></h3> 42 + <p class="credit-artists">{{.Release.PrintArtists true true}}</p> 43 + <p class="artist-role"> 44 + Role: {{.Role}} 45 + {{if .Primary}} 46 + <small>(Primary)</small> 47 + {{end}} 48 + </p> 49 + </div> 50 + </div> 51 + {{end}} 52 + {{else}} 53 + <p>This artist has no credits.</p> 54 + {{end}} 55 + </div> 56 + 57 + <div class="card-title"> 58 + <h2>Danger Zone</h2> 59 + </div> 60 + <div class="card danger"> 61 + <p> 62 + Clicking the button below will delete this artist. 63 + This action is <strong>irreversible</strong>. 64 + You will be prompted to confirm this decision. 65 + </p> 66 + <button class="delete" id="delete">Delete Artist</button> 67 + </div> 68 + 69 + </main> 70 + 71 + <script type="module" src="/admin/static/edit-artist.js" defer></script> 72 + {{end}}
+9 -9
admin/views/edit-release.html
··· 112 112 <div class="credit"> 113 113 <img src="{{.Artist.GetAvatar}}" alt="" width="64" loading="lazy" class="artist-avatar"> 114 114 <div class="credit-info"> 115 - <p class="artist-name"><a href="/admin/artists/{{.Artist.ID}}">{{.Artist.Name}}</a></p> 115 + <p class="artist-name"><a href="/admin/artist/{{.Artist.ID}}">{{.Artist.Name}}</a></p> 116 116 <p class="artist-role"> 117 117 {{.Role}} 118 118 {{if .Primary}} ··· 152 152 >Edit</a> 153 153 </div> 154 154 <div class="card tracks"> 155 - {{range .Tracks}} 156 - <div class="track" data-id="{{.ID}}"> 155 + {{range $i, $track := .Tracks}} 156 + <div class="track" data-id="{{$track.ID}}"> 157 157 <h2 class="track-title"> 158 - <span class="track-number">{{.Number}}</span> 159 - <a href="/admin/track/{{.ID}}">{{.Title}}</a> 158 + <span class="track-number">{{.Add $i 1}}</span> 159 + <a href="/admin/track/{{$track.ID}}">{{$track.Title}}</a> 160 160 </h2> 161 161 162 162 <h3>Description</h3> 163 - {{if .Description}} 164 - <p class="track-description">{{.Description}}</p> 163 + {{if $track.Description}} 164 + <p class="track-description">{{$track.GetDescriptionHTML}}</p> 165 165 {{else}} 166 166 <p class="track-description empty">No description provided.</p> 167 167 {{end}} 168 168 169 169 <h3>Lyrics</h3> 170 - {{if .Lyrics}} 171 - <p class="track-lyrics">{{.Lyrics}}</p> 170 + {{if $track.Lyrics}} 171 + <p class="track-lyrics">{{$track.GetLyricsHTML}}</p> 172 172 {{else}} 173 173 <p class="track-lyrics empty">There are no lyrics.</p> 174 174 {{end}}
+4 -4
admin/views/index.html
··· 22 22 23 23 <div class="card-title"> 24 24 <h1>Artists</h1> 25 - <a class="create-btn">Create New</a> 25 + <a class="create-btn" id="create-artist">Create New</a> 26 26 </div> 27 27 <div class="card artists"> 28 28 {{range $Artist := .Artists}} 29 29 <div class="artist"> 30 30 <img src="{{$Artist.GetAvatar}}" alt="" width="64" loading="lazy" class="artist-avatar"> 31 - <a href="/admin/artists/{{$Artist.ID}}" class="artist-name">{{$Artist.Name}}</a> 31 + <a href="/admin/artist/{{$Artist.ID}}" class="artist-name">{{$Artist.Name}}</a> 32 32 </div> 33 33 {{end}} 34 34 {{if not .Artists}} ··· 49 49 <a href="/admin/track/{{$Track.ID}}">{{$Track.Title}}</a> 50 50 </h2> 51 51 {{if $Track.Description}} 52 - <p class="track-description">{{$Track.Description}}</p> 52 + <p class="track-description">{{$Track.GetDescriptionHTML}}</p> 53 53 {{else}} 54 54 <p class="track-description empty">No description provided.</p> 55 55 {{end}} 56 56 {{if $Track.Lyrics}} 57 - <p class="track-lyrics">{{$Track.Lyrics}}</p> 57 + <p class="track-lyrics">{{$Track.GetLyricsHTML}}</p> 58 58 {{else}} 59 59 <p class="track-lyrics empty">There are no lyrics.</p> 60 60 {{end}}
+8 -11
api/api.go
··· 5 5 "net/http" 6 6 "strings" 7 7 8 - "arimelody.me/arimelody.me/admin" 9 - "arimelody.me/arimelody.me/global" 10 - "arimelody.me/arimelody.me/music/model" 11 - music "arimelody.me/arimelody.me/music/view" 8 + "arimelody-web/admin" 9 + "arimelody-web/global" 10 + music "arimelody-web/music/controller" 11 + musicView "arimelody-web/music/view" 12 12 ) 13 13 14 14 func Handler() http.Handler { ··· 18 18 19 19 mux.Handle("/v1/artist/", http.StripPrefix("/v1/artist", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 20 20 var artistID = strings.Split(r.URL.Path[1:], "/")[0] 21 - var artist model.Artist 22 - err := global.DB.Get(&artist, "SELECT * FROM artist WHERE id=$1", artistID) 21 + artist, err := music.GetArtist(global.DB, artistID) 23 22 if err != nil { 24 23 if strings.Contains(err.Error(), "no rows") { 25 24 http.NotFound(w, r) ··· 61 60 62 61 mux.Handle("/v1/music/", http.StripPrefix("/v1/music", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 63 62 var releaseID = strings.Split(r.URL.Path[1:], "/")[0] 64 - var release model.Release 65 - err := global.DB.Get(&release, "SELECT * FROM musicrelease WHERE id=$1", releaseID) 63 + release, err := music.GetRelease(global.DB, releaseID, true) 66 64 if err != nil { 67 65 if strings.Contains(err.Error(), "no rows") { 68 66 http.NotFound(w, r) ··· 76 74 switch r.Method { 77 75 case http.MethodGet: 78 76 // GET /api/v1/music/{id} 79 - music.ServeRelease(release).ServeHTTP(w, r) 77 + musicView.ServeRelease(release).ServeHTTP(w, r) 80 78 case http.MethodPut: 81 79 // PUT /api/v1/music/{id} (admin) 82 80 admin.MustAuthorise(UpdateRelease(release)).ServeHTTP(w, r) ··· 104 102 105 103 mux.Handle("/v1/track/", http.StripPrefix("/v1/track", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 106 104 var trackID = strings.Split(r.URL.Path[1:], "/")[0] 107 - var track model.Track 108 - err := global.DB.Get(&track, "SELECT * FROM musictrack WHERE id=$1", trackID) 105 + track, err := music.GetTrack(global.DB, trackID) 109 106 if err != nil { 110 107 if strings.Contains(err.Error(), "no rows") { 111 108 http.NotFound(w, r)
+51 -54
api/artist.go
··· 3 3 import ( 4 4 "encoding/json" 5 5 "fmt" 6 + "io/fs" 6 7 "net/http" 8 + "os" 9 + "path/filepath" 7 10 "strings" 8 11 9 - "arimelody.me/arimelody.me/global" 10 - "arimelody.me/arimelody.me/music/model" 11 - db "arimelody.me/arimelody.me/music/controller" 12 + "arimelody-web/global" 13 + db "arimelody-web/music/controller" 14 + music "arimelody-web/music/controller" 15 + "arimelody-web/music/model" 12 16 ) 13 17 14 - type artistJSON struct { 15 - ID string `json:"id"` 16 - Name *string `json:"name"` 17 - Website *string `json:"website"` 18 - Avatar *string `json:"avatar"` 19 - } 20 - 21 18 func ServeAllArtists() http.Handler { 22 19 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 23 20 var artists = []*model.Artist{} ··· 36 33 }) 37 34 } 38 35 39 - func ServeArtist(artist model.Artist) http.Handler { 36 + func ServeArtist(artist *model.Artist) http.Handler { 40 37 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 41 38 type ( 42 39 creditJSON struct { ··· 44 41 Primary bool `json:"primary"` 45 42 } 46 43 artistJSON struct { 47 - model.Artist 44 + *model.Artist 48 45 Credits map[string]creditJSON `json:"credits"` 49 46 } 50 47 ) ··· 78 75 79 76 func CreateArtist() http.Handler { 80 77 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 81 - var data artistJSON 82 - err := json.NewDecoder(r.Body).Decode(&data) 78 + var artist model.Artist 79 + err := json.NewDecoder(r.Body).Decode(&artist) 83 80 if err != nil { 84 81 http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) 85 82 return 86 83 } 87 84 88 - if data.ID == "" { 85 + if artist.ID == "" { 89 86 http.Error(w, "Artist ID cannot be blank\n", http.StatusBadRequest) 90 87 return 91 88 } 92 - if data.Name == nil || *data.Name == "" { 93 - http.Error(w, "Artist name cannot be blank\n", http.StatusBadRequest) 94 - return 95 - } 89 + if artist.Name == "" { artist.Name = artist.ID } 96 90 97 - var artist = model.Artist{ 98 - ID: data.ID, 99 - Name: *data.Name, 100 - Website: *data.Website, 101 - Avatar: *data.Avatar, 102 - } 103 - 104 - _, err = global.DB.Exec( 105 - "INSERT INTO artist (id, name, website, avatar) "+ 106 - "VALUES ($1, $2, $3, $4)", 107 - artist.ID, 108 - artist.Name, 109 - artist.Website, 110 - artist.Avatar) 91 + err = music.CreateArtist(global.DB, &artist) 111 92 if err != nil { 112 93 if strings.Contains(err.Error(), "duplicate key") { 113 - http.Error(w, fmt.Sprintf("Artist %s already exists\n", data.ID), http.StatusBadRequest) 94 + http.Error(w, fmt.Sprintf("Artist %s already exists\n", artist.ID), http.StatusBadRequest) 114 95 return 115 96 } 116 97 fmt.Printf("FATAL: Failed to create artist %s: %s\n", artist.ID, err) ··· 122 103 }) 123 104 } 124 105 125 - func UpdateArtist(artist model.Artist) http.Handler { 106 + func UpdateArtist(artist *model.Artist) http.Handler { 126 107 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 127 - var data artistJSON 128 - err := json.NewDecoder(r.Body).Decode(&data) 108 + err := json.NewDecoder(r.Body).Decode(&artist) 129 109 if err != nil { 130 110 fmt.Printf("FATAL: Failed to update artist: %s\n", err) 131 111 http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) 132 112 return 133 113 } 134 114 135 - if data.ID != "" { artist.ID = data.ID } 136 - if data.Name != nil { artist.Name = *data.Name } 137 - if data.Website != nil { artist.Website = *data.Website } 138 - if data.Avatar != nil { artist.Avatar = *data.Avatar } 115 + if artist.Avatar == "" { 116 + artist.Avatar = "/img/default-avatar.png" 117 + } else { 118 + if strings.Contains(artist.Avatar, ";base64,") { 119 + var artworkDirectory = filepath.Join("uploads", "avatar") 120 + filename, err := HandleImageUpload(&artist.Avatar, artworkDirectory, artist.ID) 139 121 140 - _, err = global.DB.Exec( 141 - "UPDATE artist "+ 142 - "SET name=$2, website=$3, avatar=$4 "+ 143 - "WHERE id=$1", 144 - artist.ID, 145 - artist.Name, 146 - artist.Website, 147 - artist.Avatar) 122 + // clean up files with this ID and different extensions 123 + err = filepath.Walk(artworkDirectory, func(path string, info fs.FileInfo, err error) error { 124 + if path == filepath.Join(artworkDirectory, filename) { return nil } 125 + 126 + withoutExt := strings.TrimSuffix(path, filepath.Ext(path)) 127 + if withoutExt != filepath.Join(artworkDirectory, artist.ID) { return nil } 128 + 129 + return os.Remove(path) 130 + }) 131 + if err != nil { 132 + fmt.Printf("WARN: Error while cleaning up avatar files: %s\n", err) 133 + } 134 + 135 + artist.Avatar = fmt.Sprintf("/uploads/avatar/%s", filename) 136 + } 137 + } 138 + 139 + err = music.UpdateArtist(global.DB, artist) 148 140 if err != nil { 141 + if strings.Contains(err.Error(), "no rows") { 142 + http.NotFound(w, r) 143 + return 144 + } 149 145 fmt.Printf("FATAL: Failed to update artist %s: %s\n", artist.ID, err) 150 146 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 151 147 } 152 148 }) 153 149 } 154 150 155 - func DeleteArtist(artist model.Artist) http.Handler { 151 + func DeleteArtist(artist *model.Artist) http.Handler { 156 152 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 157 - _, err := global.DB.Exec( 158 - "DELETE FROM artist "+ 159 - "WHERE id=$1", 160 - artist.ID) 153 + err := music.DeleteArtist(global.DB, artist.ID) 161 154 if err != nil { 155 + if strings.Contains(err.Error(), "no rows") { 156 + http.NotFound(w, r) 157 + return 158 + } 162 159 fmt.Printf("FATAL: Failed to delete artist %s: %s\n", artist.ID, err) 163 160 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 164 161 }
+55 -37
api/release.go
··· 10 10 "strings" 11 11 "time" 12 12 13 - "arimelody.me/arimelody.me/admin" 14 - "arimelody.me/arimelody.me/global" 15 - music "arimelody.me/arimelody.me/music/controller" 16 - "arimelody.me/arimelody.me/music/model" 13 + "arimelody-web/admin" 14 + "arimelody-web/global" 15 + music "arimelody-web/music/controller" 16 + "arimelody-web/music/model" 17 17 ) 18 18 19 19 func ServeCatalog() http.Handler { 20 20 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 21 - 22 - releases := []*model.Release{} 23 - err := global.DB.Select(&releases, "SELECT * FROM musicrelease ORDER BY release_date DESC") 21 + releases, err := music.GetAllReleases(global.DB, false, 0, true) 24 22 if err != nil { 25 23 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 26 24 return 27 25 } 28 26 29 - catalog := []model.ReleaseShorthand{} 27 + type Release struct { 28 + ID string `json:"id"` 29 + Title string `json:"title"` 30 + ReleaseType model.ReleaseType `json:"type" db:"type"` 31 + ReleaseDate time.Time `json:"releaseDate" db:"release_date"` 32 + Artwork string `json:"artwork"` 33 + Buylink string `json:"buylink"` 34 + Copyright string `json:"copyright" db:"copyright"` 35 + } 36 + 37 + catalog := []Release{} 30 38 authorised := admin.GetSession(r) != nil 31 39 for _, release := range releases { 32 40 if !release.Visible && !authorised { 33 41 continue 34 42 } 35 - catalog = append(catalog, model.ReleaseShorthand{ 43 + catalog = append(catalog, Release{ 36 44 ID: release.ID, 37 45 Title: release.Title, 38 46 ReleaseType: release.ReleaseType, 39 47 ReleaseDate: release.ReleaseDate, 40 48 Artwork: release.Artwork, 41 49 Buylink: release.Buylink, 50 + Copyright: release.Copyright, 42 51 }) 43 52 } 44 53 ··· 85 94 http.Error(w, fmt.Sprintf("Release %s already exists\n", release.ID), http.StatusBadRequest) 86 95 return 87 96 } 88 - fmt.Printf("Failed to create release %s: %s\n", release.ID, err) 97 + fmt.Printf("FATAL: Failed to create release %s: %s\n", release.ID, err) 89 98 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 90 99 return 91 100 } ··· 100 109 }) 101 110 } 102 111 103 - func UpdateRelease(release model.Release) http.Handler { 112 + func UpdateRelease(release *model.Release) http.Handler { 104 113 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 105 114 if r.URL.Path == "/" { 106 115 http.NotFound(w, r) ··· 157 166 } 158 167 } 159 168 160 - err = music.UpdateRelease(global.DB, &release) 169 + err = music.UpdateRelease(global.DB, release) 161 170 if err != nil { 171 + if strings.Contains(err.Error(), "no rows") { 172 + http.NotFound(w, r) 173 + return 174 + } 162 175 fmt.Printf("FATAL: Failed to update release %s: %s\n", release.ID, err) 163 176 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 164 177 } 165 178 }) 166 179 } 167 180 168 - func UpdateReleaseTracks(release model.Release) http.Handler { 181 + func UpdateReleaseTracks(release *model.Release) http.Handler { 169 182 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 170 183 var trackIDs = []string{} 171 184 err := json.NewDecoder(r.Body).Decode(&trackIDs) ··· 174 187 return 175 188 } 176 189 177 - err = music.UpdateReleaseTracks(global.DB, &release, trackIDs) 190 + err = music.UpdateReleaseTracks(global.DB, release.ID, trackIDs) 178 191 if err != nil { 179 - fmt.Printf("Failed to update tracks for %s: %s\n", release.ID, err) 192 + if strings.Contains(err.Error(), "no rows") { 193 + http.NotFound(w, r) 194 + return 195 + } 196 + fmt.Printf("FATAL: Failed to update tracks for %s: %s\n", release.ID, err) 180 197 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 181 198 } 182 199 }) 183 200 } 184 201 185 - func UpdateReleaseCredits(release model.Release) http.Handler { 202 + func UpdateReleaseCredits(release *model.Release) http.Handler { 186 203 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 187 204 type creditJSON struct { 188 205 Artist string ··· 196 213 return 197 214 } 198 215 199 - var credits []model.Credit 216 + var credits []*model.Credit 200 217 for _, credit := range data { 201 - credits = append(credits, model.Credit{ 218 + credits = append(credits, &model.Credit{ 202 219 Artist: model.Artist{ 203 220 ID: credit.Artist, 204 221 }, ··· 207 224 }) 208 225 } 209 226 210 - err = music.UpdateReleaseCredits(global.DB, &release, credits) 227 + err = music.UpdateReleaseCredits(global.DB, release.ID, credits) 211 228 if err != nil { 212 229 if strings.Contains(err.Error(), "duplicate key") { 213 230 http.Error(w, "Artists may only be credited once\n", http.StatusBadRequest) 214 231 return 215 232 } 216 - fmt.Printf("Failed to update links for %s: %s\n", release.ID, err) 233 + if strings.Contains(err.Error(), "no rows") { 234 + http.NotFound(w, r) 235 + return 236 + } 237 + fmt.Printf("FATAL: Failed to update links for %s: %s\n", release.ID, err) 217 238 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 218 239 } 219 240 }) 220 241 } 221 242 222 - func UpdateReleaseLinks(release model.Release) http.Handler { 243 + func UpdateReleaseLinks(release *model.Release) http.Handler { 223 244 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 224 245 if r.Method != http.MethodPut { 225 246 http.NotFound(w, r) ··· 233 254 return 234 255 } 235 256 236 - tx := global.DB.MustBegin() 237 - tx.MustExec("DELETE FROM musiclink WHERE release=$1", release.ID) 238 - for _, link := range links { 239 - tx.MustExec( 240 - "INSERT INTO musiclink "+ 241 - "(release, name, url) "+ 242 - "VALUES ($1, $2, $3)", 243 - release.ID, 244 - link.Name, 245 - link.URL) 246 - } 247 - err = tx.Commit() 257 + err = music.UpdateReleaseLinks(global.DB, release.ID, links) 248 258 if err != nil { 249 - fmt.Printf("Failed to update links for %s: %s\n", release.ID, err) 259 + if strings.Contains(err.Error(), "no rows") { 260 + http.NotFound(w, r) 261 + return 262 + } 263 + fmt.Printf("FATAL: Failed to update links for %s: %s\n", release.ID, err) 250 264 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 251 265 } 252 266 }) 253 267 } 254 268 255 - func DeleteRelease(release model.Release) http.Handler { 269 + func DeleteRelease(release *model.Release) http.Handler { 256 270 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 257 - _, err := global.DB.Exec("DELETE FROM musicrelease WHERE id=$1", release.ID) 271 + err := music.DeleteRelease(global.DB, release.ID) 258 272 if err != nil { 259 - fmt.Printf("Failed to delete release %s: %s\n", release.ID, err) 273 + if strings.Contains(err.Error(), "no rows") { 274 + http.NotFound(w, r) 275 + return 276 + } 277 + fmt.Printf("FATAL: Failed to delete release %s: %s\n", release.ID, err) 260 278 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 261 279 } 262 280 })
+15 -10
api/track.go
··· 5 5 "fmt" 6 6 "net/http" 7 7 8 - "arimelody.me/arimelody.me/global" 9 - music "arimelody.me/arimelody.me/music/controller" 10 - "arimelody.me/arimelody.me/music/model" 8 + "arimelody-web/global" 9 + music "arimelody-web/music/controller" 10 + "arimelody-web/music/model" 11 11 ) 12 12 13 13 type ( 14 14 Track struct { 15 - model.Track 16 - Releases []model.ReleaseShorthand 15 + *model.Track 16 + Releases []string `json:"releases"` 17 17 } 18 18 ) 19 19 ··· 48 48 }) 49 49 } 50 50 51 - func ServeTrack(track model.Track) http.Handler { 51 + func ServeTrack(track *model.Track) http.Handler { 52 52 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 53 - releases, err := music.GetTrackReleases(global.DB, track.ID) 53 + dbReleases, err := music.GetTrackReleases(global.DB, track.ID, false) 54 54 if err != nil { 55 55 fmt.Printf("FATAL: Failed to pull track releases for %s from DB: %s\n", track.ID, err) 56 56 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 57 + } 58 + 59 + releases := []string{} 60 + for _, release := range dbReleases { 61 + releases = append(releases, release.ID) 57 62 } 58 63 59 64 w.Header().Add("Content-Type", "application/json") ··· 97 102 }) 98 103 } 99 104 100 - func UpdateTrack(track model.Track) http.Handler { 105 + func UpdateTrack(track *model.Track) http.Handler { 101 106 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 102 107 if r.Method != http.MethodPut || r.URL.Path == "/" { 103 108 http.NotFound(w, r) ··· 115 120 return 116 121 } 117 122 118 - err = music.UpdateTrack(global.DB, &track) 123 + err = music.UpdateTrack(global.DB, track) 119 124 if err != nil { 120 125 fmt.Printf("Failed to update track %s: %s\n", track.ID, err) 121 126 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) ··· 130 135 }) 131 136 } 132 137 133 - func DeleteTrack(track model.Track) http.Handler { 138 + func DeleteTrack(track *model.Track) http.Handler { 134 139 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 135 140 if r.Method != http.MethodDelete || r.URL.Path == "/" { 136 141 http.NotFound(w, r)
+2
api/uploads.go
··· 35 35 } 36 36 defer file.Close() 37 37 38 + // TODO: generate compressed versions of image (512x512?) 39 + 38 40 buffer := bufio.NewWriter(file) 39 41 _, err = buffer.Write(imageData) 40 42 if err != nil {
+7 -7
discord/discord.go
··· 6 6 "fmt" 7 7 "net/http" 8 8 "net/url" 9 + "os" 9 10 "strings" 10 11 11 - "arimelody.me/arimelody.me/global" 12 + "arimelody-web/global" 12 13 ) 13 14 14 15 const API_ENDPOINT = "https://discord.com/api/v10" 15 16 16 17 var CREDENTIALS_PROVIDED = true 17 18 var CLIENT_ID = func() string { 18 - id := global.Args["discordClient"] 19 + id := os.Getenv("DISCORD_CLIENT") 19 20 if id == "" { 20 - fmt.Printf("WARN: Discord client ID (-discordClient) was not provided. Admin login will be unavailable.\n") 21 + fmt.Printf("WARN: Discord client ID (DISCORD_CLIENT) was not provided. Admin login will be unavailable.\n") 21 22 CREDENTIALS_PROVIDED = false 22 23 } 23 24 return id 24 25 }() 25 26 var CLIENT_SECRET = func() string { 26 - secret := global.Args["discordSecret"] 27 - if secret== "" { 28 - fmt.Printf("WARN: Discord secret (-discordSecret) was not provided. Admin login will be unavailable.\n") 27 + secret := os.Getenv("DISCORD_SECRET") 28 + if secret == "" { 29 + fmt.Printf("WARN: Discord secret (DISCORD_SECRET) was not provided. Admin login will be unavailable.\n") 29 30 CREDENTIALS_PROVIDED = false 30 31 } 31 32 return secret ··· 107 108 } 108 109 109 110 auth_info := AuthInfoResponse{} 110 - 111 111 err = json.NewDecoder(res.Body).Decode(&auth_info) 112 112 if err != nil { 113 113 return DiscordUser{}, errors.New(fmt.Sprintf("Failed to parse auth information from discord: %s\n", err))
-18
docker-compose-db.yml
··· 1 - version: '3.9' 2 - 3 - services: 4 - db: 5 - image: postgres:16.1-alpine3.18 6 - container_name: arimelody.me-db 7 - ports: 8 - - 5432:5432 9 - volumes: 10 - - arimelody-db:/var/lib/postgresql/data 11 - environment: 12 - POSTGRES_DB: arimelody 13 - POSTGRES_USER: arimelody 14 - POSTGRES_PASSWORD: fuckingpassword 15 - 16 - volumes: 17 - arimelody-db: 18 - external: true
+22
docker-compose.yml
··· 1 + services: 2 + web: 3 + image: docker.arimelody.me/arimelody.me:latest 4 + build: . 5 + ports: 6 + - 8080:8080 7 + volumes: 8 + - ./uploads:/app/uploads 9 + environment: 10 + HTTP_DOMAIN: "https://arimelody.me" 11 + ARIMELODY_DB_HOST: db 12 + DISCORD_ADMIN: # your discord user ID. 13 + DISCORD_CLIENT: # your discord OAuth client ID. 14 + DISCORD_SECRET: # your discord OAuth secret. 15 + db: 16 + image: postgres:16.1-alpine3.18 17 + volumes: 18 + - ./db:/var/lib/postgresql/data 19 + environment: 20 + POSTGRES_DB: arimelody 21 + POSTGRES_USER: arimelody 22 + POSTGRES_PASSWORD: fuckingpassword
+4 -4
global/data.go
··· 35 35 }() 36 36 37 37 var HTTP_DOMAIN = func() string { 38 - domain := Args["httpDomain"] 39 - if domain != "" { 40 - return domain 38 + domain := os.Getenv("HTTP_DOMAIN") 39 + if domain == "" { 40 + return "https://arimelody.me" 41 41 } 42 - return "https://arimelody.me" 42 + return domain 43 43 }() 44 44 45 45 var DB *sqlx.DB
+1 -1
global/funcs.go
··· 6 6 "strconv" 7 7 "time" 8 8 9 - "arimelody.me/arimelody.me/colour" 9 + "arimelody-web/colour" 10 10 ) 11 11 12 12 func DefaultHeaders(next http.Handler) http.Handler {
+2 -13
go.mod
··· 1 - module arimelody.me/arimelody.me 1 + module arimelody-web 2 2 3 3 go 1.22 4 4 5 5 require ( 6 - github.com/gomarkdown/markdown v0.0.0-20231222211730-1d6d20845b47 7 - github.com/jmoiron/sqlx v1.3.5 6 + github.com/jmoiron/sqlx v1.4.0 8 7 github.com/lib/pq v1.10.9 9 8 ) 10 - 11 - require ( 12 - github.com/jackc/pgpassfile v1.0.0 // indirect 13 - github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect 14 - github.com/jackc/pgx/v5 v5.5.5 // indirect 15 - github.com/jackc/puddle/v2 v2.2.1 // indirect 16 - golang.org/x/crypto v0.17.0 // indirect 17 - golang.org/x/sync v0.1.0 // indirect 18 - golang.org/x/text v0.14.0 // indirect 19 - )
+8 -28
go.sum
··· 1 - github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 2 - github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= 3 - github.com/gomarkdown/markdown v0.0.0-20231222211730-1d6d20845b47 h1:k4Tw0nt6lwro3Uin8eqoET7MDA4JnT8YgbCjc/g5E3k= 4 - github.com/gomarkdown/markdown v0.0.0-20231222211730-1d6d20845b47/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= 5 - github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= 6 - github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= 7 - github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= 8 - github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= 9 - github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw= 10 - github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= 11 - github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= 12 - github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= 13 - github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= 14 - github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= 15 - github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= 1 + filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= 2 + filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= 3 + github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= 4 + github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= 5 + github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= 6 + github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= 16 7 github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= 17 8 github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 18 - github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= 19 - github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 20 - github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 21 - github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 22 - github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 23 - golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= 24 - golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= 25 - golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= 26 - golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 27 - golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 28 - golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 29 - gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 30 - gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 9 + github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= 10 + github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+12 -7
main.go
··· 1 1 package main 2 2 3 3 import ( 4 + "errors" 4 5 "fmt" 5 6 "log" 6 7 "net/http" ··· 8 9 "path/filepath" 9 10 "time" 10 11 11 - "arimelody.me/arimelody.me/admin" 12 - "arimelody.me/arimelody.me/api" 13 - "arimelody.me/arimelody.me/global" 14 - musicView "arimelody.me/arimelody.me/music/view" 15 - "arimelody.me/arimelody.me/templates" 12 + "arimelody-web/admin" 13 + "arimelody-web/api" 14 + "arimelody-web/global" 15 + musicView "arimelody-web/music/view" 16 + "arimelody-web/templates" 16 17 17 18 "github.com/jmoiron/sqlx" 18 19 _ "github.com/lib/pq" ··· 22 23 23 24 func main() { 24 25 // initialise database connection 26 + var dbHost = os.Getenv("ARIMELODY_DB_HOST") 27 + if dbHost == "" { dbHost = "127.0.0.1" } 28 + 25 29 var err error 26 - global.DB, err = sqlx.Connect("postgres", "user=arimelody dbname=arimelody password=fuckingpassword sslmode=disable") 30 + global.DB, err = sqlx.Connect("postgres", "host=" + dbHost + " user=arimelody dbname=arimelody password=fuckingpassword sslmode=disable") 27 31 if err != nil { 28 32 fmt.Fprintf(os.Stderr, "FATAL: Unable to create database connection pool: %v\n", err) 29 33 os.Exit(1) ··· 64 68 func staticHandler(directory string) http.Handler { 65 69 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 66 70 info, err := os.Stat(filepath.Join(directory, filepath.Clean(r.URL.Path))) 71 + 67 72 // does the file exist? 68 73 if err != nil { 69 - if os.IsNotExist(err) { 74 + if errors.Is(err, os.ErrNotExist) { 70 75 http.NotFound(w, r) 71 76 return 72 77 }
+47 -20
music/controller/artist.go
··· 1 1 package music 2 2 3 3 import ( 4 - "arimelody.me/arimelody.me/music/model" 4 + "arimelody-web/music/model" 5 5 "github.com/jmoiron/sqlx" 6 6 ) 7 7 ··· 45 45 } 46 46 47 47 func GetArtistCredits(db *sqlx.DB, artistID string) ([]*model.Credit, error) { 48 - type DBCredit struct { 49 - Release string 50 - Artist string 51 - Role string 52 - Primary bool `db:"is_primary"` 53 - } 54 - var dbCredits []DBCredit 55 - 56 - err := db.Select(&dbCredits, "SELECT * FROM musiccredit WHERE artist=$1", artistID) 48 + rows, err := db.Query( 49 + "SELECT release.id,release.title,release.artwork,artist.id,artist.name,artist.website,artist.avatar,role,is_primary "+ 50 + "FROM musiccredit "+ 51 + "JOIN musicrelease AS release ON release=release.id "+ 52 + "JOIN artist ON artist=artist.id "+ 53 + "WHERE artist=$1 "+ 54 + "ORDER BY release_date DESC", 55 + artistID) 57 56 if err != nil { 58 57 return nil, err 59 58 } 59 + defer rows.Close() 60 + 61 + type NamePrimary struct { 62 + Name string `json:"name"` 63 + Primary bool `json:"primary" db:"is_primary"` 64 + } 65 + var credits []*model.Credit 66 + for rows.Next() { 67 + var credit model.Credit 68 + err = rows.Scan( 69 + &credit.Release.ID, 70 + &credit.Release.Title, 71 + &credit.Release.Artwork, 72 + &credit.Artist.ID, 73 + &credit.Artist.Name, 74 + &credit.Artist.Website, 75 + &credit.Artist.Avatar, 76 + &credit.Role, 77 + &credit.Primary, 78 + ) 60 79 61 - var credits []*model.Credit 62 - for _, credit := range dbCredits { 63 - credits = append(credits, &model.Credit{ 64 - Release: model.Release{ ID: credit.Release }, 65 - Artist: model.Artist{ ID: credit.Artist }, 66 - Role: credit.Role, 67 - Primary: credit.Primary, 68 - }) 80 + otherArtists := []NamePrimary{} 81 + err = db.Select(&otherArtists, 82 + "SELECT name,is_primary FROM artist "+ 83 + "JOIN musiccredit ON artist=id "+ 84 + "WHERE release=$1", 85 + credit.Release.ID) 86 + for _, otherCredit := range otherArtists { 87 + credit.Release.Credits = append(credit.Release.Credits, &model.Credit{ 88 + Artist: model.Artist{ 89 + Name: otherCredit.Name, 90 + }, 91 + Primary: otherCredit.Primary, 92 + }) 93 + } 94 + 95 + credits = append(credits, &credit) 69 96 } 70 97 71 98 return credits, nil ··· 104 131 return nil 105 132 } 106 133 107 - func DeleteArtist(db *sqlx.DB, artist *model.Artist) error { 134 + func DeleteArtist(db *sqlx.DB, artistID string) error { 108 135 _, err := db.Exec( 109 136 "DELETE FROM artist "+ 110 137 "WHERE id=$1", 111 - artist.ID, 138 + artistID, 112 139 ) 113 140 if err != nil { 114 141 return err
+143 -106
music/controller/release.go
··· 4 4 "errors" 5 5 "fmt" 6 6 7 - "arimelody.me/arimelody.me/music/model" 7 + "arimelody-web/music/model" 8 8 "github.com/jmoiron/sqlx" 9 9 ) 10 10 11 - func GetRelease(db *sqlx.DB, id string) (*model.Release, error) { 12 - var releases = model.Release{} 11 + func GetRelease(db *sqlx.DB, id string, full bool) (*model.Release, error) { 12 + var release = model.Release{} 13 13 14 - err := db.Get(&releases, "SELECT * FROM musicrelease WHERE id=$1", id) 14 + err := db.Get(&release, "SELECT * FROM musicrelease WHERE id=$1", id) 15 15 if err != nil { 16 16 return nil, err 17 17 } 18 18 19 - return &releases, nil 19 + if full { 20 + // get credits 21 + credits, err := GetReleaseCredits(db, id) 22 + if err != nil { 23 + return nil, errors.New(fmt.Sprintf("Credits: %s", err)) 24 + } 25 + for _, credit := range credits { 26 + release.Credits = append(release.Credits, credit) 27 + } 28 + 29 + // get tracks 30 + tracks, err := GetReleaseTracks(db, id) 31 + if err != nil { 32 + return nil, errors.New(fmt.Sprintf("Tracks: %s", err)) 33 + } 34 + for _, track := range tracks { 35 + release.Tracks = append(release.Tracks, track) 36 + } 37 + 38 + // get links 39 + links, err := GetReleaseLinks(db, id) 40 + if err != nil { 41 + return nil, errors.New(fmt.Sprintf("Links: %s", err)) 42 + } 43 + for _, link := range links { 44 + release.Links = append(release.Links, link) 45 + } 46 + } 47 + 48 + return &release, nil 20 49 } 21 50 22 - func GetAllReleases(db *sqlx.DB) ([]*model.Release, error) { 51 + func GetAllReleases(db *sqlx.DB, onlyVisible bool, limit int, full bool) ([]*model.Release, error) { 23 52 var releases = []*model.Release{} 24 53 25 - err := db.Select(&releases, "SELECT * FROM musicrelease ORDER BY release_date DESC") 54 + query := "SELECT * FROM musicrelease" 55 + if onlyVisible { 56 + query += " WHERE visible=true" 57 + } 58 + query += " ORDER BY release_date DESC" 59 + var err error 60 + if limit > 0 { 61 + err = db.Select(&releases, query + " LIMIT $1", limit) 62 + } else { 63 + err = db.Select(&releases, query) 64 + } 26 65 if err != nil { 27 66 return nil, err 28 67 } 29 68 69 + if full { 70 + for _, release := range releases { 71 + // get credits 72 + credits, err := GetReleaseCredits(db, release.ID) 73 + if err != nil { 74 + return nil, errors.New(fmt.Sprintf("Credits: %s", err)) 75 + } 76 + for _, credit := range credits { 77 + release.Credits = append(release.Credits, credit) 78 + } 79 + 80 + // get tracks 81 + tracks, err := GetReleaseTracks(db, release.ID) 82 + if err != nil { 83 + return nil, errors.New(fmt.Sprintf("Tracks: %s", err)) 84 + } 85 + for _, track := range tracks { 86 + release.Tracks = append(release.Tracks, track) 87 + } 88 + 89 + // get links 90 + links, err := GetReleaseLinks(db, release.ID) 91 + if err != nil { 92 + return nil, errors.New(fmt.Sprintf("Links: %s", err)) 93 + } 94 + for _, link := range links { 95 + release.Links = append(release.Links, link) 96 + } 97 + } 98 + } 99 + 30 100 return releases, nil 31 101 } 32 102 ··· 78 148 return nil 79 149 } 80 150 81 - func UpdateReleaseTracks(db *sqlx.DB, release *model.Release, new_tracks []string) error { 82 - _, err := db.Exec( 83 - "DELETE FROM musicreleasetrack "+ 84 - "WHERE release=$1", 85 - release.ID, 86 - ) 151 + func UpdateReleaseTracks(db *sqlx.DB, releaseID string, new_tracks []string) error { 152 + tx, err := db.Begin() 87 153 if err != nil { 88 154 return err 89 155 } 90 156 157 + _, err = tx.Exec("DELETE FROM musicreleasetrack WHERE release=$1", releaseID) 158 + if err != nil { 159 + return err 160 + } 91 161 for i, trackID := range new_tracks { 92 - _, err = db.Exec( 162 + _, err = tx.Exec( 93 163 "INSERT INTO musicreleasetrack "+ 94 164 "(release, track, number) "+ 95 165 "VALUES ($1, $2, $3)", 96 - release.ID, 166 + releaseID, 97 167 trackID, 98 - i, 99 - ) 168 + i) 100 169 if err != nil { 101 170 return err 102 171 } 103 172 } 104 173 174 + err = tx.Commit() 175 + if err != nil { 176 + return err 177 + } 178 + 105 179 return nil 106 180 } 107 181 108 - func UpdateReleaseCredits(db *sqlx.DB, release *model.Release, new_credits []model.Credit) error { 109 - _, err := db.Exec( 110 - "DELETE FROM musiccredit "+ 111 - "WHERE release=$1", 112 - release.ID, 113 - ) 182 + func UpdateReleaseCredits(db *sqlx.DB, releaseID string, new_credits []*model.Credit) error { 183 + tx, err := db.Begin() 114 184 if err != nil { 115 185 return err 116 186 } 117 187 188 + _, err = tx.Exec("DELETE FROM musiccredit WHERE release=$1", releaseID) 189 + if err != nil { 190 + return err 191 + } 118 192 for _, credit := range new_credits { 119 - _, err = db.Exec( 193 + _, err = tx.Exec( 120 194 "INSERT INTO musiccredit "+ 121 195 "(release, artist, role, is_primary) "+ 122 196 "VALUES ($1, $2, $3, $4)", 123 - release.ID, 197 + releaseID, 124 198 credit.Artist.ID, 125 199 credit.Role, 126 200 credit.Primary, ··· 130 204 } 131 205 } 132 206 207 + err = tx.Commit() 208 + if err != nil { 209 + return err 210 + } 211 + 133 212 return nil 134 213 } 135 214 136 - func UpdateReleaseLinks(db *sqlx.DB, release *model.Release, new_links []*model.Link) error { 137 - _, err := db.Exec( 138 - "DELETE FROM musiclink "+ 139 - "WHERE release=$1", 140 - release.ID, 141 - ) 215 + func UpdateReleaseLinks(db *sqlx.DB, releaseID string, new_links []*model.Link) error { 216 + tx, err := db.Begin() 142 217 if err != nil { 143 218 return err 144 219 } 145 220 221 + _, err = tx.Exec("DELETE FROM musiclink WHERE release=$1", releaseID) 222 + if err != nil { 223 + return err 224 + } 146 225 for _, link := range new_links { 147 - _, err = db.Exec( 226 + fmt.Printf("%s: %s\n", link.Name, link.URL) 227 + _, err := tx.Exec( 148 228 "INSERT INTO musiclink "+ 149 229 "(release, name, url) "+ 150 230 "VALUES ($1, $2, $3)", 151 - release.ID, 231 + releaseID, 152 232 link.Name, 153 233 link.URL, 154 234 ) ··· 157 237 } 158 238 } 159 239 240 + err = tx.Commit() 241 + if err != nil { 242 + return err 243 + } 244 + 160 245 return nil 161 246 } 162 247 163 - func DeleteRelease(db *sqlx.DB, release *model.Release) error { 248 + func DeleteRelease(db *sqlx.DB, releaseID string) error { 164 249 _, err := db.Exec( 165 250 "DELETE FROM musicrelease "+ 166 251 "WHERE id=$1", 167 - release.ID, 252 + releaseID, 168 253 ) 169 254 if err != nil { 170 255 return err ··· 173 258 return nil 174 259 } 175 260 176 - func GetFullRelease(db *sqlx.DB, releaseID string) (*model.FullRelease, error) { 177 - // get release 178 - release, err := GetRelease(db, releaseID) 179 - if err != nil { 180 - return nil, err 181 - } 182 - 183 - // get credits 184 - credits, err := GetReleaseCredits(db, releaseID) 185 - if err != nil { 186 - return nil, errors.New(fmt.Sprintf("Credits: %s", err)) 187 - } 188 - 189 - // get artists 190 - for i, credit := range credits { 191 - artist, err := GetArtist(db, credit.Artist.ID) 192 - if err != nil { 193 - return nil, errors.New(fmt.Sprintf("FATAL: Failed to serve release %s: Artists: %s\n", release.ID, err)) 194 - } 195 - credits[i].Artist = *artist 196 - } 197 - 198 - // get tracks 199 - dbTracks, err := GetReleaseTracks(db, releaseID) 200 - if err != nil { 201 - return nil, errors.New(fmt.Sprintf("Tracks: %s", err)) 202 - } 203 - tracks := []model.DisplayTrack{} 204 - for i, track := range dbTracks { 205 - tracks = append(tracks, track.MakeDisplay(i + 1)) 206 - } 207 - 208 - // get links 209 - links, err := GetReleaseLinks(db, releaseID) 210 - if err != nil { 211 - return nil, errors.New(fmt.Sprintf("Links: %s", err)) 212 - } 213 - 214 - return &model.FullRelease{ 215 - Release: release, 216 - Tracks: tracks, 217 - Credits: credits, 218 - Links: links, 219 - }, nil 220 - } 221 - 222 - func GetReleaseTracks(db *sqlx.DB, releaseID string) ([]model.Track, error) { 223 - var tracks = []model.Track{} 261 + func GetReleaseTracks(db *sqlx.DB, releaseID string) ([]*model.Track, error) { 262 + var tracks = []*model.Track{} 224 263 225 264 err := db.Select(&tracks, 226 265 "SELECT musictrack.* FROM musictrack "+ ··· 236 275 return tracks, nil 237 276 } 238 277 239 - func GetReleaseCredits(db *sqlx.DB, releaseID string) ([]model.Credit, error) { 240 - type DBCredit struct { 241 - Release string 242 - Artist string 243 - Role string 244 - Primary bool `db:"is_primary"` 245 - } 246 - var dbCredits []DBCredit 247 - 248 - err := db.Select(&dbCredits, 249 - "SELECT musiccredit.* FROM musiccredit "+ 250 - "JOIN artist ON artist=id "+ 251 - "WHERE release=$1", 278 + func GetReleaseCredits(db *sqlx.DB, releaseID string) ([]*model.Credit, error) { 279 + rows, err := db.Query( 280 + "SELECT artist.id,artist.name,artist.website,artist.avatar,role,is_primary "+ 281 + "FROM musiccredit "+ 282 + "JOIN artist ON artist=artist.id "+ 283 + "JOIN musicrelease ON release=musicrelease.id "+ 284 + "WHERE musicrelease.id=$1 "+ 285 + "ORDER BY is_primary DESC", 252 286 releaseID, 253 287 ) 254 288 if err != nil { 255 289 return nil, err 256 290 } 257 291 258 - var credits []model.Credit 259 - for _, credit := range dbCredits { 260 - credits = append(credits, model.Credit{ 261 - Release: model.Release{ ID: credit.Release }, 262 - Artist: model.Artist{ ID: credit.Artist }, 263 - Role: credit.Role, 264 - Primary: credit.Primary, 265 - }) 292 + var credits []*model.Credit 293 + for rows.Next() { 294 + credit := model.Credit{} 295 + rows.Scan( 296 + &credit.Artist.ID, 297 + &credit.Artist.Name, 298 + &credit.Artist.Website, 299 + &credit.Artist.Avatar, 300 + &credit.Role, 301 + &credit.Primary) 302 + credits = append(credits, &credit) 266 303 } 267 304 268 305 return credits, nil 269 306 } 270 307 271 - func GetReleaseLinks(db *sqlx.DB, releaseID string) ([]model.Link, error) { 272 - var links = []model.Link{} 308 + func GetReleaseLinks(db *sqlx.DB, releaseID string) ([]*model.Link, error) { 309 + var links = []*model.Link{} 273 310 274 311 err := db.Select(&links, "SELECT name,url FROM musiclink WHERE release=$1", releaseID) 275 312 if err != nil {
+42 -4
music/controller/track.go
··· 1 1 package music 2 2 3 3 import ( 4 - "arimelody.me/arimelody.me/music/model" 4 + "arimelody-web/music/model" 5 5 "github.com/jmoiron/sqlx" 6 6 ) 7 7 ··· 55 55 return tracks, nil 56 56 } 57 57 58 - func GetTrackReleases(db *sqlx.DB, trackID string) ([]model.ReleaseShorthand, error) { 59 - var releases = []model.ReleaseShorthand{} 58 + func GetTrackReleases(db *sqlx.DB, trackID string, full bool) ([]*model.Release, error) { 59 + var releases = []*model.Release{} 60 60 61 61 err := db.Select(&releases, 62 - "SELECT id,title,type,release_date,artwork,buylink FROM musicrelease "+ 62 + "SELECT id,title,type,release_date,artwork,buylink "+ 63 + "FROM musicrelease "+ 63 64 "JOIN musicreleasetrack ON release=id "+ 64 65 "WHERE track=$1 "+ 65 66 "ORDER BY release_date", ··· 68 69 if err != nil { 69 70 return nil, err 70 71 } 72 + 73 + type NamePrimary struct { 74 + Name string `json:"name"` 75 + Primary bool `json:"primary" db:"is_primary"` 76 + } 77 + for _, release := range releases { 78 + // get artists 79 + credits := []NamePrimary{} 80 + err := db.Select(&credits, 81 + "SELECT name,is_primary FROM artist "+ 82 + "JOIN musiccredit ON artist=artist.id "+ 83 + "JOIN musicrelease ON release=musicrelease.id "+ 84 + "WHERE musicrelease.id=$1", release.ID) 85 + if err != nil { 86 + return nil, err 87 + } 88 + for _, credit := range credits { 89 + release.Credits = append(release.Credits, &model.Credit{ 90 + Artist: model.Artist{ 91 + Name: credit.Name, 92 + }, 93 + Primary: credit.Primary, 94 + }) 95 + } 96 + 97 + // get tracks 98 + tracks := []string{} 99 + err = db.Select(&tracks, "SELECT track FROM musicreleasetrack WHERE release=$1", release.ID) 100 + if err != nil { 101 + return nil, err 102 + } 103 + for _, trackID := range tracks { 104 + release.Tracks = append(release.Tracks, &model.Track{ 105 + ID: trackID, 106 + }) 107 + } 108 + } 71 109 72 110 return releases, nil 73 111 }
-55
music/model/artist.go
··· 1 1 package model 2 2 3 - import "strings" 4 - 5 3 type ( 6 4 Artist struct { 7 5 ID string `json:"id"` ··· 21 19 } 22 20 return artist.Avatar 23 21 } 24 - 25 - func (release FullRelease) GetUniqueArtists(only_primary bool) []*Artist { 26 - var artists = []*Artist{} 27 - 28 - for _, credit := range release.Credits { 29 - if only_primary && !credit.Primary { 30 - continue 31 - } 32 - 33 - exists := false 34 - for _, a := range artists { 35 - if a.ID == credit.Artist.ID { 36 - exists = true 37 - break 38 - } 39 - } 40 - 41 - if exists { 42 - continue 43 - } 44 - 45 - artists = append(artists, &credit.Artist) 46 - } 47 - 48 - return artists 49 - } 50 - 51 - func (release FullRelease) GetUniqueArtistNames(only_primary bool) []string { 52 - var names = []string{} 53 - for _, artist := range release.GetUniqueArtists(only_primary) { 54 - names = append(names, artist.Name) 55 - } 56 - 57 - return names 58 - } 59 - 60 - func (release FullRelease) PrintArtists(only_primary bool, ampersand bool) string { 61 - names := release.GetUniqueArtistNames(only_primary) 62 - 63 - if len(names) == 0 { 64 - return "Unknown Artist" 65 - } else if len(names) == 1 { 66 - return names[0] 67 - } 68 - 69 - if ampersand { 70 - res := strings.Join(names[:len(names)-1], ", ") 71 - res += " & " + names[len(names)-1] 72 - return res 73 - } else { 74 - return strings.Join(names[:], ", ") 75 - } 76 - }
+8 -6
music/model/credit.go
··· 1 1 package model 2 2 3 - type Credit struct { 4 - Release Release `json:"release"` 5 - Artist Artist `json:"artist"` 6 - Role string `json:"role"` 7 - Primary bool `json:"primary" db:"is_primary"` 8 - } 3 + type ( 4 + Credit struct { 5 + Release Release `json:"release"` 6 + Artist Artist `json:"artist"` 7 + Role string `json:"role"` 8 + Primary bool `json:"primary" db:"is_primary"` 9 + } 10 + )
+39 -17
music/model/release.go
··· 1 1 package model 2 2 3 3 import ( 4 + "html/template" 5 + "strings" 4 6 "time" 5 7 ) 6 8 ··· 19 21 Buylink string `json:"buylink"` 20 22 Copyright string `json:"copyright" db:"copyright"` 21 23 CopyrightURL string `json:"copyrightURL" db:"copyrighturl"` 22 - } 23 - 24 - FullRelease struct { 25 - *Release 26 - Tracks []DisplayTrack `json:"tracks"` 27 - Credits []Credit `json:"credits"` 28 - Links []Link `json:"links"` 29 - } 30 - 31 - ReleaseShorthand struct { 32 - ID string `json:"id"` 33 - Title string `json:"title"` 34 - ReleaseType ReleaseType `json:"type" db:"type"` 35 - ReleaseDate time.Time `json:"releaseDate" db:"release_date"` 36 - Artwork string `json:"artwork"` 37 - Buylink string `json:"buylink"` 24 + Tracks []*Track `json:"tracks"` 25 + Credits []*Credit `json:"credits"` 26 + Links []*Link `json:"links"` 38 27 } 39 28 ) 40 29 ··· 48 37 49 38 // GETTERS 50 39 40 + func (release Release) GetDescriptionHTML() template.HTML { 41 + return template.HTML(strings.Replace(release.Description, "\n", "<br>", -1)) 42 + } 43 + 51 44 func (release Release) TextReleaseDate() string { 52 45 return release.ReleaseDate.Format("2006-01-02T15:04") 53 46 } ··· 67 60 return release.Artwork 68 61 } 69 62 70 - func (release FullRelease) IsSingle() bool { 63 + func (release Release) IsSingle() bool { 71 64 return len(release.Tracks) == 1; 72 65 } 73 66 74 67 func (release Release) IsReleased() bool { 75 68 return release.ReleaseDate.Before(time.Now()) 76 69 } 70 + 71 + func (release Release) GetUniqueArtistNames(only_primary bool) []string { 72 + names := []string{} 73 + 74 + for _, credit := range release.Credits { 75 + if only_primary && !credit.Primary { continue } 76 + names = append(names, credit.Artist.Name) 77 + } 78 + 79 + return names 80 + } 81 + 82 + func (release Release) PrintArtists(only_primary bool, ampersand bool) string { 83 + names := release.GetUniqueArtistNames(only_primary) 84 + 85 + if len(names) == 0 { 86 + return "Unknown Artist" 87 + } else if len(names) == 1 { 88 + return names[0] 89 + } 90 + 91 + if ampersand { 92 + res := strings.Join(names[:len(names)-1], ", ") 93 + res += " & " + names[len(names)-1] 94 + return res 95 + } else { 96 + return strings.Join(names[:], ", ") 97 + } 98 + }
+16 -17
music/model/track.go
··· 7 7 8 8 type ( 9 9 Track struct { 10 - ID string `json:"id"` 11 - Title string `json:"title"` 12 - Description string `json:"description"` 13 - Lyrics string `json:"lyrics" db:"lyrics"` 14 - PreviewURL string `json:"previewURL" db:"preview_url"` 10 + ID string `json:"id"` 11 + Title string `json:"title"` 12 + Description string `json:"description"` 13 + Lyrics string `json:"lyrics" db:"lyrics"` 14 + PreviewURL string `json:"previewURL" db:"preview_url"` 15 15 } 16 + ) 16 17 17 - DisplayTrack struct { 18 - *Track 19 - Lyrics template.HTML `json:"lyrics"` 20 - Number int `json:"-"` 21 - } 22 - ) 18 + func (track Track) GetDescriptionHTML() template.HTML { 19 + return template.HTML(strings.Replace(track.Description, "\n", "<br>", -1)) 20 + } 21 + 22 + func (track Track) GetLyricsHTML() template.HTML { 23 + return template.HTML(strings.Replace(track.Lyrics, "\n", "<br>", -1)) 24 + } 23 25 24 - func (track Track) MakeDisplay(number int) DisplayTrack { 25 - return DisplayTrack{ 26 - Track: &track, 27 - Lyrics: template.HTML(strings.Replace(track.Lyrics, "\n", "<br>", -1)), 28 - Number: number, 29 - } 26 + // this function is stupid and i hate that i need it 27 + func (track Track) Add(a int, b int) int { 28 + return a + b 30 29 }
+10 -19
music/view/music.go
··· 4 4 "fmt" 5 5 "net/http" 6 6 7 - "arimelody.me/arimelody.me/global" 8 - music "arimelody.me/arimelody.me/music/controller" 9 - "arimelody.me/arimelody.me/music/model" 10 - "arimelody.me/arimelody.me/templates" 7 + "arimelody-web/global" 8 + music "arimelody-web/music/controller" 9 + "arimelody-web/music/model" 10 + "arimelody-web/templates" 11 11 ) 12 12 13 13 // HTTP HANDLER METHODS ··· 21 21 return 22 22 } 23 23 24 - var release model.Release 25 - err := global.DB.Get(&release, "SELECT * FROM musicrelease WHERE id=$1", r.URL.Path[1:]) 24 + release, err := music.GetRelease(global.DB, r.URL.Path[1:], true) 26 25 if err != nil { 27 26 http.NotFound(w, r) 28 27 return ··· 36 35 37 36 func ServeCatalog() http.Handler { 38 37 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 39 - dbReleases, err := music.GetAllReleases(global.DB) 38 + releases, err := music.GetAllReleases(global.DB, true, 0, true) 40 39 if err != nil { 41 40 fmt.Printf("FATAL: Failed to pull releases for catalog: %s\n", err) 42 41 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 43 42 return 44 43 } 45 - releases := []*model.FullRelease{} 46 - for _, dbRelease := range dbReleases { 47 - if !dbRelease.Visible { continue } 48 - if !dbRelease.IsReleased() { 49 - dbRelease.ReleaseType = model.Upcoming 50 - } 51 - release, err := music.GetFullRelease(global.DB, dbRelease.ID) 52 - if err != nil { 53 - fmt.Printf("FATAL: Failed to pull full release for %s: %s\n", dbRelease.ID, err) 54 - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 55 - return 44 + 45 + for _, release := range releases { 46 + if !release.IsReleased() { 47 + release.ReleaseType = model.Upcoming 56 48 } 57 - releases = append(releases, release) 58 49 } 59 50 60 51 err = templates.Pages["music"].Execute(w, releases)
+13 -19
music/view/release.go
··· 5 5 "fmt" 6 6 "net/http" 7 7 8 - "arimelody.me/arimelody.me/admin" 9 - "arimelody.me/arimelody.me/global" 10 - "arimelody.me/arimelody.me/music/model" 11 - db "arimelody.me/arimelody.me/music/controller" 12 - "arimelody.me/arimelody.me/templates" 8 + "arimelody-web/admin" 9 + "arimelody-web/global" 10 + "arimelody-web/music/model" 11 + db "arimelody-web/music/controller" 12 + "arimelody-web/templates" 13 13 ) 14 14 15 15 type ( ··· 26 26 } 27 27 28 28 Release struct { 29 - model.Release 29 + *model.Release 30 30 Tracks []Track `json:"tracks"` 31 31 Credits []Credit `json:"credits"` 32 32 Links map[string]string `json:"links"` 33 33 } 34 34 ) 35 35 36 - func ServeRelease(release model.Release) http.Handler { 36 + func ServeRelease(release *model.Release) http.Handler { 37 37 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 38 38 // only allow authorised users to view hidden releases 39 39 authorised := admin.GetSession(r) != nil ··· 108 108 }) 109 109 } 110 110 111 - func ServeGateway(release model.Release) http.Handler { 111 + func ServeGateway(release *model.Release) http.Handler { 112 112 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 113 113 // only allow authorised users to view hidden releases 114 114 authorised := admin.GetSession(r) != nil ··· 117 117 return 118 118 } 119 119 120 - fullRelease := &model.FullRelease{ 121 - Release: &release, 122 - } 120 + response := *release 123 121 124 122 if authorised || release.IsReleased() { 125 - fullerRelease, err := db.GetFullRelease(global.DB, release.ID) 126 - if err != nil { 127 - fmt.Printf("FATAL: Failed to pull full release data for %s: %s\n", release.ID, err) 128 - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 129 - return 130 - } 131 - fullRelease = fullerRelease 123 + response.Tracks = release.Tracks 124 + response.Credits = release.Credits 125 + response.Links = release.Links 132 126 } 133 127 134 - err := templates.Pages["music-gateway"].Execute(w, fullRelease) 128 + err := templates.Pages["music-gateway"].Execute(w, response) 135 129 136 130 if err != nil { 137 131 fmt.Printf("Error rendering music gateway for %s: %s\n", release.ID, err)
+13 -1
public/style/music-gateway.css
··· 296 296 display: inline-block; 297 297 } 298 298 299 + #upcoming-release { 300 + width: fit-content; 301 + padding: .3em 1em; 302 + font-size: 1em; 303 + background: #101010; 304 + } 305 + 299 306 ul#links { 300 307 width: 100%; 301 308 margin: 1rem 0; ··· 357 364 } 358 365 359 366 #description { 360 - font-size: 1.2em; 367 + font-size: 1.1em; 368 + } 369 + 370 + #copyright { 371 + margin-bottom: 0; 372 + font-size: .8em; 361 373 } 362 374 363 375 #share {
+9 -5
views/music-gateway.html
··· 61 61 <p id="type" class="{{.ReleaseType}}">{{.ReleaseType}}</p> 62 62 {{else}} 63 63 <p id="type" class="upcoming">upcoming</p> 64 - <p>Releases: {{.PrintReleaseDate}}</p> 64 + <p id="upcoming-release">Releases: {{.PrintReleaseDate}}</p> 65 65 {{end}} 66 66 67 67 {{if .IsReleased}} ··· 79 79 80 80 {{if .Description}} 81 81 82 - <p id="description">{{.Description}}</p> 82 + <p id="description">{{.GetDescriptionHTML}}</p> 83 83 84 84 {{else if .IsSingle}} 85 85 ··· 90 90 91 91 {{end}} 92 92 93 + {{if and .Copyright .CopyrightURL}} 94 + <p id="copyright">{{.Title}} &copy; {{.GetReleaseYear}} by {{.PrintArtists true true}} is licensed under <a href="{{.CopyrightURL}}" target="_blank">{{.Copyright}}</a></p> 95 + {{end}} 96 + 93 97 <button id="share">share</button> 94 98 </div> 95 99 ··· 118 122 <div id="lyrics"> 119 123 <p class="album-track-subheading">LYRICS</p> 120 124 {{if $Track.Lyrics}} 121 - {{$Track.Lyrics}} 125 + {{$Track.GetLyricsHTML}} 122 126 {{else}} 123 127 <span class="empty">No lyrics.</span> 124 128 {{end}} ··· 130 134 <h2>TRACKS</h2> 131 135 {{range $i, $track := .Tracks}} 132 136 <details> 133 - <summary class="album-track-title">{{$track.Number}}. {{$track.Title}}</summary> 137 + <summary class="album-track-title">{{$track.Add $i 1}}. {{$track.Title}}</summary> 134 138 135 139 {{if $track.Description}} 136 140 <p class="album-track-subheading">DESCRIPTION</p> ··· 139 143 140 144 <p class="album-track-subheading">LYRICS</p> 141 145 {{if $track.Lyrics}} 142 - {{$track.Lyrics}} 146 + {{$track.GetLyricsHTML}} 143 147 {{else}} 144 148 <span class="empty">No lyrics.</span> 145 149 {{end}}