new-song.go (8103B)
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 newsong "lyrics/internal/server/ui/views/new-song" 11 "net/http" 12 "time" 13 ) 14 15 type NewSongForm struct { 16 ArtistID []domain.ArtistID `schema:"artist-id"` 17 Title string `schema:"song-title"` 18 LatinTitle string `schema:"latin-title"` 19 ReleaseID []domain.ReleaseID `schema:"release-id"` 20 Genres []string `schema:"genres"` 21 Lyrics string `schema:"lyrics"` 22 FeaturingID []domain.ArtistID `schema:"featuring-id"` 23 ReleaseDate string `schema:"release-date"` 24 MusicBrainzRecordingID domain.MusicBrainzID `schema:"musicbrainz-recording-id"` 25 CreateAsSingle bool `schema:"create-as-single"` 26 } 27 28 func parseNewSongForm(form NewSongForm) (domain.NewSong, error) { 29 var song domain.NewSong 30 31 // artists 32 if len(form.ArtistID) == 0 { 33 return song, errors.New("At least one artist is required") 34 } 35 36 artists := make([]domain.NewSongArtist, 0, len(form.ArtistID)+len(form.FeaturingID)) 37 seenArtists := make(map[int64]struct{}, len(form.ArtistID)+len(form.FeaturingID)) 38 39 for i, id := range form.ArtistID { 40 if id < 0 { 41 return song, errors.New("Invalid artist selection") 42 } 43 if _, exists := seenArtists[int64(id)]; exists { 44 return song, errors.New("An artist cannot be added more than once") 45 } 46 seenArtists[int64(id)] = struct{}{} 47 48 artists = append(artists, domain.NewSongArtist{ 49 ArtistID: id, 50 Role: domain.RolePrimary, 51 SortOrder: i, 52 }) 53 } 54 55 // featuring 56 for i, id := range form.FeaturingID { 57 if id < 0 { 58 return song, errors.New("Invalid artist selection") 59 } 60 if _, exists := seenArtists[int64(id)]; exists { 61 return song, errors.New("An artist cannot be added more than once") 62 } 63 seenArtists[int64(id)] = struct{}{} 64 65 artists = append(artists, domain.NewSongArtist{ 66 ArtistID: id, 67 Role: domain.RoleFeatured, 68 SortOrder: i, 69 }) 70 } 71 72 // title 73 if !domain.NoTrailingOrLeadingSpaceRegex.MatchString(form.Title) { 74 return song, errors.New("Title must not be empty or have leading/trailing whitespace") 75 } 76 77 // latin title 78 var latinTitle *string 79 if form.LatinTitle != "" { 80 if !domain.NoTrailingOrLeadingSpaceRegex.MatchString(form.LatinTitle) { 81 return song, errors.New("Latin title must not be empty or have leading/trailing whitespace") 82 } 83 if !domain.IsValidSortNameRegex.MatchString(form.LatinTitle) { 84 return song, errors.New("Latin title contains invalid characters") 85 } 86 latinTitle = &form.LatinTitle 87 } 88 89 // genres 90 if len(form.Genres) == 0 { 91 return song, errors.New("At least one genre is required") 92 } 93 genres := make([]domain.Genre, 0, len(form.Genres)) 94 for _, g := range form.Genres { 95 genre := domain.Genre(g) 96 if _, ok := domain.ValidGenres[genre]; !ok { 97 return song, fmt.Errorf("Unknown genre: %q", g) 98 } 99 genres = append(genres, genre) 100 } 101 102 // release date 103 releaseDate, err := time.Parse("2006-01-02", form.ReleaseDate) 104 if err != nil { 105 return song, fmt.Errorf("Invalid release date: %w", err) 106 } 107 minDate := time.Date(1000, 1, 1, 0, 0, 0, 0, time.UTC) 108 maxDate := time.Now().AddDate(3, 0, 0) 109 if releaseDate.Before(minDate) || releaseDate.After(maxDate) { 110 return song, fmt.Errorf("Release date must be between %s and %s", 111 minDate.Format("2006-01-02"), maxDate.Format("2006-01-02")) 112 } 113 114 // releases 115 releases := make([]domain.NewSongRelease, 0, len(form.ReleaseID)) 116 if !form.CreateAsSingle { 117 seenReleases := make(map[int64]struct{}, len(form.ReleaseID)) 118 for _, id := range form.ReleaseID { 119 if id < 0 { 120 return song, errors.New("Invalid release selection") 121 } 122 if _, exists := seenReleases[int64(id)]; exists { 123 return song, errors.New("A release cannot be added more than once") 124 } 125 seenReleases[int64(id)] = struct{}{} 126 127 releases = append(releases, domain.NewSongRelease{ 128 ReleaseID: id, 129 }) 130 } 131 } 132 133 // musicbrainz recording id 134 var musicbrainzRecordingID *domain.MusicBrainzID 135 if form.MusicBrainzRecordingID != "" { 136 if !domain.MusicBrainzIDIsValid(string(form.MusicBrainzRecordingID)) { 137 return song, fmt.Errorf("'%s' must be a valid MusicBrainz ID", form.MusicBrainzRecordingID) 138 } 139 musicbrainzRecordingID = &form.MusicBrainzRecordingID 140 } 141 142 song = domain.NewSong{ 143 Title: form.Title, 144 LatinTitle: latinTitle, 145 Lyrics: form.Lyrics, 146 ReleaseDate: releaseDate, 147 Genres: genres, 148 Releases: releases, 149 Artists: artists, 150 MusicBrainzRecordingID: musicbrainzRecordingID, 151 } 152 153 return song, nil 154 } 155 156 func (ss *ServerState) NewSong(w http.ResponseWriter, r *http.Request) { 157 logger := ss.logger.With("handler", "NewSong") 158 159 switch r.Method { 160 case http.MethodGet: 161 err := newsong.NewSong(newsong.NewSongParams{}).Render(r.Context(), w) 162 if err != nil { 163 if errors.Is(err, context.Canceled) { 164 return 165 } 166 167 logger.Error("failed to render new song page", "err", err) 168 http.Error(w, "Internal Server Error", http.StatusInternalServerError) 169 return 170 } 171 case http.MethodPost: 172 err := r.ParseForm() 173 if err != nil { 174 logger.Error("failed to parse http form", "err", err) 175 htmx.HxError(w, http.StatusBadRequest, "Failed to add song.") 176 return 177 } 178 179 var newSongForm NewSongForm 180 err = decoder.Decode(&newSongForm, r.PostForm) 181 if err != nil { 182 logger.Error("failed to decode http form", "err", err) 183 htmx.HxError(w, http.StatusBadRequest, err.Error()) 184 return 185 } 186 187 newSong, err := parseNewSongForm(newSongForm) 188 if err != nil { 189 logger.Error("failed to parse new song form", "err", err) 190 htmx.HxError(w, http.StatusBadRequest, err.Error()) 191 return 192 } 193 194 if newSongForm.CreateAsSingle { 195 releaseArtists := make([]domain.NewReleaseArtist, 0, len(newSong.Artists)) 196 for _, artist := range newSong.Artists { 197 releaseArtists = append(releaseArtists, domain.NewReleaseArtist(artist)) 198 } 199 200 single := domain.NewRelease{ 201 Title: newSong.Title, 202 LatinTitle: newSong.LatinTitle, 203 ReleaseDate: newSong.ReleaseDate, 204 Artists: releaseArtists, 205 Type: domain.ReleaseTypeSingle, 206 } 207 208 tx, err := ss.db.BeginTx(r.Context(), nil) 209 if err != nil { 210 logger.Error("failed to begin db transaction", "err", err) 211 htmx.HxError(w, http.StatusInternalServerError, "Failed to add release.") 212 return 213 } 214 215 id, err := db.InsertRelease(tx, single) 216 if err != nil { 217 if rollbackErr := tx.Rollback(); rollbackErr != nil { 218 logger.Error("failed to rollback db transaction", "err", rollbackErr) 219 } 220 logger.Error("failed to save new release into db", "err", err) 221 htmx.HxError(w, http.StatusInternalServerError, "Failed to add release.") 222 return 223 } 224 225 if err := tx.Commit(); err != nil { 226 logger.Error("failed to commit db transaction", "err", err) 227 htmx.HxError(w, http.StatusInternalServerError, "Failed to add release.") 228 return 229 } 230 231 logger.Info("added release", "release", single) 232 233 newSong.Releases = append(newSong.Releases, domain.NewSongRelease{ 234 ReleaseID: id, 235 TrackNumber: 1, 236 }) 237 } 238 239 tx, err := ss.db.BeginTx(r.Context(), nil) 240 if err != nil { 241 logger.Error("failed to begin db transaction", "err", err) 242 htmx.HxError(w, http.StatusInternalServerError, "Failed to add song.") 243 return 244 } 245 246 err = db.InsertSong(tx, newSong) 247 if err != nil { 248 if rollbackErr := tx.Rollback(); rollbackErr != nil { 249 logger.Error("failed to rollback db transaction", "err", rollbackErr) 250 } 251 logger.Error("failed to save new song into db", "err", err) 252 htmx.HxError(w, http.StatusInternalServerError, "Failed to add song.") 253 return 254 } 255 256 if err := tx.Commit(); err != nil { 257 logger.Error("failed to commit db transaction", "err", err) 258 htmx.HxError(w, http.StatusInternalServerError, "Failed to add song.") 259 return 260 } 261 262 logger.Info("added song", "song", newSong) 263 264 htmx.HxRedirect(w, http.StatusOK, "/") 265 } 266 }