edit-release.go (7342B)
1 package server 2 3 import ( 4 "context" 5 "errors" 6 "fmt" 7 "lyrics/internal/db" 8 "lyrics/internal/domain" 9 "lyrics/internal/server/htmx" 10 "lyrics/internal/server/routes" 11 editrelease "lyrics/internal/server/ui/views/edit-release" 12 "net/http" 13 "strconv" 14 "time" 15 ) 16 17 type EditReleaseForm struct { 18 Title string `schema:"release-title"` 19 Type domain.ReleaseType `schema:"type"` 20 LatinTitle string `schema:"latin-title"` 21 ArtistID []domain.ArtistID `schema:"artist-id"` 22 SongID []domain.SongID `schema:"song-id"` 23 TrackNumber []int `schema:"track-number"` 24 ReleaseDate string `schema:"release-date"` 25 MusicBrainzReleaseID domain.MusicBrainzID `schema:"musicbrainz-release-id"` 26 } 27 28 func parseEditReleaseForm(form EditReleaseForm) (domain.EditRelease, error) { 29 var release domain.EditRelease 30 31 // type 32 if form.Type == "" { 33 return release, errors.New("Type must not be empty") 34 } 35 if _, ok := domain.ReleaseTypes[form.Type]; !ok { 36 return release, errors.New("Invalid type selected") 37 } 38 39 // title 40 if !domain.NoTrailingOrLeadingSpaceRegex.MatchString(form.Title) { 41 return release, errors.New("Title name must not be empty or have leading/trailing whitespace") 42 } 43 44 // artists 45 if len(form.ArtistID) == 0 { 46 return release, errors.New("At least one artist is required") 47 } 48 49 artists := make([]domain.NewReleaseArtist, 0, len(form.ArtistID)) 50 seenArtists := make(map[int64]struct{}, len(form.ArtistID)) 51 52 for i, id := range form.ArtistID { 53 if id < 0 { 54 return release, errors.New("Invalid artist selection") 55 } 56 if _, exists := seenArtists[int64(id)]; exists { 57 return release, errors.New("An artist cannot be added more than once") 58 } 59 seenArtists[int64(id)] = struct{}{} 60 61 artists = append(artists, domain.NewReleaseArtist{ 62 ArtistID: id, 63 Role: domain.RolePrimary, 64 SortOrder: i, 65 }) 66 } 67 68 // latin title 69 var latinTitle *string 70 if form.LatinTitle != "" { 71 if !domain.NoTrailingOrLeadingSpaceRegex.MatchString(form.LatinTitle) { 72 return release, errors.New("Latin title must not be empty or have leading/trailing whitespace") 73 } 74 if !domain.IsValidSortNameRegex.MatchString(form.LatinTitle) { 75 return release, errors.New("Latin title contains invalid characters") 76 } 77 latinTitle = &form.LatinTitle 78 } 79 80 releaseDate, err := time.Parse("2006-01-02", form.ReleaseDate) 81 if err != nil { 82 return release, fmt.Errorf("Invalid release date: %w", err) 83 } 84 minDate := time.Date(1000, 1, 1, 0, 0, 0, 0, time.UTC) 85 maxDate := time.Now().AddDate(3, 0, 0) 86 if releaseDate.Before(minDate) || releaseDate.After(maxDate) { 87 return release, fmt.Errorf("Release date must be between %s and %s", 88 minDate.Format("2006-01-02"), maxDate.Format("2006-01-02")) 89 } 90 91 if len(form.SongID) != len(form.TrackNumber) { 92 return release, errors.New("Song ID and track number counts do not match") 93 } 94 95 songs := make([]domain.EditReleaseSong, 0, len(form.SongID)) 96 seenTracknumbers := make(map[int]domain.SongID, len(form.SongID)) 97 seenSongs := make(map[domain.SongID]int, len(form.SongID)) 98 99 for i := 0; i < len(form.SongID); i++ { 100 songID := form.SongID[i] 101 trackNumber := form.TrackNumber[i] 102 103 if songID < 0 { 104 return release, errors.New("Invalid song selection") 105 } 106 107 if trackNumber < 1 { 108 return release, errors.New("Track number can not be less that 1") 109 } 110 111 if _, exists := seenTracknumbers[trackNumber]; exists { 112 return release, errors.New("Songs may not share the same track number") 113 } 114 115 if _, exists := seenSongs[songID]; exists { 116 return release, errors.New("A song cannot be added more than once") 117 } 118 119 seenTracknumbers[trackNumber] = songID 120 seenSongs[songID] = trackNumber 121 122 songs = append(songs, domain.EditReleaseSong{ 123 SongID: songID, 124 TrackNumber: trackNumber, 125 }) 126 } 127 128 // musicbrainz release id 129 var musicbrainzReleaseID *domain.MusicBrainzID 130 if form.MusicBrainzReleaseID != "" { 131 if !domain.MusicBrainzIDIsValid(string(form.MusicBrainzReleaseID)) { 132 return release, fmt.Errorf("'%s' must be a valid MusicBrainz ID", form.MusicBrainzReleaseID) 133 } 134 musicbrainzReleaseID = &form.MusicBrainzReleaseID 135 } 136 137 release = domain.EditRelease{ 138 MusicBrainzReleaseID: musicbrainzReleaseID, 139 Title: form.Title, 140 LatinTitle: latinTitle, 141 ReleaseDate: releaseDate, 142 Artists: artists, 143 Songs: songs, 144 Type: form.Type, 145 } 146 147 return release, nil 148 } 149 150 func (ss *ServerState) EditRelease(w http.ResponseWriter, r *http.Request) { 151 logger := ss.logger.With("handler", "EditRelease") 152 153 idStr := r.PathValue("id") 154 id, err := strconv.Atoi(idStr) 155 if err != nil { 156 logger.Error("failed to convert release id to integer", "id", idStr, "err", err) 157 htmx.HxError(w, http.StatusBadRequest, err.Error()) 158 return 159 } 160 161 slug := r.PathValue("slug") 162 logger = logger.With("id", id, "slug", slug) 163 164 switch r.Method { 165 case http.MethodGet: 166 release, err := db.Release(ss.db, domain.ReleaseID(id)) 167 if err != nil { 168 logger.Error("failed to get release", "err", err) 169 htmx.HxError(w, http.StatusInternalServerError, fmt.Sprintf("failed to query for release '%s'", slug)) 170 return 171 } 172 173 err = editrelease.EditRelease(editrelease.EditReleaseParams{ 174 Release: release, 175 }).Render(r.Context(), w) 176 if err != nil { 177 if errors.Is(err, context.Canceled) { 178 return 179 } 180 181 logger.Error("failed to render edit release page", "err", err) 182 http.Error(w, "Internal Server Error", http.StatusInternalServerError) 183 return 184 } 185 case http.MethodPost: 186 err := r.ParseForm() 187 if err != nil { 188 logger.Error("failed to parse http form", "err", err) 189 htmx.HxError(w, http.StatusBadRequest, "Failed to edit release.") 190 return 191 } 192 193 var editReleaseForm EditReleaseForm 194 err = decoder.Decode(&editReleaseForm, r.PostForm) 195 if err != nil { 196 logger.Error("failed to decode http form", "err", err) 197 htmx.HxError(w, http.StatusBadRequest, err.Error()) 198 return 199 } 200 201 updatedRelease, err := parseEditReleaseForm(editReleaseForm) 202 if err != nil { 203 logger.Error("failed to parse edited release form", "err", err) 204 htmx.HxError(w, http.StatusBadRequest, err.Error()) 205 return 206 } 207 updatedRelease.ID = domain.ReleaseID(id) 208 209 tx, err := ss.db.BeginTx(r.Context(), nil) 210 if err != nil { 211 logger.Error("failed to begin db transaction", "err", err) 212 htmx.HxError(w, http.StatusInternalServerError, "Failed to edit release.") 213 return 214 } 215 216 err = db.UpdateRelease(tx, updatedRelease) 217 if err != nil { 218 if rollbackErr := tx.Rollback(); rollbackErr != nil { 219 logger.Error("failed to rollback db transaction", "err", rollbackErr) 220 } 221 logger.Error("failed to save edited release into db", "err", err) 222 htmx.HxError(w, http.StatusInternalServerError, "Failed to edit release.") 223 return 224 } 225 226 if err := tx.Commit(); err != nil { 227 logger.Error("failed to commit db transaction", "err", err) 228 htmx.HxError(w, http.StatusInternalServerError, "Failed to edit release.") 229 return 230 } 231 232 logger.Info("edited release", "release", updatedRelease) 233 234 release := domain.Release{ 235 ID: updatedRelease.ID, 236 Title: updatedRelease.Title, 237 LatinTitle: updatedRelease.LatinTitle, 238 } 239 240 htmx.HxRedirect(w, http.StatusOK, routes.Release(release)) 241 } 242 }