lyrics

lyrics
git clone git://brookjeynes.dev/bjeynes/lyrics.git
Log | Files | Refs

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 }