From 90a671982c7ab39178c9620783c0e9ca9f1a4fab Mon Sep 17 00:00:00 2001 From: ari melody Date: Sat, 1 Aug 2026 00:29:31 +0100 Subject: [PATCH] HEAVY: finish music service migration, tidy up services, more tests --- admin/artisthttp.go | 7 +- admin/http.go | 12 +- admin/releasehttp.go | 20 +- admin/trackhttp.go | 7 +- api/api.go | 18 +- api/artist.go | 53 ++- api/release.go | 171 ++++---- api/track.go | 55 ++- controller/release.go | 327 --------------- controller/track.go | 181 --------- errors/notexist.go | 16 + errors/notexist_test.go | 26 ++ errors/validation.go | 15 + errors/validation_test.go | 26 ++ main.go | 13 +- model/account.go | 4 +- model/app/appstate.go | 16 +- model/artist.go | 4 +- model/artist_test.go | 11 +- model/credit.go | 8 +- model/link_test.go | 7 +- model/release.go | 21 +- model/release_test.go | 68 +++- model/track_test.go | 9 +- repository/account/interface.go | 4 +- repository/account/memory.go | 68 ++-- repository/account/postgres.go | 11 +- repository/music/artist_memory.go | 135 +++++++ .../music/artist_postgres.go | 122 +++--- repository/music/interface.go | 110 ++++++ repository/music/release_memory.go | 228 +++++++++++ repository/music/release_postgres.go | 303 ++++++++++++++ repository/music/track_memory.go | 123 ++++++ repository/music/track_postgres.go | 137 +++++++ service/account/account.go | 86 ++-- service/account/account_test.go | 152 ++++--- service/log/log.go | 6 +- service/music/artist.go | 82 ++++ service/music/artist_test.go | 225 +++++++++++ service/music/music.go | 18 + service/music/music_test.go | 9 + service/music/release.go | 180 +++++++++ service/music/release_test.go | 374 ++++++++++++++++++ service/music/track.go | 81 ++++ service/validator/validator.go | 8 + service/validator/validator_test.go | 39 ++ view/music.go | 4 +- 47 files changed, 2698 insertions(+), 902 deletions(-) delete mode 100644 controller/release.go delete mode 100644 controller/track.go create mode 100644 errors/notexist.go create mode 100644 errors/notexist_test.go create mode 100644 errors/validation.go create mode 100644 errors/validation_test.go create mode 100644 repository/music/artist_memory.go rename controller/artist.go => repository/music/artist_postgres.go (50%) create mode 100644 repository/music/interface.go create mode 100644 repository/music/release_memory.go create mode 100644 repository/music/release_postgres.go create mode 100644 repository/music/track_memory.go create mode 100644 repository/music/track_postgres.go create mode 100644 service/music/artist.go create mode 100644 service/music/artist_test.go create mode 100644 service/music/music.go create mode 100644 service/music/music_test.go create mode 100644 service/music/release.go create mode 100644 service/music/release_test.go create mode 100644 service/music/track.go create mode 100644 service/validator/validator.go create mode 100644 service/validator/validator_test.go diff --git a/admin/artisthttp.go b/admin/artisthttp.go index ef67a4e..05cfbb5 100644 --- a/admin/artisthttp.go +++ b/admin/artisthttp.go @@ -6,7 +6,6 @@ import ( "strings" "arimelody-web/admin/templates" - "arimelody-web/controller" "arimelody-web/model" "arimelody-web/model/app" ) @@ -23,7 +22,7 @@ func serveArtists(app *app.AppState) http.Handler { return } - artists, err := controller.GetAllArtists(app.DB) + artists, err := app.MusicService.GetAllArtists() if err != nil { fmt.Printf("WARN: Failed to fetch artists: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -50,7 +49,7 @@ func serveArtist(app *app.AppState, artistID string) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { session := r.Context().Value("session").(*model.Session) - artist, err := controller.GetArtist(app.DB, artistID) + artist, err := app.MusicService.GetArtistByID(artistID) if err != nil { if artist == nil { http.NotFound(w, r) @@ -61,7 +60,7 @@ func serveArtist(app *app.AppState, artistID string) http.Handler { return } - credits, err := controller.GetArtistCredits(app.DB, artist.ID, true) + credits, err := app.MusicService.GetArtistCredits(artistID, true) if err != nil { fmt.Printf("WARN: Failed to serve admin artist page for %s: %s\n", artistID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) diff --git a/admin/http.go b/admin/http.go index 97437ac..66e66f1 100644 --- a/admin/http.go +++ b/admin/http.go @@ -83,39 +83,39 @@ func AdminIndexHandler(app *app.AppState) http.Handler { session := r.Context().Value("session").(*model.Session) - releases, err := controller.GetAllReleases(app.DB, false, 3, true) + releases, err := app.MusicService.GetAllReleases(false, 3) if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to pull releases: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } - releaseCount, err := controller.GetReleaseCount(app.DB, false) + releaseCount, err := app.MusicService.GetReleaseCount(false) if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to pull releases count: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } - artists, err := controller.GetAllArtists(app.DB) + artists, err := app.MusicService.GetAllArtists() if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to pull artists: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } - artistCount, err := controller.GetArtistCount(app.DB) + artistCount, err := app.MusicService.GetArtistCount() if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to pull artist count: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } - tracks, err := controller.GetOrphanTracks(app.DB) + tracks, err := app.MusicService.GetOrphanTracks() if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to pull orphan tracks: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } - trackCount, err := controller.GetTrackCount(app.DB) + trackCount, err := app.MusicService.GetTrackCount() if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to pull track count: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) diff --git a/admin/releasehttp.go b/admin/releasehttp.go index d16528e..72586ed 100644 --- a/admin/releasehttp.go +++ b/admin/releasehttp.go @@ -7,9 +7,9 @@ import ( "strings" "arimelody-web/admin/templates" - "arimelody-web/controller" "arimelody-web/model" "arimelody-web/model/app" + "arimelody-web/errors" ) func serveReleases(app *app.AppState) http.Handler { @@ -34,7 +34,7 @@ func serveReleases(app *app.AppState) http.Handler { Releases []*model.Release } - releases, err := controller.GetAllReleases(app.DB, false, 0, true) + releases, err := app.MusicService.GetAllFullReleases(false, 0) if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to fetch releases: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -60,9 +60,13 @@ func serveRelease(app *app.AppState, releaseID string, action string) http.Handl return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { session := r.Context().Value("session").(*model.Session) - release, err := controller.GetRelease(app.DB, releaseID, true) + release, err := app.MusicService.GetFullReleaseByID(releaseID) if err != nil { - if strings.Contains(err.Error(), "no rows") { + if errors.IsValidationError(err) { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if errors.IsNotExistError(err) { http.NotFound(w, r) return } @@ -130,7 +134,7 @@ func serveEditCredits(release *model.Release) http.Handler { func serveAddCredit(app *app.AppState, release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - artists, err := controller.GetArtistsNotOnRelease(app.DB, release.ID) + artists, err := app.MusicService.GetArtistsNotOnRelease(release.ID) if err != nil { fmt.Printf("WARN: Failed to fetch artists not on %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -158,7 +162,7 @@ func serveNewCredit(app *app.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { split := strings.Split(r.URL.Path, "/") artistID := split[len(split) - 1] - artist, err := controller.GetArtist(app.DB, artistID) + artist, err := app.MusicService.GetArtistByID(artistID) if err != nil { fmt.Printf("WARN: Failed to fetch artist %s: %s\n", artistID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -207,7 +211,7 @@ func serveEditTracks(release *model.Release) http.Handler { func serveAddTrack(app *app.AppState, release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - tracks, err := controller.GetTracksNotOnRelease(app.DB, release.ID) + tracks, err := app.MusicService.GetTracksNotOnRelease(release.ID) if err != nil { fmt.Printf("WARN: Failed to fetch tracks not on %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -235,7 +239,7 @@ func serveNewTrack(app *app.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { split := strings.Split(r.URL.Path, "/") trackID := split[len(split) - 1] - track, err := controller.GetTrack(app.DB, trackID) + track, err := app.MusicService.GetTrackByID(trackID) if err != nil { fmt.Printf("WARN: Failed to fetch track %s: %s\n", trackID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) diff --git a/admin/trackhttp.go b/admin/trackhttp.go index 2122748..8c4554b 100644 --- a/admin/trackhttp.go +++ b/admin/trackhttp.go @@ -6,7 +6,6 @@ import ( "strings" "arimelody-web/admin/templates" - "arimelody-web/controller" "arimelody-web/model" "arimelody-web/model/app" ) @@ -23,7 +22,7 @@ func serveTracks(app *app.AppState) http.Handler { return } - tracks, err := controller.GetAllTracks(app.DB) + tracks, err := app.MusicService.GetAllTracks() if err != nil { fmt.Printf("WARN: Failed to fetch tracks: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -50,7 +49,7 @@ func serveTrack(app *app.AppState, trackID string) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { session := r.Context().Value("session").(*model.Session) - track, err := controller.GetTrack(app.DB, trackID) + track, err := app.MusicService.GetTrackByID(trackID) if err != nil { fmt.Printf("WARN: Failed to serve admin track page for %s: %s\n", trackID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -61,7 +60,7 @@ func serveTrack(app *app.AppState, trackID string) http.Handler { return } - releases, err := controller.GetTrackReleases(app.DB, track.ID, true) + releases, err := app.MusicService.GetTrackReleases(trackID) if err != nil { fmt.Printf("WARN: Failed to fetch releases for track %s: %s\n", trackID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) diff --git a/api/api.go b/api/api.go index 84b7f8a..f23fcbd 100644 --- a/api/api.go +++ b/api/api.go @@ -10,6 +10,7 @@ import ( "arimelody-web/controller" "arimelody-web/model" "arimelody-web/model/app" + "arimelody-web/errors" ) func Handler(app *app.AppState) http.Handler { @@ -21,9 +22,9 @@ func Handler(app *app.AppState) http.Handler { mux.Handle("/v1/artist/", http.StripPrefix("/v1/artist", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var artistID = strings.Split(r.URL.Path[1:], "/")[0] - artist, err := controller.GetArtist(app.DB, artistID) + artist, err := app.MusicService.GetArtistByID(artistID) if err != nil { - if strings.Contains(err.Error(), "no rows") { + if errors.IsNotExistError(err) { http.NotFound(w, r) return } @@ -63,9 +64,9 @@ func Handler(app *app.AppState) http.Handler { mux.Handle("/v1/music/", http.StripPrefix("/v1/music", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var releaseID = strings.Split(r.URL.Path[1:], "/")[0] - release, err := controller.GetRelease(app.DB, releaseID, true) + release, err := app.MusicService.GetFullReleaseByID(releaseID) if err != nil { - if strings.Contains(err.Error(), "no rows") { + if errors.IsNotExistError(err) { http.NotFound(w, r) return } @@ -105,9 +106,9 @@ func Handler(app *app.AppState) http.Handler { mux.Handle("/v1/track/", http.StripPrefix("/v1/track", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var trackID = strings.Split(r.URL.Path[1:], "/")[0] - track, err := controller.GetTrack(app.DB, trackID) + track, err := app.MusicService.GetTrackByID(trackID) if err != nil { - if strings.Contains(err.Error(), "no rows") { + if errors.IsNotExistError(err) { http.NotFound(w, r) return } @@ -187,7 +188,10 @@ func getSession(app *app.AppState, r *http.Request) (*model.Session, error) { // fetch existing session session, err := controller.GetSession(app, token) - if err != nil && !strings.Contains(err.Error(), "no rows") { + if errors.IsValidationError(err) { + return nil, err + } + if errors.IsNotExistError(err) { return nil, fmt.Errorf("Failed to retrieve session: %v\n", err) } diff --git a/api/artist.go b/api/artist.go index a43228d..61cb207 100644 --- a/api/artist.go +++ b/api/artist.go @@ -10,15 +10,15 @@ import ( "strings" "time" - "arimelody-web/controller" "arimelody-web/model" "arimelody-web/model/app" + "arimelody-web/errors" ) func ServeAllArtists(app *app.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var artists = []*model.Artist{} - artists, err := controller.GetAllArtists(app.DB) + artists, err := app.MusicService.GetAllArtists() if err != nil { fmt.Printf("WARN: Failed to serve all artists: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -53,9 +53,9 @@ func ServeArtist(app *app.AppState, artist *model.Artist) http.Handler { ) session := r.Context().Value("session").(*model.Session) - show_hidden_releases := session != nil && session.Account != nil + showHiddenReleases := session != nil && session.Account != nil - dbCredits, err := controller.GetArtistCredits(app.DB, artist.ID, show_hidden_releases) + dbCredits, err := app.MusicService.GetArtistCredits(artist.ID, showHiddenReleases) if err != nil { fmt.Printf("WARN: Failed to retrieve artist credits for %s: %v\n", artist.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -91,31 +91,34 @@ func CreateArtist(app *app.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { session := r.Context().Value("session").(*model.Session) - var artist model.Artist - err := json.NewDecoder(r.Body).Decode(&artist) + type CreateArtistDTO struct { + ID string `json:"id"` + Name string `json:"name"` + } + dto := &CreateArtistDTO{} + + err := json.NewDecoder(r.Body).Decode(dto) if err != nil { http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) return } - if artist.ID == "" { - http.Error(w, "Artist ID cannot be blank\n", http.StatusBadRequest) - return - } - if artist.Name == "" { artist.Name = artist.ID } - - err = controller.CreateArtist(app.DB, &artist) + err = app.MusicService.CreateArtist(dto.ID, dto.Name, "", "") if err != nil { - if strings.Contains(err.Error(), "duplicate key") { - http.Error(w, fmt.Sprintf("Artist %s already exists\n", artist.ID), http.StatusBadRequest) + if errors.IsValidationError(err) { + http.Error(w, err.Error(), http.StatusBadRequest) return } - fmt.Printf("WARN: Failed to create artist %s: %s\n", artist.ID, err) + if strings.Contains(err.Error(), "duplicate key") { + http.Error(w, fmt.Sprintf("Artist %s already exists\n", dto.ID), http.StatusBadRequest) + return + } + fmt.Printf("WARN: Failed to create artist %s: %s\n", dto.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } - app.LogService.Info(model.LOG_ARTIST, "Artist \"%s\" created by \"%s\".", artist.Name, session.Account.Username) + app.LogService.Info(model.LOG_ARTIST, "Artist \"%s\" created by \"%s\".", dto.Name, session.Account.Username) w.WriteHeader(http.StatusCreated) }) @@ -156,9 +159,13 @@ func UpdateArtist(app *app.AppState, artist *model.Artist) http.Handler { } } - err = controller.UpdateArtist(app.DB, artist) + err = app.MusicService.UpdateArtist(artist) if err != nil { - if strings.Contains(err.Error(), "no rows") { + if errors.IsValidationError(err) { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if errors.IsNotExistError(err) { http.NotFound(w, r) return } @@ -174,9 +181,13 @@ func DeleteArtist(app *app.AppState, artist *model.Artist) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { session := r.Context().Value("session").(*model.Session) - err := controller.DeleteArtist(app.DB, artist.ID) + err := app.MusicService.DeleteArtist(artist.ID) if err != nil { - if strings.Contains(err.Error(), "no rows") { + if errors.IsValidationError(err) { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if errors.IsNotExistError(err) { http.NotFound(w, r) return } diff --git a/api/release.go b/api/release.go index 7448a4e..e97eb16 100644 --- a/api/release.go +++ b/api/release.go @@ -13,6 +13,7 @@ import ( "arimelody-web/controller" "arimelody-web/model" "arimelody-web/model/app" + "arimelody-web/errors" ) func ServeRelease(app *app.AppState, release *model.Release) http.Handler { @@ -39,50 +40,50 @@ func ServeRelease(app *app.AppState, release *model.Release) http.Handler { } type ( - Track struct { + TrackDTO struct { Title string `json:"title"` Description string `json:"description"` Lyrics string `json:"lyrics"` } - Credit struct { + CreditDTO struct { *model.Artist Role string `json:"role"` Primary bool `json:"primary"` } - Release struct { + ReleaseDTO struct { *model.Release - Tracks []Track `json:"tracks"` - Credits []Credit `json:"credits"` + Tracks []TrackDTO `json:"tracks"` + Credits []CreditDTO `json:"credits"` Links map[string]string `json:"links"` } ) - response := Release{ + response := ReleaseDTO{ Release: release, - Tracks: []Track{}, - Credits: []Credit{}, + Tracks: []TrackDTO{}, + Credits: []CreditDTO{}, Links: make(map[string]string), } if release.IsReleased() || privileged { // get credits - credits, err := controller.GetReleaseCredits(app.DB, release.ID) + credits, err := app.MusicService.GetReleaseCredits(release.ID) if err != nil { fmt.Printf("WARN: Failed to serve release %s: Credits: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } for _, credit := range credits { - artist, err := controller.GetArtist(app.DB, credit.Artist.ID) + artist, err := app.MusicService.GetArtistByID(credit.Artist.ID) if err != nil { fmt.Printf("WARN: Failed to serve release %s: Artists: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } - response.Credits = append(response.Credits, Credit{ + response.Credits = append(response.Credits, CreditDTO{ Artist: artist, Role: credit.Role, Primary: credit.Primary, @@ -90,14 +91,14 @@ func ServeRelease(app *app.AppState, release *model.Release) http.Handler { } // get tracks - tracks, err := controller.GetReleaseTracks(app.DB, release.ID) + tracks, err := app.MusicService.GetReleaseTracks(release.ID) if err != nil { fmt.Printf("WARN: Failed to serve release %s: Tracks: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } for _, track := range tracks { - response.Tracks = append(response.Tracks, Track{ + response.Tracks = append(response.Tracks, TrackDTO{ Title: track.Title, Description: track.Description, Lyrics: track.Lyrics, @@ -105,7 +106,7 @@ func ServeRelease(app *app.AppState, release *model.Release) http.Handler { } // get links - links, err := controller.GetReleaseLinks(app.DB, release.ID) + links, err := app.MusicService.GetReleaseLinks(release.ID) if err != nil { fmt.Printf("WARN: Failed to serve release %s: Links: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -129,7 +130,7 @@ func ServeRelease(app *app.AppState, release *model.Release) http.Handler { func ServeCatalog(app *app.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - releases, err := controller.GetAllReleases(app.DB, false, 0, true) + releases, err := app.MusicService.GetAllFullReleases(false, 0) if err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return @@ -192,47 +193,56 @@ func CreateRelease(app *app.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { session := r.Context().Value("session").(*model.Session) - var release model.Release - err := json.NewDecoder(r.Body).Decode(&release) + type CreateReleaseDTO struct { + ID string `json:"id"` + Title string `json:"title"` + ReleaseType string `json:"type"` + ReleaseDate time.Time `json:"release_date"` + Artwork string `json:"artwork"` + } + var dto CreateReleaseDTO + err := json.NewDecoder(r.Body).Decode(&dto) if err != nil { http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) return } - if release.ID == "" { - http.Error(w, "Release ID cannot be empty\n", http.StatusBadRequest) - return - } - - if release.Title == "" { release.Title = release.ID } - if release.ReleaseType == "" { release.ReleaseType = model.Single } - - if release.ReleaseDate != time.Unix(0, 0) { - release.ReleaseDate = time.Date(time.Now().Year(), time.Now().Month(), time.Now().Day(), 0, 0, 0, 0, time.UTC) + if dto.ReleaseType == "" { dto.ReleaseType = string(model.Single) } + if dto.ReleaseDate != time.Unix(0, 0) { + dto.ReleaseDate = time.Date(time.Now().Year(), time.Now().Month(), time.Now().Day(), 0, 0, 0, 0, time.UTC) } + if dto.Artwork == "" { dto.Artwork = model.DEFAULT_RELEASE_ARTWORK_URL } - if release.Artwork == "" { release.Artwork = "/img/default-cover-art.png" } - - err = controller.CreateRelease(app.DB, &release) + err = app.MusicService.CreateRelease( + dto.ID, + dto.Title, + dto.ReleaseType, + dto.ReleaseDate, + dto.Artwork, + ) if err != nil { - if strings.Contains(err.Error(), "duplicate key") { - http.Error(w, fmt.Sprintf("Release %s already exists\n", release.ID), http.StatusBadRequest) + if errors.IsValidationError(err) { + http.Error(w, err.Error(), http.StatusBadRequest) return } - fmt.Printf("WARN: Failed to create release %s: %s\n", release.ID, err) + if strings.Contains(err.Error(), "duplicate key") { + http.Error(w, fmt.Sprintf("Release %s already exists\n", dto.ID), http.StatusBadRequest) + return + } + fmt.Printf("WARN: Failed to create release %s: %s\n", dto.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } - app.LogService.Info(model.LOG_MUSIC, "Release \"%s\" created by \"%s\".", release.ID, session.Account.Username) + app.LogService.Info(model.LOG_MUSIC, "Release \"%s\" created by \"%s\".", dto.ID, session.Account.Username) w.Header().Add("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) encoder := json.NewEncoder(w) encoder.SetIndent("", "\t") - err = encoder.Encode(release) + err = encoder.Encode(dto) if err != nil { - fmt.Printf("WARN: Release %s created, but failed to send JSON response: %s\n", release.ID, err) + fmt.Printf("WARN: Release %s created, but failed to send JSON response: %s\n", dto.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } }) @@ -297,9 +307,13 @@ func UpdateRelease(app *app.AppState, release *model.Release) http.Handler { } } - err = controller.UpdateRelease(app.DB, release) + err = app.MusicService.UpdateRelease(release) if err != nil { - if strings.Contains(err.Error(), "no rows") { + if errors.IsValidationError(err) { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if errors.IsNotExistError(err) { http.NotFound(w, r) return } @@ -315,28 +329,32 @@ func UpdateReleaseTracks(app *app.AppState, release *model.Release) http.Handler return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { session := r.Context().Value("session").(*model.Session) - var trackIDs = []string{} - err := json.NewDecoder(r.Body).Decode(&trackIDs) + var newTrackIDs = []string{} + err := json.NewDecoder(r.Body).Decode(&newTrackIDs) if err != nil { http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) return } - err = controller.UpdateReleaseTracks(app.DB, release.ID, trackIDs) + err = app.MusicService.UpdateReleaseTracks(release.ID, newTrackIDs) if err != nil { - if strings.Contains(err.Error(), "duplicate key") { - http.Error(w, "Release cannot have duplicate tracks", http.StatusBadRequest) + if errors.IsValidationError(err) { + http.Error(w, err.Error(), http.StatusBadRequest) return } - if strings.Contains(err.Error(), "no rows") { + if errors.IsNotExistError(err) { http.NotFound(w, r) return } + if strings.Contains(err.Error(), "duplicate key") { + http.Error(w, "Release cannot have duplicate tracks", http.StatusBadRequest) + return + } fmt.Printf("WARN: Failed to update tracks for %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } - app.LogService.Info(model.LOG_MUSIC, "Tracklist for release \"%s\" updated by \"%s\".", release.ID, session.Account.Username) + app.LogService.Info(model.LOG_MUSIC, "Release \"%s\" tracklist updated by \"%s\".", release.ID, session.Account.Username) }) } @@ -344,22 +362,22 @@ func UpdateReleaseCredits(app *app.AppState, release *model.Release) http.Handle return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { session := r.Context().Value("session").(*model.Session) - type creditJSON struct { + type CreditDTO struct { Artist string Role string Primary bool } - var data []creditJSON - err := json.NewDecoder(r.Body).Decode(&data) + var dto []CreditDTO + err := json.NewDecoder(r.Body).Decode(&dto) if err != nil { http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) return } - var credits []*model.Credit - for _, credit := range data { - credits = append(credits, &model.Credit{ - Artist: model.Artist{ + var newCredits []*model.Credit + for _, credit := range dto { + newCredits = append(newCredits, &model.Credit{ + Artist: &model.Artist{ ID: credit.Artist, }, Role: credit.Role, @@ -367,21 +385,25 @@ func UpdateReleaseCredits(app *app.AppState, release *model.Release) http.Handle }) } - err = controller.UpdateReleaseCredits(app.DB, release.ID, credits) + err = app.MusicService.UpdateReleaseCredits(release.ID, newCredits) if err != nil { - if strings.Contains(err.Error(), "duplicate key") { - http.Error(w, "Artists may only be credited once", http.StatusBadRequest) + if errors.IsValidationError(err) { + http.Error(w, err.Error(), http.StatusBadRequest) return } - if strings.Contains(err.Error(), "no rows") { + if errors.IsNotExistError(err) { http.NotFound(w, r) return } + if strings.Contains(err.Error(), "duplicate key") { + http.Error(w, "Artists may only be credited once", http.StatusBadRequest) + return + } fmt.Printf("WARN: Failed to update credits for %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } - app.LogService.Info(model.LOG_MUSIC, "Credits for release \"%s\" updated by \"%s\".", release.ID, session.Account.Username) + app.LogService.Info(model.LOG_MUSIC, "Release \"%s\" credits updated by \"%s\".", release.ID, session.Account.Username) }) } @@ -389,28 +411,41 @@ func UpdateReleaseLinks(app *app.AppState, release *model.Release) http.Handler return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { session := r.Context().Value("session").(*model.Session) - var links = []*model.Link{} - err := json.NewDecoder(r.Body).Decode(&links) + type LinkDTO struct { + Name string `json:"name"` + URL string `json:"url"` + } + var dto = []LinkDTO{} + err := json.NewDecoder(r.Body).Decode(&dto) if err != nil { http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) return } - err = controller.UpdateReleaseLinks(app.DB, release.ID, links) + links := []*model.Link{} + for _, link := range dto { + links = append(links, &model.Link{ Name: link.Name, URL: link.URL }) + } + + err = app.MusicService.UpdateReleaseLinks(release.ID, links) if err != nil { - if strings.Contains(err.Error(), "duplicate key") { - http.Error(w, "Release cannot have duplicate link names", http.StatusBadRequest) + if errors.IsValidationError(err) { + http.Error(w, err.Error(), http.StatusBadRequest) return } - if strings.Contains(err.Error(), "no rows") { + if errors.IsNotExistError(err) { http.NotFound(w, r) return } + if strings.Contains(err.Error(), "duplicate key") { + http.Error(w, "Release cannot have duplicate link names", http.StatusBadRequest) + return + } fmt.Printf("WARN: Failed to update links for %s: %s\n", release.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } - app.LogService.Info(model.LOG_MUSIC, "Links for release \"%s\" updated by \"%s\".", release.ID, session.Account.Username) + app.LogService.Info(model.LOG_MUSIC, "Release \"%s\" links updated by \"%s\".", release.ID, session.Account.Username) }) } @@ -418,9 +453,13 @@ func DeleteRelease(app *app.AppState, release *model.Release) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { session := r.Context().Value("session").(*model.Session) - err := controller.DeleteRelease(app.DB, release.ID) + err := app.MusicService.DeleteRelease(release.ID) if err != nil { - if strings.Contains(err.Error(), "no rows") { + if errors.IsValidationError(err) { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if errors.IsNotExistError(err) { http.NotFound(w, r) return } diff --git a/api/track.go b/api/track.go index c742681..749c66e 100644 --- a/api/track.go +++ b/api/track.go @@ -5,7 +5,7 @@ import ( "fmt" "net/http" - "arimelody-web/controller" + "arimelody-web/errors" "arimelody-web/model" "arimelody-web/model/app" ) @@ -26,7 +26,7 @@ func ServeAllTracks(app *app.AppState) http.Handler { var tracks = []Track{} var dbTracks = []*model.Track{} - dbTracks, err := controller.GetAllTracks(app.DB) + dbTracks, err := app.MusicService.GetAllTracks() if err != nil { fmt.Printf("WARN: Failed to pull tracks from DB: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -52,7 +52,7 @@ func ServeAllTracks(app *app.AppState) http.Handler { func ServeTrack(app *app.AppState, track *model.Track) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - dbReleases, err := controller.GetTrackReleases(app.DB, track.ID, false) + dbReleases, err := app.MusicService.GetTrackReleases(track.ID) if err != nil { fmt.Printf("WARN: Failed to pull track releases for %s from DB: %s\n", track.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) @@ -78,26 +78,30 @@ func CreateTrack(app *app.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { session := r.Context().Value("session").(*model.Session) - var track model.Track - err := json.NewDecoder(r.Body).Decode(&track) + type CreateTrackDTO struct { + Title string `json:"title"` + Description string `json:"description"` + Lyrics string `json:"lyrics"` + } + var dto CreateTrackDTO + err := json.NewDecoder(r.Body).Decode(&dto) if err != nil { http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) return } - if track.Title == "" { - http.Error(w, "Track title cannot be empty\n", http.StatusBadRequest) - return - } - - id, err := controller.CreateTrack(app.DB, &track) + id, err := app.MusicService.CreateTrack(dto.Title, dto.Description, dto.Lyrics, "") if err != nil { + if errors.IsValidationError(err) { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } fmt.Printf("WARN: Failed to create track: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } - app.LogService.Info(model.LOG_MUSIC, "Track \"%s\" (%s) created by \"%s\".", track.Title, track.ID, session.Account.Username) + app.LogService.Info(model.LOG_MUSIC, "Track \"%s\" (%s) created by \"%s\".", dto.Title, id, session.Account.Username) w.Header().Add("Content-Type", "text/plain") w.WriteHeader(http.StatusCreated) @@ -114,19 +118,28 @@ func UpdateTrack(app *app.AppState, track *model.Track) http.Handler { session := r.Context().Value("session").(*model.Session) - err := json.NewDecoder(r.Body).Decode(&track) + type UpdateTrackDTO struct { + Title string `json:"title"` + Description string `json:"description"` + Lyrics string `json:"lyrics"` + } + var dto UpdateTrackDTO + err := json.NewDecoder(r.Body).Decode(&dto) if err != nil { http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) return } - if track.Title == "" { - http.Error(w, "Track title cannot be empty\n", http.StatusBadRequest) - return - } + track.Title = dto.Title + track.Description = dto.Description + track.Lyrics = dto.Lyrics - err = controller.UpdateTrack(app.DB, track) + err = app.MusicService.UpdateTrack(track) if err != nil { + if errors.IsValidationError(err) { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } fmt.Printf("WARN: Failed to update track %s: %s\n", track.ID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return @@ -154,8 +167,12 @@ func DeleteTrack(app *app.AppState, track *model.Track) http.Handler { session := r.Context().Value("session").(*model.Session) var trackID = r.URL.Path[1:] - err := controller.DeleteTrack(app.DB, trackID) + err := app.MusicService.DeleteTrack(trackID) if err != nil { + if errors.IsNotExistError(err) { + http.NotFound(w, r) + return + } fmt.Printf("WARN: Failed to delete track %s: %s\n", trackID, err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } diff --git a/controller/release.go b/controller/release.go deleted file mode 100644 index a22d157..0000000 --- a/controller/release.go +++ /dev/null @@ -1,327 +0,0 @@ -package controller - -import ( - "fmt" - - "arimelody-web/model" - - "github.com/jmoiron/sqlx" -) - -func GetRelease(db *sqlx.DB, id string, full bool) (*model.Release, error) { - var release = model.Release{} - - err := db.Get(&release, "SELECT * FROM musicrelease WHERE id=$1", id) - if err != nil { - return nil, err - } - - if full { - // get credits - credits, err := GetReleaseCredits(db, id) - if err != nil { - return nil, fmt.Errorf("Credits: %s", err) - } - for _, credit := range credits { - release.Credits = append(release.Credits, credit) - } - - // get tracks - tracks, err := GetReleaseTracks(db, id) - if err != nil { - return nil, fmt.Errorf("Tracks: %s", err) - } - for _, track := range tracks { - release.Tracks = append(release.Tracks, track) - } - - // get links - links, err := GetReleaseLinks(db, id) - if err != nil { - return nil, fmt.Errorf("Links: %s", err) - } - for _, link := range links { - release.Links = append(release.Links, link) - } - } - - return &release, nil -} - -func GetAllReleases(db *sqlx.DB, onlyVisible bool, limit int, full bool) ([]*model.Release, error) { - var releases = []*model.Release{} - - query := "SELECT * FROM musicrelease" - if onlyVisible { - query += " WHERE visible=true" - } - query += " ORDER BY release_date DESC" - var err error - if limit > 0 { - err = db.Select(&releases, query + " LIMIT $1", limit) - } else { - err = db.Select(&releases, query) - } - if err != nil { - return nil, err - } - - for _, release := range releases { - // get credits - credits, err := GetReleaseCredits(db, release.ID) - if err != nil { - return nil, fmt.Errorf("Credits: %s", err) - } - for _, credit := range credits { - release.Credits = append(release.Credits, credit) - } - - if full { - // get tracks - tracks, err := GetReleaseTracks(db, release.ID) - if err != nil { - return nil, fmt.Errorf("Tracks: %s", err) - } - for _, track := range tracks { - release.Tracks = append(release.Tracks, track) - } - - // get links - links, err := GetReleaseLinks(db, release.ID) - if err != nil { - return nil, fmt.Errorf("Links: %s", err) - } - for _, link := range links { - release.Links = append(release.Links, link) - } - } - } - - return releases, nil -} -func GetReleaseCount(db *sqlx.DB, onlyVisible bool) (int, error) { - query := "SELECT count(*) FROM musicrelease" - if onlyVisible { - query += " WHERE visible=true" - } - - var count int - err := db.Get(&count, query) - - return count, err -} - -func CreateRelease(db *sqlx.DB, release *model.Release) error { - _, err := db.Exec( - "INSERT INTO musicrelease "+ - "(id, visible, title, description, type, release_date, artwork, buyname, buylink, copyright, copyrighturl) "+ - "VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)", - release.ID, - release.Visible, - release.Title, - release.Description, - release.ReleaseType, - release.ReleaseDate.Format("2006-01-02 15:04:05"), - release.Artwork, - release.Buyname, - release.Buylink, - release.Copyright, - release.CopyrightURL, - ) - if err != nil { - return err - } - - return nil -} - -func UpdateRelease(db *sqlx.DB, release *model.Release) error { - _, err := db.Exec( - "UPDATE musicrelease SET "+ - "visible=$2, title=$3, description=$4, type=$5, release_date=$6, artwork=$7, buyname=$8, buylink=$9, copyright=$10, copyrighturl=$11 "+ - "WHERE id=$1", - release.ID, - release.Visible, - release.Title, - release.Description, - release.ReleaseType, - release.ReleaseDate.Format("2006-01-02 15:04:05"), - release.Artwork, - release.Buyname, - release.Buylink, - release.Copyright, - release.CopyrightURL, - ) - if err != nil { - return err - } - - return nil -} - -func UpdateReleaseTracks(db *sqlx.DB, releaseID string, new_tracks []string) error { - tx, err := db.Begin() - if err != nil { - return err - } - - _, err = tx.Exec("DELETE FROM musicreleasetrack WHERE release=$1", releaseID) - if err != nil { - return err - } - for i, trackID := range new_tracks { - _, err = tx.Exec( - "INSERT INTO musicreleasetrack "+ - "(release, track, number) "+ - "VALUES ($1, $2, $3)", - releaseID, - trackID, - i) - if err != nil { - return err - } - } - - err = tx.Commit() - if err != nil { - return err - } - - return nil -} - -func UpdateReleaseCredits(db *sqlx.DB, releaseID string, new_credits []*model.Credit) error { - tx, err := db.Begin() - if err != nil { - return err - } - - _, err = tx.Exec("DELETE FROM musiccredit WHERE release=$1", releaseID) - if err != nil { - return err - } - for _, credit := range new_credits { - _, err = tx.Exec( - "INSERT INTO musiccredit "+ - "(release, artist, role, is_primary) "+ - "VALUES ($1, $2, $3, $4)", - releaseID, - credit.Artist.ID, - credit.Role, - credit.Primary, - ) - if err != nil { - return err - } - } - - err = tx.Commit() - if err != nil { - return err - } - - return nil -} - -func UpdateReleaseLinks(db *sqlx.DB, releaseID string, new_links []*model.Link) error { - tx, err := db.Begin() - if err != nil { - return err - } - - _, err = tx.Exec("DELETE FROM musiclink WHERE release=$1", releaseID) - if err != nil { - return err - } - for _, link := range new_links { - _, err := tx.Exec( - "INSERT INTO musiclink "+ - "(release, name, url) "+ - "VALUES ($1, $2, $3)", - releaseID, - link.Name, - link.URL, - ) - if err != nil { - return err - } - } - - err = tx.Commit() - if err != nil { - return err - } - - return nil -} - -func DeleteRelease(db *sqlx.DB, releaseID string) error { - _, err := db.Exec( - "DELETE FROM musicrelease "+ - "WHERE id=$1", - releaseID, - ) - if err != nil { - return err - } - - return nil -} - -func GetReleaseTracks(db *sqlx.DB, releaseID string) ([]*model.Track, error) { - var tracks = []*model.Track{} - - err := db.Select(&tracks, - "SELECT musictrack.* FROM musictrack "+ - "JOIN musicreleasetrack ON track=id "+ - "WHERE release=$1 "+ - "ORDER BY number ASC", - releaseID, - ) - if err != nil { - return nil, err - } - - return tracks, nil -} - -func GetReleaseCredits(db *sqlx.DB, releaseID string) ([]*model.Credit, error) { - rows, err := db.Query( - "SELECT artist.id,artist.name,artist.website,artist.avatar,role,is_primary "+ - "FROM musiccredit "+ - "JOIN artist ON artist=artist.id "+ - "JOIN musicrelease ON release=musicrelease.id "+ - "WHERE musicrelease.id=$1 "+ - "ORDER BY is_primary DESC", - releaseID, - ) - if err != nil { - return nil, err - } - - var credits []*model.Credit - for rows.Next() { - credit := model.Credit{} - rows.Scan( - &credit.Artist.ID, - &credit.Artist.Name, - &credit.Artist.Website, - &credit.Artist.Avatar, - &credit.Role, - &credit.Primary) - credits = append(credits, &credit) - } - - return credits, nil -} - -func GetReleaseLinks(db *sqlx.DB, releaseID string) ([]*model.Link, error) { - var links = []*model.Link{} - - err := db.Select(&links, "SELECT name,url FROM musiclink WHERE release=$1", releaseID) - if err != nil { - return nil, err - } - - return links, nil -} diff --git a/controller/track.go b/controller/track.go deleted file mode 100644 index 27f4afc..0000000 --- a/controller/track.go +++ /dev/null @@ -1,181 +0,0 @@ -package controller - -import ( - "arimelody-web/model" - - "github.com/jmoiron/sqlx" -) - -// DATABASE - -func GetTrack(db *sqlx.DB, id string) (*model.Track, error) { - var track = model.Track{} - - stmt, _ := db.Preparex("SELECT * FROM musictrack WHERE id=$1") - err := stmt.Get(&track, id) - if err != nil { - return nil, err - } - return &track, nil -} - -func GetAllTracks(db *sqlx.DB) ([]*model.Track, error) { - var tracks = []*model.Track{} - - err := db.Select(&tracks, "SELECT * FROM musictrack") - if err != nil { - return nil, err - } - - return tracks, nil -} -func GetTrackCount(db *sqlx.DB) (int, error) { - var count int - err := db.Get(&count, "SELECT count(*) FROM musictrack") - return count, err -} - -func GetOrphanTracks(db *sqlx.DB) ([]*model.Track, error) { - var tracks = []*model.Track{} - - err := db.Select(&tracks, "SELECT * FROM musictrack WHERE id NOT IN (SELECT track FROM musicreleasetrack)") - if err != nil { - return nil, err - } - - return tracks, nil -} - -func GetTracksNotOnRelease(db *sqlx.DB, releaseID string) ([]*model.Track, error) { - var tracks = []*model.Track{} - - err := db.Select(&tracks, - "SELECT * FROM musictrack "+ - "WHERE id NOT IN "+ - "(SELECT track FROM musicreleasetrack WHERE release=$1)", - releaseID) - if err != nil { - return nil, err - } - - return tracks, nil -} - -func GetTrackReleases(db *sqlx.DB, trackID string, full bool) ([]*model.Release, error) { - var releases = []*model.Release{} - - err := db.Select(&releases, - "SELECT id,title,type,release_date,artwork,buylink "+ - "FROM musicrelease "+ - "JOIN musicreleasetrack ON release=id "+ - "WHERE track=$1 "+ - "ORDER BY release_date", - trackID, - ) - if err != nil { - return nil, err - } - - type NamePrimary struct { - Name string `json:"name"` - Primary bool `json:"primary" db:"is_primary"` - } - for _, release := range releases { - // get artists - credits := []NamePrimary{} - err := db.Select(&credits, - "SELECT name,is_primary FROM artist "+ - "JOIN musiccredit ON artist=artist.id "+ - "JOIN musicrelease ON release=musicrelease.id "+ - "WHERE musicrelease.id=$1", release.ID) - if err != nil { - return nil, err - } - for _, credit := range credits { - release.Credits = append(release.Credits, &model.Credit{ - Artist: model.Artist{ - Name: credit.Name, - }, - Primary: credit.Primary, - }) - } - - // get tracks - tracks := []string{} - err = db.Select(&tracks, "SELECT track FROM musicreleasetrack WHERE release=$1", release.ID) - if err != nil { - return nil, err - } - for _, trackID := range tracks { - release.Tracks = append(release.Tracks, &model.Track{ - ID: trackID, - }) - } - } - - return releases, nil -} - -func PullOrphanTracks(db *sqlx.DB) ([]*model.Track, error) { - var tracks = []*model.Track{} - - err := db.Select(&tracks, - "SELECT id, title, description, lyrics, preview_url FROM musictrack "+ - "WHERE id NOT IN "+ - "(SELECT track FROM musicreleasetrack)", - ) - if err != nil { - return nil, err - } - - return tracks, nil -} - -func CreateTrack(db *sqlx.DB, track *model.Track) (string, error) { - var trackID string - err := db.QueryRow( - "INSERT INTO musictrack (title, description, lyrics, preview_url) "+ - "VALUES ($1, $2, $3, $4) "+ - "RETURNING id", - track.Title, - track.Description, - track.Lyrics, - track.PreviewURL, - ).Scan(&trackID) - if err != nil { - return "", err - } - - return trackID, nil -} - -func UpdateTrack(db *sqlx.DB, track *model.Track) error { - _, err := db.Exec( - "UPDATE musictrack "+ - "SET title=$2, description=$3, lyrics=$4, preview_url=$5 "+ - "WHERE id=$1", - track.ID, - track.Title, - track.Description, - track.Lyrics, - track.PreviewURL, - ) - if err != nil { - return err - } - - return nil -} - -func DeleteTrack(db *sqlx.DB, trackID string) error { - _, err := db.Exec( - "DELETE FROM musictrack "+ - "WHERE id=$1", - trackID, - ) - if err != nil { - return err - } - - return nil -} diff --git a/errors/notexist.go b/errors/notexist.go new file mode 100644 index 0000000..01b0fc2 --- /dev/null +++ b/errors/notexist.go @@ -0,0 +1,16 @@ +package errors + +type NotExistError struct { + query string +} +func NewNotExistError(query string) *NotExistError { + return &NotExistError{ query: query } +} +func (err *NotExistError) Error() string { + return err.query +} +func IsNotExistError(err error) bool { + _, ok := err.(*NotExistError) + return ok +} + diff --git a/errors/notexist_test.go b/errors/notexist_test.go new file mode 100644 index 0000000..fe61771 --- /dev/null +++ b/errors/notexist_test.go @@ -0,0 +1,26 @@ +package errors_test + +import ( + "arimelody-web/errors" + goErrors "errors" + "testing" + + "gotest.tools/v3/assert" +) + +func Test_NotExistError(t *testing.T) { + var err error + message := "entity does not exist" + + t.Run("can create error", func(t *testing.T) { + err = errors.NewNotExistError(message) + assert.Error(t, err, message) + }) + + t.Run("validator returns true for valid error", func(t *testing.T) { + assert.Equal(t, errors.IsNotExistError(err), true) + }) + t.Run("validator returns false for invalid error", func(t *testing.T) { + assert.Equal(t, errors.IsNotExistError(goErrors.New("other error")), false) + }) +} diff --git a/errors/validation.go b/errors/validation.go new file mode 100644 index 0000000..7309694 --- /dev/null +++ b/errors/validation.go @@ -0,0 +1,15 @@ +package errors + +type ValidationError struct { + message string +} +func NewValidationError(message string) *ValidationError { + return &ValidationError{ message: message } +} +func (err *ValidationError) Error() string { + return err.message +} +func IsValidationError(err error) bool { + _, ok := err.(*ValidationError) + return ok +} diff --git a/errors/validation_test.go b/errors/validation_test.go new file mode 100644 index 0000000..8a6783c --- /dev/null +++ b/errors/validation_test.go @@ -0,0 +1,26 @@ +package errors_test + +import ( + "arimelody-web/errors" + goErrors "errors" + "testing" + + "gotest.tools/v3/assert" +) + +func Test_ValidationError(t *testing.T) { + var err error + message := "invalid input" + + t.Run("can create error", func(t *testing.T) { + err = errors.NewValidationError(message) + assert.Error(t, err, message) + }) + + t.Run("validator returns true for valid error", func(t *testing.T) { + assert.Equal(t, errors.IsValidationError(err), true) + }) + t.Run("validator returns false for invalid error", func(t *testing.T) { + assert.Equal(t, errors.IsValidationError(goErrors.New("other error")), false) + }) +} diff --git a/main.go b/main.go index f0d7f95..7086265 100644 --- a/main.go +++ b/main.go @@ -24,12 +24,16 @@ import ( "arimelody-web/cursor" "arimelody-web/model" "arimelody-web/model/app" + "arimelody-web/view" + accountRepo "arimelody-web/repository/account" logRepo "arimelody-web/repository/log" + musicRepo "arimelody-web/repository/music" + repo "arimelody-web/repository/postgres" accountService "arimelody-web/service/account" logService "arimelody-web/service/log" - "arimelody-web/view" + musicService "arimelody-web/service/music" "github.com/jmoiron/sqlx" _ "github.com/lib/pq" @@ -47,6 +51,7 @@ var publicFS embed.FS func main() { // TODO: switch to a new logger. this one kinda sucks + // i'll be so forreal i might write my own logger := log.New(os.Stderr, "main", model.DEFAULT_LOG_FLAGS) logger.Print("made with <3 by ari melody\n\n") @@ -103,6 +108,12 @@ func main() { log.New(os.Stderr, "account-repo", model.DEFAULT_LOG_FLAGS), ) + musicRepo := musicRepo.NewMusicRepositoryPostgres(psqlDB) + app.MusicService = musicService.NewMusicService( + musicRepo, + log.New(os.Stderr, "music-repo", model.DEFAULT_LOG_FLAGS), + ) + // handle command arguments if len(os.Args) > 1 { arg := os.Args[1] diff --git a/model/account.go b/model/account.go index 67424b7..80e0a92 100644 --- a/model/account.go +++ b/model/account.go @@ -16,8 +16,8 @@ type ( Email sql.NullString `json:"email" db:"email"` AvatarURL sql.NullString `json:"avatar_url" db:"avatar_url"` CreatedAt time.Time `json:"created_at" db:"created_at"` - FailAttempts int `json:"fail_attempts" db:"fail_attempts"` - Locked bool `json:"locked" db:"locked"` + FailAttempts int `json:"fail_attempts" db:"fail_attempts"` + Locked bool `json:"locked" db:"locked"` Privileges []AccountPrivilege `json:"privileges"` } diff --git a/model/app/appstate.go b/model/app/appstate.go index 2e0c334..80a84d4 100644 --- a/model/app/appstate.go +++ b/model/app/appstate.go @@ -8,12 +8,10 @@ import ( "arimelody-web/model/twitch" logService "arimelody-web/service/log" - inviteService "arimelody-web/service/invite" + //inviteService "arimelody-web/service/invite" accountService "arimelody-web/service/account" - sessionService "arimelody-web/service/session" - artistService "arimelody-web/service/artist" - releaseService "arimelody-web/service/release" - trackService "arimelody-web/service/track" + //sessionService "arimelody-web/service/session" + musicService "arimelody-web/service/music" ) type ( @@ -55,11 +53,9 @@ type ( PublicFS embed.FS LogService *logService.LogService - InviteService *inviteService.InviteService + //InviteService *inviteService.InviteService AccountService *accountService.AccountService - SesisonService *sessionService.SessionService - ArtistService *artistService.ArtistService - ReleaseService *releaseService.ReleaseService - TrackService *trackService.TrackService + //SesisonService *sessionService.SessionService + MusicService *musicService.MusicService } ) diff --git a/model/artist.go b/model/artist.go index 746a7dd..b9f940b 100644 --- a/model/artist.go +++ b/model/artist.go @@ -9,9 +9,11 @@ type ( } ) +const DEFAULT_AVATAR_URL = "/img/default-avatar.png" + func (artist Artist) GetAvatar() string { if artist.Avatar == "" { - return "/img/default-avatar.png" + return DEFAULT_AVATAR_URL } return artist.Avatar } diff --git a/model/artist_test.go b/model/artist_test.go index feb9a18..8e39479 100644 --- a/model/artist_test.go +++ b/model/artist_test.go @@ -1,21 +1,22 @@ -package model +package model_test import ( - "testing" + "arimelody-web/model" + "testing" ) func Test_Artist_GetAvatar(t *testing.T) { want := "testavatar.png" - artist := Artist{ Avatar: want } + artist := model.Artist{ Avatar: want } got := artist.GetAvatar() if want != got { t.Errorf(`correct value not returned when avatar is populated (want "%s", got "%s")`, want, got) } - artist = Artist{} + artist = model.Artist{} - want = "/img/default-avatar.png" + want = model.DEFAULT_AVATAR_URL got = artist.GetAvatar() if want != got { t.Errorf(`default value not returned when avatar is empty (want "%s", got "%s")`, want, got) diff --git a/model/credit.go b/model/credit.go index 2080d83..f5f8feb 100644 --- a/model/credit.go +++ b/model/credit.go @@ -2,9 +2,9 @@ package model type ( Credit struct { - Release Release `json:"release"` - Artist Artist `json:"artist"` - Role string `json:"role"` - Primary bool `json:"primary" db:"is_primary"` + Release *Release `json:"release"` + Artist *Artist `json:"artist"` + Role string `json:"role"` + Primary bool `json:"primary" db:"is_primary"` } ) diff --git a/model/link_test.go b/model/link_test.go index b368094..2458dac 100644 --- a/model/link_test.go +++ b/model/link_test.go @@ -1,11 +1,12 @@ -package model +package model_test import ( - "testing" + "arimelody-web/model" + "testing" ) func Test_Link_NormaliseName(t *testing.T) { - link := Link{ + link := model.Link{ Name: "!c@o#o$l%-^a&w*e(s)o_m=e+-[l{i]n}k-0123456789ABCDEF", } diff --git a/model/release.go b/model/release.go index 46f4460..3f00d4d 100644 --- a/model/release.go +++ b/model/release.go @@ -34,6 +34,8 @@ const ( EP ReleaseType = "EP" Compilation ReleaseType = "compilation" Upcoming ReleaseType = "upcoming" + + DEFAULT_RELEASE_ARTWORK_URL = "/img/default-cover-art.png" ) // GETTERS @@ -52,7 +54,7 @@ func (release Release) PrintReleaseDate() string { func (release Release) GetArtwork() string { if release.Artwork == "" { - return "/img/default-cover-art.png" + return DEFAULT_RELEASE_ARTWORK_URL } return release.Artwork } @@ -93,3 +95,20 @@ func (release Release) PrintArtists(only_primary bool, ampersand bool) string { return strings.Join(names[:], ", ") } } + +func ValidReleaseType(releaseType string) (ReleaseType, bool) { + switch releaseType { + case "single": + return Single, true + case "album": + return Album, true + case "EP": + return EP, true + case "compilation": + return Compilation, true + case "upcoming": + return Upcoming, true + default: + return "", false + } +} diff --git a/model/release_test.go b/model/release_test.go index fc0e221..37ef775 100644 --- a/model/release_test.go +++ b/model/release_test.go @@ -1,6 +1,7 @@ -package model +package model_test import ( + "arimelody-web/model" "strings" "testing" "time" @@ -10,7 +11,7 @@ import ( func Test_Release(t *testing.T) { t.Run("prints correct description HTML", func(t *testing.T) { - release := Release{ + release := model.Release{ Description: "this is\na test\ndescription!", } @@ -24,7 +25,7 @@ func Test_Release(t *testing.T) { }) t.Run("prints correct release date", func(t *testing.T) { - release := Release{ + release := model.Release{ ReleaseDate: time.Date(2025, time.July, 26, 16, 0, 0, 0, time.UTC), } @@ -34,37 +35,37 @@ func Test_Release(t *testing.T) { t.Run("returns correct artwork", func(t *testing.T) { artwork := "testartwork.png" - release := Release{ Artwork: artwork } + release := model.Release{ Artwork: artwork } assert.Equal(t, release.GetArtwork(), artwork) }) t.Run("returns placeholder artwork when empty", func(t *testing.T) { - release := Release{} - assert.Equal(t, release.GetArtwork(), "/img/default-cover-art.png") + release := model.Release{} + assert.Equal(t, release.GetArtwork(), model.DEFAULT_RELEASE_ARTWORK_URL) }) t.Run("singles", func(t *testing.T) { - release := Release{ - Tracks: []*Track{}, + release := model.Release{ + Tracks: []*model.Track{}, } t.Run("false when no tracks are present", func(t *testing.T) { assert.Equal(t, release.IsSingle(), false) }) - release.Tracks = append(release.Tracks, &Track{}) + release.Tracks = append(release.Tracks, &model.Track{}) t.Run("true when one track is present", func(t *testing.T) { assert.Equal(t, release.IsSingle(), true) }) - release.Tracks = append(release.Tracks, &Track{}) + release.Tracks = append(release.Tracks, &model.Track{}) t.Run("false when >1 tracks are present", func(t *testing.T) { assert.Equal(t, release.IsSingle(), false) }) }) t.Run("released", func(t *testing.T) { - release := Release { + release := model.Release { ReleaseDate: time.Now(), } @@ -84,23 +85,23 @@ func Test_Release(t *testing.T) { artist3 := "idk" artist4 := "guest" - release := Release{} + release := model.Release{} t.Run("prints \"Unknown Artist\" when release has no credits", func(t *testing.T) { assert.Equal(t, release.PrintArtists(false, true), "Unknown Artist") }) release.Credits = append( release.Credits, - &Credit{ Artist: Artist{ Name: artist1 }, Primary: true }, + &model.Credit{ Artist: &model.Artist{ Name: artist1 }, Primary: true }, ) t.Run("prints ONLY first artist name when release has one credit", func(t *testing.T) { assert.Equal(t, release.PrintArtists(false, true), artist1) }) - release.Credits = append(release.Credits, []*Credit{ - { Artist: Artist{ Name: artist2 }, Primary: true }, - { Artist: Artist{ Name: artist3 }, Primary: false }, - { Artist: Artist{ Name: artist4 }, Primary: true }, + release.Credits = append(release.Credits, []*model.Credit{ + { Artist: &model.Artist{ Name: artist2 }, Primary: true }, + { Artist: &model.Artist{ Name: artist3 }, Primary: false }, + { Artist: &model.Artist{ Name: artist4 }, Primary: true }, }...) t.Run("can get only unique primary artist names", func(t *testing.T) { assert.Equal( @@ -146,4 +147,37 @@ func Test_Release(t *testing.T) { ) }) }) + + t.Run("validating release types", func(t *testing.T) { + t.Run("single", func(t *testing.T) { + releaseType, ok := model.ValidReleaseType("single") + assert.Equal(t, ok, true) + assert.Equal(t, releaseType, model.Single) + }) + t.Run("album", func(t *testing.T) { + releaseType, ok := model.ValidReleaseType("album") + assert.Equal(t, ok, true) + assert.Equal(t, releaseType, model.Album) + }) + t.Run("EP", func(t *testing.T) { + releaseType, ok := model.ValidReleaseType("EP") + assert.Equal(t, ok, true) + assert.Equal(t, releaseType, model.EP) + }) + t.Run("compilation", func(t *testing.T) { + releaseType, ok := model.ValidReleaseType("compilation") + assert.Equal(t, ok, true) + assert.Equal(t, releaseType, model.Compilation) + }) + t.Run("upcoming", func(t *testing.T) { + releaseType, ok := model.ValidReleaseType("upcoming") + assert.Equal(t, ok, true) + assert.Equal(t, releaseType, model.Upcoming) + }) + t.Run("invalid", func(t *testing.T) { + releaseType, ok := model.ValidReleaseType("invalid") + assert.Equal(t, ok, false) + assert.Equal(t, string(releaseType), "") + }) + }) } diff --git a/model/track_test.go b/model/track_test.go index eb64b10..091deff 100644 --- a/model/track_test.go +++ b/model/track_test.go @@ -1,11 +1,12 @@ -package model +package model_test import ( - "testing" + "arimelody-web/model" + "testing" ) func Test_Track_DescriptionHTML(t *testing.T) { - track := Track{ + track := model.Track{ Description: "this is\na test\ndescription!", } @@ -19,7 +20,7 @@ func Test_Track_DescriptionHTML(t *testing.T) { } func Test_Track_LyricsHTML(t *testing.T) { - track := Track{ + track := model.Track{ Lyrics: "these are\ntest\nlyrics!", } diff --git a/repository/account/interface.go b/repository/account/interface.go index fa31064..ba90073 100644 --- a/repository/account/interface.go +++ b/repository/account/interface.go @@ -27,11 +27,11 @@ type AccountRepository interface { UpdateAvatarURL(id string, avatarURL string) error RemoveAvatar(id string) error - Delete(id string) error - // Increment the number of account login failure attempts, // returning the current fail count. IncrementFails(id string) (int, error) ResetFails(id string) error SetLocked(id string, lock bool) error + + Delete(id string) (string, error) } diff --git a/repository/account/memory.go b/repository/account/memory.go index 707da81..7f886d1 100644 --- a/repository/account/memory.go +++ b/repository/account/memory.go @@ -1,9 +1,10 @@ package account import ( + "arimelody-web/errors" "arimelody-web/model" "database/sql" - "errors" + "slices" "strconv" ) @@ -24,10 +25,11 @@ func (repo *AccountRepositoryMemory) GetCount() (int, error) { return len(repo.accounts), nil } func (repo *AccountRepositoryMemory) GetByID(id string) (*model.Account, error) { - for _, account := range repo.accounts { - if account.ID == id { return account, nil } - } - return nil, nil + index := slices.IndexFunc(repo.accounts, func(account *model.Account) bool { + return account.ID == id + }) + if index == -1 { return nil, nil } + return repo.accounts[index], nil } func (repo *AccountRepositoryMemory) GetByUsername(username string) (*model.Account, error) { for _, account := range repo.accounts { @@ -47,9 +49,9 @@ func (repo *AccountRepositoryMemory) GetByEmail(email string) (*model.Account, e // Create an account, returning the new account ID. func (repo *AccountRepositoryMemory) Create(username string, password string, email *string, avatarURL *string) (string, error) { if account, err := repo.GetByUsername(username); err != nil { - return "", errors.New("Failed to fetch other acccounts by username") + return "", errors.NewNotExistError("Failed to fetch other acccounts by username") } else if account != nil { - return "", errors.New("Account with this username already exists") + return "", errors.NewNotExistError("Account with this username already exists") } emailRef := "" @@ -74,9 +76,9 @@ func (repo *AccountRepositoryMemory) Create(username string, password string, em // more specialised Update* and Remove* functions should be used. func (repo *AccountRepositoryMemory) Update(id string, username string, password string, email *string, avatarUrl *string) error { if account, err := repo.GetByUsername(username); err != nil { - return errors.New("Failed to fetch other acccounts by username") + return errors.NewNotExistError("Failed to fetch other acccounts by username") } else if account != nil && account.ID != id { - return errors.New("Account with this username already exists") + return errors.NewNotExistError("Account with this username already exists") } account, err := repo.GetByID(id) @@ -93,14 +95,14 @@ func (repo *AccountRepositoryMemory) Update(id string, username string, password } func (repo *AccountRepositoryMemory) UpdateUsername(id string, username string) error { if account, err := repo.GetByUsername(username); err != nil { - return errors.New("Failed to fetch other acccounts by username") + return errors.NewNotExistError("Failed to fetch other acccounts by username") } else if account != nil && account.ID != id { - return errors.New("Account with this username already exists") + return errors.NewNotExistError("Account with this username already exists") } account, err := repo.GetByID(id) if err != nil { return err } - if account == nil { return errors.New("Account does not exist") } + if account == nil { return errors.NewNotExistError("Account does not exist") } account.Username = username return nil @@ -108,7 +110,7 @@ func (repo *AccountRepositoryMemory) UpdateUsername(id string, username string) func (repo *AccountRepositoryMemory) UpdatePassword(id string, password string) error { account, err := repo.GetByID(id) if err != nil { return err } - if account == nil { return errors.New("Account does not exist") } + if account == nil { return errors.NewNotExistError("Account does not exist") } account.Password = password return nil @@ -116,7 +118,7 @@ func (repo *AccountRepositoryMemory) UpdatePassword(id string, password string) func (repo *AccountRepositoryMemory) UpdateEmail(id string, email string) error { account, err := repo.GetByID(id) if err != nil { return err } - if account == nil { return errors.New("Account does not exist") } + if account == nil { return errors.NewNotExistError("Account does not exist") } account.Email.Valid = true account.Email.String = email @@ -125,7 +127,7 @@ func (repo *AccountRepositoryMemory) UpdateEmail(id string, email string) error func (repo *AccountRepositoryMemory) RemoveEmail(id string) error { account, err := repo.GetByID(id) if err != nil { return err } - if account == nil { return errors.New("Account does not exist") } + if account == nil { return errors.NewNotExistError("Account does not exist") } account.Email.Valid = false account.Email.String = "" @@ -134,7 +136,7 @@ func (repo *AccountRepositoryMemory) RemoveEmail(id string) error { func (repo *AccountRepositoryMemory) UpdateAvatarURL(id string, avatarURL string) error { account, err := repo.GetByID(id) if err != nil { return err } - if account == nil { return errors.New("Account does not exist") } + if account == nil { return errors.NewNotExistError("Account does not exist") } account.AvatarURL.Valid = true account.AvatarURL.String = avatarURL @@ -143,31 +145,13 @@ func (repo *AccountRepositoryMemory) UpdateAvatarURL(id string, avatarURL string func (repo *AccountRepositoryMemory) RemoveAvatar(id string) error { account, err := repo.GetByID(id) if err != nil { return err } - if account == nil { return errors.New("Account does not exist") } + if account == nil { return errors.NewNotExistError("Account does not exist") } account.AvatarURL.Valid = false account.AvatarURL.String = "" return nil } -func (repo *AccountRepositoryMemory) Delete(id string) error { - accountIndex := -1 - for index, account := range repo.accounts { - if account.ID == id { - accountIndex = index - break - } - } - if accountIndex == -1 { return nil } - - repo.accounts = append( - repo.accounts[:accountIndex], - repo.accounts[accountIndex+1:]..., - ) - - return nil -} - // Increment the number of account login failure attempts, // returning the current fail count. func (repo *AccountRepositoryMemory) IncrementFails(id string) (int, error) { @@ -188,3 +172,17 @@ func (repo *AccountRepositoryMemory) SetLocked(id string, locked bool) error { account.Locked = locked return nil } + +func (repo *AccountRepositoryMemory) Delete(id string) (string, error) { + var deletedID string + newAccounts := []*model.Account{} + for _, account := range repo.accounts { + if account.ID == id { + deletedID = id + continue + } + newAccounts = append(newAccounts, account) + } + repo.accounts = newAccounts + return deletedID, nil +} diff --git a/repository/account/postgres.go b/repository/account/postgres.go index cad3c61..03d797f 100644 --- a/repository/account/postgres.go +++ b/repository/account/postgres.go @@ -157,11 +157,6 @@ func (repo *AccountRepositoryPostgres) RemoveAvatar(id string) error { return err } -func (repo *AccountRepositoryPostgres) Delete(id string) error { - _, err := repo.db.Exec("DELETE FROM account WHERE id=$1", id) - return err -} - // Increment the number of account login failure attempts, // returning the current fail count. func (repo *AccountRepositoryPostgres) IncrementFails(id string) (int, error) { @@ -179,3 +174,9 @@ func (repo *AccountRepositoryPostgres) SetLocked(id string, locked bool) error { _, err := repo.db.Exec("UPDATE account SET locked = $2 WHERE id=$1", id, locked) return err } + +func (repo *AccountRepositoryPostgres) Delete(id string) (string, error) { + var deletedID string + err := repo.db.Get(&deletedID, "DELETE FROM account WHERE id=$1", id) + return deletedID, err +} diff --git a/repository/music/artist_memory.go b/repository/music/artist_memory.go new file mode 100644 index 0000000..ff61eee --- /dev/null +++ b/repository/music/artist_memory.go @@ -0,0 +1,135 @@ +package music + +import ( + "arimelody-web/errors" + "arimelody-web/model" + "slices" +) + +func (repo *MusicRepositoryMemory) GetAllArtists() ([]*model.Artist, error) { + return repo.artists, nil +} +func (repo *MusicRepositoryMemory) GetArtistCount() (int, error) { + return len(repo.artists), nil +} + +func (repo *MusicRepositoryMemory) GetArtistByID(id string) (*model.Artist, error) { + index := slices.IndexFunc(repo.artists, func(artist *model.Artist) bool { + return artist.ID == id + }) + if index == -1 { return nil, nil } + return repo.artists[index], nil +} + +func (repo *MusicRepositoryMemory) GetArtistsNotOnRelease(releaseID string) ([]*model.Artist, error) { + release, err := repo.GetReleaseByID(releaseID) + if err != nil { return nil, err } + artists, err := repo.GetAllArtists() + if err != nil { return nil, err } + + artistsNotOnRelease := []*model.Artist{} + for _, artist := range artists { + if !slices.ContainsFunc(release.Credits, func(credit *model.Credit) bool { + return credit.Artist.ID == artist.ID + }) { + artistsNotOnRelease = append(artistsNotOnRelease, artist) + } + } + + return artistsNotOnRelease, nil +} + +func (repo *MusicRepositoryMemory) GetArtistCredits(artistID string, showHidden bool) ([]*model.Credit, error) { + releases, err := repo.GetAllReleases(!showHidden, 0) + if err != nil { return nil, err } + + credits := []*model.Credit{} + for _, release := range releases { + credits = append(credits, slices.DeleteFunc( + release.Credits, + func(credit *model.Credit) bool { + return credit.Artist.ID != artistID + }, + )...) + } + + return credits, nil +} + +func (repo *MusicRepositoryMemory) CreateArtist( + id string, + name string, + website string, + avatar string, +) error { + if artist, err := repo.GetArtistByID(id); err != nil { + if !errors.IsNotExistError(err) { return err } + } else { + if artist != nil { return errors.NewValidationError("Artist with this ID already exists") } + } + + repo.artists = append(repo.artists, &model.Artist{ + ID: id, + Name: name, + Website: website, + Avatar: avatar, + }) + return nil +} + +func (repo *MusicRepositoryMemory) UpdateArtist(artist *model.Artist) error { + repoArtist, err := repo.GetArtistByID(artist.ID) + if err != nil { return err } + if repoArtist == nil { return errors.NewNotExistError("Artist does not exist") } + + repoArtist.Name = artist.Name + repoArtist.Website = artist.Website + repoArtist.Avatar = artist.Avatar + return nil +} +func (repo *MusicRepositoryMemory) UpdateArtistID(oldID string, newID string) error { + artist, err := repo.GetArtistByID(oldID) + if err != nil { return err } + if artist == nil { return errors.NewNotExistError("Artist does not exist") } + + artist.ID = newID + return nil +} +func (repo *MusicRepositoryMemory) UpdateArtistName(id string, name string) error { + artist, err := repo.GetArtistByID(id) + if err != nil { return err } + if artist == nil { return errors.NewNotExistError("Artist does not exist") } + + artist.Name = name + return nil +} +func (repo *MusicRepositoryMemory) UpdateArtistWebsite(id string, website string) error { + artist, err := repo.GetArtistByID(id) + if err != nil { return err } + if artist == nil { return errors.NewNotExistError("Artist does not exist") } + + artist.Website = website + return nil +} +func (repo *MusicRepositoryMemory) UpdateArtistAvatar(id string, avatar string) error { + artist, err := repo.GetArtistByID(id) + if err != nil { return err } + if artist == nil { return errors.NewNotExistError("Artist does not exist") } + + artist.Avatar = avatar + return nil +} + +func (repo *MusicRepositoryMemory) DeleteArtist(id string) (string, error) { + var deletedID string + newArtists := []*model.Artist{} + for _, artist := range repo.artists { + if artist.ID == id { + deletedID = id + continue + } + newArtists = append(newArtists, artist) + } + repo.artists = newArtists + return deletedID, nil +} diff --git a/controller/artist.go b/repository/music/artist_postgres.go similarity index 50% rename from controller/artist.go rename to repository/music/artist_postgres.go index adcdbc5..aa86da9 100644 --- a/controller/artist.go +++ b/repository/music/artist_postgres.go @@ -1,17 +1,29 @@ -package controller +package music import ( "arimelody-web/model" - - "github.com/jmoiron/sqlx" ) -// DATABASE +func (repo *MusicRepositoryPostgres) GetAllArtists() ([]*model.Artist, error) { + var artists = []*model.Artist{} -func GetArtist(db *sqlx.DB, id string) (*model.Artist, error) { + err := repo.db.Select(&artists, "SELECT * FROM artist") + if err != nil { + return nil, err + } + + return artists, nil +} +func (repo *MusicRepositoryPostgres) GetArtistCount() (int, error) { + var count int + err := repo.db.Get(&count, "SELECT count(*) FROM artist") + return count, err +} + +func (repo *MusicRepositoryPostgres) GetArtistByID(id string) (*model.Artist, error) { var artist = model.Artist{} - err := db.Get(&artist, "SELECT * FROM artist WHERE id=$1", id) + err := repo.db.Get(&artist, "SELECT * FROM artist WHERE id=$1", id) if err != nil { return nil, err } @@ -19,26 +31,10 @@ func GetArtist(db *sqlx.DB, id string) (*model.Artist, error) { return &artist, nil } -func GetAllArtists(db *sqlx.DB) ([]*model.Artist, error) { +func (repo *MusicRepositoryPostgres) GetArtistsNotOnRelease(releaseID string) ([]*model.Artist, error) { var artists = []*model.Artist{} - err := db.Select(&artists, "SELECT * FROM artist") - if err != nil { - return nil, err - } - - return artists, nil -} -func GetArtistCount(db *sqlx.DB) (int, error) { - var count int - err := db.Get(&count, "SELECT count(*) FROM artist") - return count, err -} - -func GetArtistsNotOnRelease(db *sqlx.DB, releaseID string) ([]*model.Artist, error) { - var artists = []*model.Artist{} - - err := db.Select(&artists, + err := repo.db.Select(&artists, "SELECT * FROM artist "+ "WHERE id NOT IN "+ "(SELECT artist FROM musiccredit WHERE release=$1)", @@ -50,15 +46,15 @@ func GetArtistsNotOnRelease(db *sqlx.DB, releaseID string) ([]*model.Artist, err return artists, nil } -func GetArtistCredits(db *sqlx.DB, artistID string, show_hidden bool) ([]*model.Credit, error) { +func (repo *MusicRepositoryPostgres) GetArtistCredits(artistID string, showHidden bool) ([]*model.Credit, error) { var query string = "SELECT release.id,title,artwork,release_date,artist.id,name,website,avatar,role,is_primary "+ "FROM musiccredit "+ "JOIN musicrelease AS release ON release=release.id "+ "JOIN artist ON artist=artist.id "+ "WHERE artist=$1 " - if !show_hidden { query += "AND visible=true " } + if !showHidden { query += "AND visible=true " } query += "ORDER BY release_date DESC" - rows, err := db.Query(query, artistID) + rows, err := repo.db.Query(query, artistID) if err != nil { return nil, err } @@ -70,7 +66,10 @@ func GetArtistCredits(db *sqlx.DB, artistID string, show_hidden bool) ([]*model. } var credits []*model.Credit for rows.Next() { - var credit model.Credit + credit := &model.Credit{ + Release: &model.Release{}, + Artist: &model.Artist{}, + } err = rows.Scan( &credit.Release.ID, &credit.Release.Title, @@ -85,44 +84,42 @@ func GetArtistCredits(db *sqlx.DB, artistID string, show_hidden bool) ([]*model. ) otherArtists := []NamePrimary{} - err = db.Select(&otherArtists, + err = repo.db.Select(&otherArtists, "SELECT name,is_primary FROM artist "+ "JOIN musiccredit ON artist=id "+ "WHERE release=$1", credit.Release.ID) for _, otherCredit := range otherArtists { credit.Release.Credits = append(credit.Release.Credits, &model.Credit{ - Artist: model.Artist{ + Artist: &model.Artist{ Name: otherCredit.Name, }, Primary: otherCredit.Primary, }) } - credits = append(credits, &credit) + credits = append(credits, credit) } return credits, nil } -func CreateArtist(db *sqlx.DB, artist *model.Artist) error { - _, err := db.Exec( +func (repo *MusicRepositoryPostgres) CreateArtist( + id string, + name string, + website string, + avatar string, +) error { + _, err := repo.db.Exec( "INSERT INTO artist (id, name, website, avatar) "+ "VALUES ($1, $2, $3, $4)", - artist.ID, - artist.Name, - artist.Website, - artist.Avatar, + id, name, website, avatar, ) - if err != nil { - return err - } - - return nil + return err } -func UpdateArtist(db *sqlx.DB, artist *model.Artist) error { - _, err := db.Exec( +func (repo *MusicRepositoryPostgres) UpdateArtist(artist *model.Artist) error { + _, err := repo.db.Exec( "UPDATE artist "+ "SET name=$2, website=$3, avatar=$4 "+ "WHERE id=$1", @@ -131,22 +128,27 @@ func UpdateArtist(db *sqlx.DB, artist *model.Artist) error { artist.Website, artist.Avatar, ) - if err != nil { - return err - } - - return nil + return err +} +func (repo *MusicRepositoryPostgres) UpdateArtistID(oldID string, newID string) error { + _, err := repo.db.Exec("UPDATE artist SET id=$2 WHERE id=$1", oldID, newID) + return err +} +func (repo *MusicRepositoryPostgres) UpdateArtistName(id string, name string) error { + _, err := repo.db.Exec("UPDATE artist SET name=$2 WHERE id=$1", id, name) + return err +} +func (repo *MusicRepositoryPostgres) UpdateArtistWebsite(id string, website string) error { + _, err := repo.db.Exec("UPDATE artist SET website=$2 WHERE id=$1", id, website) + return err +} +func (repo *MusicRepositoryPostgres) UpdateArtistAvatar(id string, avatar string) error { + _, err := repo.db.Exec("UPDATE artist SET avatar=$2 WHERE id=$1", id, avatar) + return err } -func DeleteArtist(db *sqlx.DB, artistID string) error { - _, err := db.Exec( - "DELETE FROM artist "+ - "WHERE id=$1", - artistID, - ) - if err != nil { - return err - } - - return nil +func (repo *MusicRepositoryPostgres) DeleteArtist(id string) (string, error) { + var deletedID string + err := repo.db.Get(&deletedID, "DELETE FROM artist WHERE id=$1", id) + return deletedID, err } diff --git a/repository/music/interface.go b/repository/music/interface.go new file mode 100644 index 0000000..5fdc3ac --- /dev/null +++ b/repository/music/interface.go @@ -0,0 +1,110 @@ +package music + +import ( + "arimelody-web/model" + "time" + + "github.com/jmoiron/sqlx" +) + +type MusicRepository interface { + // artists + + GetAllArtists() ([]*model.Artist, error) + GetArtistCount() (int, error) + // Fetches an artist by ID, returning an error if one was encountered. + // If the artist does not exist, both response fields are nil. + GetArtistByID(id string) (*model.Artist, error) + GetArtistsNotOnRelease(releaseID string) ([]*model.Artist, error) + GetArtistCredits(artistID string, showHidden bool) ([]*model.Credit, error) + + CreateArtist(id string, name string, website string, avatar string) error + + UpdateArtist(artist *model.Artist) error + UpdateArtistID(oldID string, newID string) error + UpdateArtistName(id string, name string) error + UpdateArtistWebsite(id string, website string) error + UpdateArtistAvatar(id string, avatar string) error + + DeleteArtist(id string) (string, error) + + // releases + + // Fetch all releases. + // Filters to visible releases if `onlyVisible = true`. + // If `limit > 0`, limits the number of results. + GetAllReleases(onlyVisible bool, limit int) ([]*model.Release, error) + GetReleaseCount(onlyVisible bool) (int, error) + GetReleaseByID(id string) (*model.Release, error) + + GetReleaseTracks(id string) ([]*model.Track, error) + GetReleaseCredits(id string) ([]*model.Credit, error) + GetReleaseLinks(id string) ([]*model.Link, error) + + CreateRelease(id string, title string, releaseType model.ReleaseType, releaseDate time.Time, artworkURL string) error + + UpdateRelease(release *model.Release) error + UpdateReleaseID(oldID string, newID string) error + UpdateReleaseVisibility(id string, visible bool) error + UpdateReleaseTitle(id string, title string) error + UpdateReleaseDescription(id string, description string) error + UpdateReleaseType(id string, releaseType model.ReleaseType) error + UpdateReleaseDate(id string, releaseDate time.Time) error + UpdateReleaseArtwork(id string, artwork string) error + UpdateReleaseBuyInfo(id string, buyName string, buyLink string) error + UpdateReleaseCopyright(id string, copyright string, url string) error + UpdateReleaseTracks(id string, newTrackIDs []string) error + UpdateReleaseCredits(id string, newCredits []*model.Credit) error + UpdateReleaseLinks(id string, newLinks []*model.Link) error + + DeleteRelease(id string) (string, error) + + // tracks + + GetAllTracks() ([]*model.Track, error) + GetTrackCount() (int, error) + GetTrackByID(id string) (*model.Track, error) + GetOrphanTracks() ([]*model.Track, error) + GetTracksNotOnRelease(releaseID string) ([]*model.Track, error) + GetTrackReleases(trackID string) ([]*model.Release, error) + + CreateTrack(title string, description string, lyrics string, previewURL string) (string, error) + + UpdateTrack(track *model.Track) error + UpdateTrackTitle(id string, title string) error + UpdateTrackDescription(id string, description string) error + UpdateTrackLyrics(id string, lyrics string) error + UpdateTrackPreviewURL(id string, previewURL string) error + + DeleteTrack(id string) (string, error) +} + +type ( + MusicRepositoryPostgres struct { + db *sqlx.DB + } + + MusicRepositoryMemory struct { + artists []*model.Artist + releases []*model.Release + tracks []*model.Track + } +) + +var _ MusicRepository = new(MusicRepositoryPostgres) +func NewMusicRepositoryPostgres(db *sqlx.DB) *MusicRepositoryPostgres { + return &MusicRepositoryPostgres{ db: db } +} + +var _ MusicRepository = new(MusicRepositoryMemory) +func NewMusicRepositoryMemory( + artists []*model.Artist, + releases []*model.Release, + tracks []*model.Track, +) *MusicRepositoryMemory { + return &MusicRepositoryMemory{ + artists: artists, + releases: releases, + tracks: tracks, + } +} diff --git a/repository/music/release_memory.go b/repository/music/release_memory.go new file mode 100644 index 0000000..3e99612 --- /dev/null +++ b/repository/music/release_memory.go @@ -0,0 +1,228 @@ +package music + +import ( + "arimelody-web/errors" + "arimelody-web/model" + "fmt" + "slices" + "time" +) + +func (repo *MusicRepositoryMemory) GetAllReleases(onlyVisible bool, limit int) ([]*model.Release, error) { + releases := []*model.Release{} + for _, release := range repo.releases { + if !onlyVisible || release.Visible { + releases = append(releases, release) + } + } + return releases, nil +} +func (repo *MusicRepositoryMemory) GetReleaseCount(onlyVisible bool) (int, error) { + releaseCount := 0 + for _, release := range repo.releases { + if !onlyVisible || release.Visible { + releaseCount++ + } + } + return releaseCount, nil +} + +func (repo *MusicRepositoryMemory) GetReleaseByID(id string) (*model.Release, error) { + index := slices.IndexFunc(repo.releases, func(release *model.Release) bool { + return release.ID == id + }) + if index == -1 { return nil, nil } + return repo.releases[index], nil +} + +func (repo *MusicRepositoryMemory) GetReleaseTracks(id string) ([]*model.Track, error) { + release, err := repo.GetReleaseByID(id) + if err != nil { return nil, err } + return release.Tracks, nil +} + +func (repo *MusicRepositoryMemory) GetReleaseCredits(id string) ([]*model.Credit, error) { + release, err := repo.GetReleaseByID(id) + if err != nil { return nil, err } + return release.Credits, nil +} + +func (repo *MusicRepositoryMemory) GetReleaseLinks(id string) ([]*model.Link, error) { + release, err := repo.GetReleaseByID(id) + if err != nil { return nil, err } + return release.Links, nil +} + +func (repo *MusicRepositoryMemory) CreateRelease( + id string, + title string, + releaseType model.ReleaseType, + releaseDate time.Time, + artworkURL string, +) error { + if release, err := repo.GetReleaseByID(id); err != nil { + if !errors.IsNotExistError(err) { return err } + } else { + if release != nil { return errors.NewValidationError("Release with this ID already exists") } + } + + repo.releases = append(repo.releases, &model.Release{ + ID: id, + Title: title, + ReleaseType: releaseType, + ReleaseDate: releaseDate, + Artwork: artworkURL, + }) + return nil +} + +func (repo *MusicRepositoryMemory) UpdateRelease(release *model.Release) error { + repoRelease, err := repo.GetReleaseByID(release.ID) + if err != nil { return err } + if repoRelease == nil { return errors.NewNotExistError("Release does not exist") } + + repoRelease.Visible = release.Visible + repoRelease.Title = release.Title + repoRelease.Description = release.Description + repoRelease.ReleaseType = release.ReleaseType + repoRelease.ReleaseDate = release.ReleaseDate + repoRelease.Artwork = release.Artwork + repoRelease.Buyname = release.Buyname + repoRelease.Buylink = release.Buylink + repoRelease.Copyright = release.Copyright + repoRelease.CopyrightURL = release.CopyrightURL + + return nil +} + +func (repo *MusicRepositoryMemory) UpdateReleaseID(oldID string, newID string) error { + release, err := repo.GetReleaseByID(oldID) + if err != nil { return err } + if release == nil { return errors.NewNotExistError("Release does not exist") } + release.ID = newID + return nil +} +func (repo *MusicRepositoryMemory) UpdateReleaseVisibility(id string, visible bool) error { + release, err := repo.GetReleaseByID(id) + if err != nil { return err } + if release == nil { return errors.NewNotExistError("Release does not exist") } + release.Visible = visible + return nil +} +func (repo *MusicRepositoryMemory) UpdateReleaseTitle(id string, title string) error { + release, err := repo.GetReleaseByID(id) + if err != nil { return err } + if release == nil { return errors.NewNotExistError("Release does not exist") } + release.Title = title + return nil +} +func (repo *MusicRepositoryMemory) UpdateReleaseDescription(id string, description string) error { + release, err := repo.GetReleaseByID(id) + if err != nil { return err } + if release == nil { return errors.NewNotExistError("Release does not exist") } + release.Description = description + return nil +} +func (repo *MusicRepositoryMemory) UpdateReleaseType(id string, releaseType model.ReleaseType) error { + release, err := repo.GetReleaseByID(id) + if err != nil { return err } + if release == nil { return errors.NewNotExistError("Release does not exist") } + release.ReleaseType = releaseType + return nil +} +func (repo *MusicRepositoryMemory) UpdateReleaseDate(id string, releaseDate time.Time) error { + release, err := repo.GetReleaseByID(id) + if err != nil { return err } + if release == nil { return errors.NewNotExistError("Release does not exist") } + release.ReleaseDate = releaseDate + return nil +} +func (repo *MusicRepositoryMemory) UpdateReleaseArtwork(id string, artwork string) error { + release, err := repo.GetReleaseByID(id) + if err != nil { return err } + if release == nil { return errors.NewNotExistError("Release does not exist") } + release.Artwork = artwork + return nil +} +func (repo *MusicRepositoryMemory) UpdateReleaseBuyInfo(id string, buyName string, buyLink string) error { + release, err := repo.GetReleaseByID(id) + if err != nil { return err } + if release == nil { return errors.NewNotExistError("Release does not exist") } + release.Buyname = buyName + release.Buylink = buyLink + return nil +} +func (repo *MusicRepositoryMemory) UpdateReleaseCopyright(id string, copyright string, url string) error { + release, err := repo.GetReleaseByID(id) + if err != nil { return err } + if release == nil { return errors.NewNotExistError("Release does not exist") } + release.Copyright = copyright + release.CopyrightURL = url + return nil +} + +func (repo *MusicRepositoryMemory) UpdateReleaseTracks(id string, newTrackIDs []string) error { + release, err := repo.GetReleaseByID(id) + if err != nil { return err } + if release == nil { return errors.NewNotExistError("Release does not exist") } + + tracks := []*model.Track{} + for _, trackID := range newTrackIDs { + track, err := repo.GetTrackByID(trackID) + if err != nil { + if errors.IsNotExistError(err) { + return errors.NewNotExistError(fmt.Sprintf("Track %s does not exist", trackID)) + } + return err + } + tracks = append(tracks, track) + } + release.Tracks = tracks + + return nil +} + +func (repo *MusicRepositoryMemory) UpdateReleaseCredits(id string, newCredits []*model.Credit) error { + release, err := repo.GetReleaseByID(id) + if err != nil { return err } + if release == nil { return errors.NewNotExistError("Release does not exist") } + + for _, credit := range newCredits { + if credit.Artist == nil { return errors.NewValidationError("Credit artist cannot be empty") } + if len(credit.Artist.ID) == 0 { return errors.NewValidationError("Credit artist ID cannot be empty") } + if artist, err := repo.GetArtistByID(credit.Artist.ID); err != nil { + return err + } else if artist == nil { + return errors.NewNotExistError(fmt.Sprintf("Artist '%s' does not exist", credit.Artist.ID)) + } else { + credit.Release = release + credit.Artist = artist + } + } + + release.Credits = newCredits + + return nil +} + +func (repo *MusicRepositoryMemory) UpdateReleaseLinks(id string, newLinks []*model.Link) error { + release, err := repo.GetReleaseByID(id) + if err != nil { return err } + if release == nil { return errors.NewNotExistError("Release does not exist") } + release.Links = newLinks + return nil +} + +func (repo *MusicRepositoryMemory) DeleteRelease(id string) (string, error) { + var deletedID string + newReleases := []*model.Release{} + for _, release := range repo.releases { + if release.ID == id { + deletedID = id + continue + } + newReleases = append(newReleases, release) + } + repo.releases = newReleases + return deletedID, nil +} diff --git a/repository/music/release_postgres.go b/repository/music/release_postgres.go new file mode 100644 index 0000000..e98a165 --- /dev/null +++ b/repository/music/release_postgres.go @@ -0,0 +1,303 @@ +package music + +import ( + "arimelody-web/model" + "time" +) + +func (repo *MusicRepositoryPostgres) GetAllReleases(onlyVisible bool, limit int) ([]*model.Release, error) { + var releases = []*model.Release{} + + query := "SELECT * FROM musicrelease" + if onlyVisible { + query += " WHERE visible=true" + } + query += " ORDER BY release_date DESC" + var err error + if limit > 0 { + err = repo.db.Select(&releases, query + " LIMIT $1", limit) + } else { + err = repo.db.Select(&releases, query) + } + if err != nil { + return nil, err + } + + return releases, nil +} +func (repo *MusicRepositoryPostgres) GetReleaseCount(onlyVisible bool) (int, error) { + query := "SELECT count(*) FROM musicrelease" + if onlyVisible { + query += " WHERE visible=true" + } + + var count int + err := repo.db.Get(&count, query) + + return count, err +} + +func (repo *MusicRepositoryPostgres) GetReleaseByID(id string) (*model.Release, error) { + var release = model.Release{} + err := repo.db.Get(&release, "SELECT * FROM musicrelease WHERE id=$1", id) + if err != nil { return nil, err } + return &release, nil +} + +func (repo *MusicRepositoryPostgres) GetReleaseTracks(releaseID string) ([]*model.Track, error) { + var tracks = []*model.Track{} + + err := repo.db.Select(&tracks, + "SELECT musictrack.* FROM musictrack "+ + "JOIN musicreleasetrack ON track=id "+ + "WHERE release=$1 "+ + "ORDER BY number ASC", + releaseID, + ) + if err != nil { + return nil, err + } + + return tracks, nil +} + +func (repo *MusicRepositoryPostgres) GetReleaseCredits(releaseID string) ([]*model.Credit, error) { + rows, err := repo.db.Query( + "SELECT artist.id,artist.name,artist.website,artist.avatar,role,is_primary "+ + "FROM musiccredit "+ + "JOIN artist ON artist=artist.id "+ + "JOIN musicrelease ON release=musicrelease.id "+ + "WHERE musicrelease.id=$1 "+ + "ORDER BY is_primary DESC", + releaseID, + ) + if err != nil { + return nil, err + } + + var credits []*model.Credit + for rows.Next() { + credit := &model.Credit{ + Artist: &model.Artist{}, + } + rows.Scan( + &credit.Artist.ID, + &credit.Artist.Name, + &credit.Artist.Website, + &credit.Artist.Avatar, + &credit.Role, + &credit.Primary) + credits = append(credits, credit) + } + + return credits, nil +} + +func (repo *MusicRepositoryPostgres) GetReleaseLinks(releaseID string) ([]*model.Link, error) { + var links = []*model.Link{} + + err := repo.db.Select(&links, "SELECT name,url FROM musiclink WHERE release=$1", releaseID) + if err != nil { + return nil, err + } + + return links, nil +} + +func (repo *MusicRepositoryPostgres) CreateRelease( + id string, + title string, + releaseType model.ReleaseType, + releaseDate time.Time, + artworkURL string, +) error { + _, err := repo.db.Exec( + "INSERT INTO musicrelease "+ + "(id, title, type, release_date, artwork) "+ + "VALUES ($1, $2, $3, $4, $5)", + id, + title, + releaseType, + releaseDate.Format("2006-01-02 15:04:05"), + artworkURL, + ) + if err != nil { + return err + } + + return nil +} + +func (repo *MusicRepositoryPostgres) UpdateRelease(release *model.Release) error { + _, err := repo.db.Exec( + "UPDATE musicrelease SET "+ + "visible=$2, title=$3, description=$4, type=$5, release_date=$6, artwork=$7, buyname=$8, buylink=$9, copyright=$10, copyrighturl=$11 "+ + "WHERE id=$1", + release.ID, + release.Visible, + release.Title, + release.Description, + release.ReleaseType, + release.ReleaseDate.Format("2006-01-02 15:04:05"), + release.Artwork, + release.Buyname, + release.Buylink, + release.Copyright, + release.CopyrightURL, + ) + if err != nil { + return err + } + + return nil +} + +func (repo *MusicRepositoryPostgres) UpdateReleaseID(oldID string, newID string) error { + _, err := repo.db.Exec("UPDATE musicrelease SET id=$2 WHERE id=$1", oldID, newID) + return err +} + +func (repo *MusicRepositoryPostgres) UpdateReleaseVisibility(id string, visible bool) error { + _, err := repo.db.Exec("UPDATE musicrelease SET visible=$2 WHERE id=$1", id, visible) + return err +} +func (repo *MusicRepositoryPostgres) UpdateReleaseTitle(id string, title string) error { + _, err := repo.db.Exec("UPDATE musicrelease SET title=$2 WHERE id=$1", id, title) + return err +} +func (repo *MusicRepositoryPostgres) UpdateReleaseDescription(id string, description string) error { + _, err := repo.db.Exec("UPDATE musicrelease SET description=$2 WHERE id=$1", id, description) + return err +} +func (repo *MusicRepositoryPostgres) UpdateReleaseType(id string, releaseType model.ReleaseType) error { + _, err := repo.db.Exec("UPDATE musicrelease SET type=$2 WHERE id=$1", id, releaseType) + return err +} +func (repo *MusicRepositoryPostgres) UpdateReleaseDate(id string, releaseDate time.Time) error { + _, err := repo.db.Exec( + "UPDATE musicrelease SET release_date=$2 WHERE id=$1", + id, + releaseDate.Format("2006-01-02 15:04:05"), + ) + return err +} +func (repo *MusicRepositoryPostgres) UpdateReleaseArtwork(id string, artwork string) error { + _, err := repo.db.Exec("UPDATE musicrelease SET artwork=$2 WHERE id=$1", id, artwork) + return err +} +func (repo *MusicRepositoryPostgres) UpdateReleaseBuyInfo(id string, buyName string, buyLink string) error { + _, err := repo.db.Exec( + "UPDATE musicrelease SET buyname=$2,buylink=$3 WHERE id=$1", + id, buyName, buyLink, + ) + return err +} +func (repo *MusicRepositoryPostgres) UpdateReleaseCopyright(id string, copyright string, url string) error { + _, err := repo.db.Exec( + "UPDATE musicrelease SET copyright=$2,copyrighturl=$3 WHERE id=$1", + id, copyright, url, + ) + return err +} + +func (repo *MusicRepositoryPostgres) UpdateReleaseTracks(releaseID string, newTrackIDs []string) error { + tx, err := repo.db.Begin() + if err != nil { + return err + } + + _, err = tx.Exec("DELETE FROM musicreleasetrack WHERE release=$1", releaseID) + if err != nil { + return err + } + for i, trackID := range newTrackIDs { + _, err = tx.Exec( + "INSERT INTO musicreleasetrack "+ + "(release, track, number) "+ + "VALUES ($1, $2, $3)", + releaseID, + trackID, + i) + if err != nil { + return err + } + } + + err = tx.Commit() + if err != nil { + return err + } + + return nil +} + +func (repo *MusicRepositoryPostgres) UpdateReleaseCredits(releaseID string, newCredits []*model.Credit) error { + tx, err := repo.db.Begin() + if err != nil { + return err + } + + _, err = tx.Exec("DELETE FROM musiccredit WHERE release=$1", releaseID) + if err != nil { + return err + } + for _, credit := range newCredits { + _, err = tx.Exec( + "INSERT INTO musiccredit "+ + "(release, artist, role, is_primary) "+ + "VALUES ($1, $2, $3, $4)", + releaseID, + credit.Artist.ID, + credit.Role, + credit.Primary, + ) + if err != nil { + return err + } + } + + err = tx.Commit() + if err != nil { + return err + } + + return nil +} + +func (repo *MusicRepositoryPostgres) UpdateReleaseLinks(releaseID string, newLinks []*model.Link) error { + tx, err := repo.db.Begin() + if err != nil { + return err + } + + _, err = tx.Exec("DELETE FROM musiclink WHERE release=$1", releaseID) + if err != nil { + return err + } + for _, link := range newLinks { + _, err := tx.Exec( + "INSERT INTO musiclink "+ + "(release, name, url) "+ + "VALUES ($1, $2, $3)", + releaseID, + link.Name, + link.URL, + ) + if err != nil { + return err + } + } + + err = tx.Commit() + if err != nil { + return err + } + + return nil +} + +func (repo *MusicRepositoryPostgres) DeleteRelease(id string) (string, error) { + var deletedID string + err := repo.db.Get(&deletedID, "DELETE FROM musicrelease WHERE id=$1", id) + return deletedID, err +} diff --git a/repository/music/track_memory.go b/repository/music/track_memory.go new file mode 100644 index 0000000..7ad574b --- /dev/null +++ b/repository/music/track_memory.go @@ -0,0 +1,123 @@ +package music + +import ( + "arimelody-web/errors" + "arimelody-web/model" + "slices" + "strconv" +) + +func (repo *MusicRepositoryMemory) GetAllTracks() ([]*model.Track, error) { + return repo.tracks, nil +} +func (repo *MusicRepositoryMemory) GetTrackCount() (int, error) { + return len(repo.tracks), nil +} + +func (repo *MusicRepositoryMemory) GetTrackByID(id string) (*model.Track, error) { + index := slices.IndexFunc(repo.tracks, func(track *model.Track) bool { + return track.ID == id + }) + if index == -1 { return nil, nil } + return repo.tracks[index], nil +} + +func (repo *MusicRepositoryMemory) GetOrphanTracks() ([]*model.Track, error) { + return slices.DeleteFunc(repo.tracks, func(track *model.Track) bool { + return slices.ContainsFunc(repo.releases, func(release *model.Release) bool { + return slices.ContainsFunc(release.Tracks, func(releaseTrack *model.Track) bool { + return releaseTrack.ID == track.ID + }) + }) + }), nil +} + +func (repo *MusicRepositoryMemory) GetTracksNotOnRelease(releaseID string) ([]*model.Track, error) { + release, err := repo.GetReleaseByID(releaseID) + if err != nil { return nil, err } + + return slices.DeleteFunc(repo.tracks, func(track *model.Track) bool { + return slices.ContainsFunc(release.Tracks, func(releaseTrack *model.Track) bool { + return releaseTrack.ID == track.ID + }) + }), nil +} + +func (repo *MusicRepositoryMemory) GetTrackReleases(trackID string) ([]*model.Release, error) { + return slices.DeleteFunc(repo.releases, func(release *model.Release) bool { + return !slices.ContainsFunc(release.Tracks, func(track *model.Track) bool { + return track.ID == trackID + }) + }), nil +} + +func (repo *MusicRepositoryMemory) CreateTrack( + title string, + description string, + lyrics string, + previewURL string, +) (string, error) { + id := strconv.Itoa(len(repo.tracks)) + repo.tracks = append(repo.tracks, &model.Track{ + ID: id, + Title: title, + Description: description, + Lyrics: lyrics, + PreviewURL: previewURL, + }) + return id, nil +} + +func (repo *MusicRepositoryMemory) UpdateTrack(track *model.Track) error { + repoTrack, err := repo.GetTrackByID(track.ID) + if err != nil { return err } + if repoTrack == nil { return errors.NewNotExistError("Track does not exist") } + + repoTrack.Title = track.Title + repoTrack.Description = track.Description + repoTrack.Lyrics = track.Lyrics + repoTrack.PreviewURL = track.PreviewURL + return nil +} +func (repo *MusicRepositoryMemory) UpdateTrackTitle(id string, title string) error { + repoTrack, err := repo.GetTrackByID(id) + if err != nil { return err } + if repoTrack == nil { return errors.NewNotExistError("Track does not exist") } + repoTrack.Title = title + return nil +} +func (repo *MusicRepositoryMemory) UpdateTrackDescription(id string, description string) error { + repoTrack, err := repo.GetTrackByID(id) + if err != nil { return err } + if repoTrack == nil { return errors.NewNotExistError("Track does not exist") } + repoTrack.Description = description + return nil +} +func (repo *MusicRepositoryMemory) UpdateTrackLyrics(id string, lyrics string) error { + repoTrack, err := repo.GetTrackByID(id) + if err != nil { return err } + if repoTrack == nil { return errors.NewNotExistError("Track does not exist") } + repoTrack.Lyrics = lyrics + return nil +} +func (repo *MusicRepositoryMemory) UpdateTrackPreviewURL(id string, previewURL string) error { + repoTrack, err := repo.GetTrackByID(id) + if err != nil { return err } + if repoTrack == nil { return errors.NewNotExistError("Track does not exist") } + repoTrack.PreviewURL = previewURL + return nil +} + +func (repo *MusicRepositoryMemory) DeleteTrack(id string) (string, error) { + var deletedID string + newTracks := []*model.Track{} + for _, track := range repo.tracks { + if track.ID == id { + deletedID = id + continue + } + newTracks = append(newTracks, track) + } + repo.tracks = newTracks + return deletedID, nil +} diff --git a/repository/music/track_postgres.go b/repository/music/track_postgres.go new file mode 100644 index 0000000..b0b0946 --- /dev/null +++ b/repository/music/track_postgres.go @@ -0,0 +1,137 @@ +package music + +import ( + "arimelody-web/model" +) + +func (repo *MusicRepositoryPostgres) GetAllTracks() ([]*model.Track, error) { + var tracks = []*model.Track{} + + err := repo.db.Select(&tracks, "SELECT * FROM musictrack") + if err != nil { + return nil, err + } + + return tracks, nil +} +func (repo *MusicRepositoryPostgres) GetTrackCount() (int, error) { + var count int + err := repo.db.Get(&count, "SELECT count(*) FROM musictrack") + return count, err +} + +func (repo *MusicRepositoryPostgres) GetTrackByID(id string) (*model.Track, error) { + var track = model.Track{} + + stmt, _ := repo.db.Preparex("SELECT * FROM musictrack WHERE id=$1") + err := stmt.Get(&track, id) + if err != nil { + return nil, err + } + return &track, nil +} + +func (repo *MusicRepositoryPostgres) GetOrphanTracks() ([]*model.Track, error) { + var tracks = []*model.Track{} + + err := repo.db.Select(&tracks, "SELECT * FROM musictrack WHERE id NOT IN (SELECT track FROM musicreleasetrack)") + if err != nil { + return nil, err + } + + return tracks, nil +} + +func (repo *MusicRepositoryPostgres) GetTracksNotOnRelease(releaseID string) ([]*model.Track, error) { + var tracks = []*model.Track{} + + err := repo.db.Select(&tracks, + "SELECT * FROM musictrack "+ + "WHERE id NOT IN "+ + "(SELECT track FROM musicreleasetrack WHERE release=$1)", + releaseID) + if err != nil { + return nil, err + } + + return tracks, nil +} + +func (repo *MusicRepositoryPostgres) GetTrackReleases(trackID string) ([]*model.Release, error) { + var releases = []*model.Release{} + + err := repo.db.Select(&releases, + "SELECT id,title,type,release_date,artwork,buylink "+ + "FROM musicrelease "+ + "JOIN musicreleasetrack ON release=id "+ + "WHERE track=$1 "+ + "ORDER BY release_date", + trackID, + ) + if err != nil { return nil, err } + + return releases, nil +} + +func (repo *MusicRepositoryPostgres) CreateTrack( + title string, + description string, + lyrics string, + previewURL string, +) (string, error) { + var trackID string + err := repo.db.QueryRow( + "INSERT INTO musictrack (title, description, lyrics, preview_url) "+ + "VALUES ($1, $2, $3, $4) "+ + "RETURNING id", + title, + description, + lyrics, + previewURL, + ).Scan(&trackID) + if err != nil { + return "", err + } + + return trackID, nil +} + +func (repo *MusicRepositoryPostgres) UpdateTrack(track *model.Track) error { + _, err := repo.db.Exec( + "UPDATE musictrack "+ + "SET title=$2, description=$3, lyrics=$4, preview_url=$5 "+ + "WHERE id=$1", + track.ID, + track.Title, + track.Description, + track.Lyrics, + track.PreviewURL, + ) + if err != nil { + return err + } + + return nil +} +func (repo *MusicRepositoryPostgres) UpdateTrackTitle(id string, title string) error { + _, err := repo.db.Exec("UPDATE musictrack SET title=$2 WHERE id=$1", id, title) + return err +} +func (repo *MusicRepositoryPostgres) UpdateTrackDescription(id string, description string) error { + _, err := repo.db.Exec("UPDATE musictrack SET description=$2 WHERE id=$1", id, description) + return err +} +func (repo *MusicRepositoryPostgres) UpdateTrackLyrics(id string, lyrics string) error { + _, err := repo.db.Exec("UPDATE musictrack SET lyrics=$2 WHERE id=$1", id, lyrics) + return err +} +func (repo *MusicRepositoryPostgres) UpdateTrackPreviewURL(id string, previewURL string) error { + _, err := repo.db.Exec("UPDATE musictrack SET preview_url=$2 WHERE id=$1", id, previewURL) + return err +} + +func (repo *MusicRepositoryPostgres) DeleteTrack(id string) (string, error) { + var deletedID string + err := repo.db.Get(&deletedID, "DELETE FROM musictrack WHERE id=$1 RETURNING id", id) + return deletedID, err +} diff --git a/service/account/account.go b/service/account/account.go index 658252f..bf7afd4 100644 --- a/service/account/account.go +++ b/service/account/account.go @@ -1,10 +1,10 @@ package account import ( + "arimelody-web/errors" "arimelody-web/model" repository "arimelody-web/repository/account" - "errors" - "fmt" + "arimelody-web/service/validator" "log" ) @@ -29,33 +29,24 @@ func (s *AccountService) GetCount() (int, error) { } func (s *AccountService) GetByID(id string) (*model.Account, error) { - if account, err := s.repo.GetByID(id); err != nil { - return nil, err - } else if account == nil { - return nil, fmt.Errorf("Account does not exist: %s", id) - } else { - return account, nil - } + account, err := s.repo.GetByID(id) + if err != nil { return nil, err } + if account == nil { return nil, errors.NewNotExistError("Account does not exist") } + return account, nil } func (s *AccountService) GetByUsername(username string) (*model.Account, error) { - if account, err := s.repo.GetByUsername(username); err != nil { - return nil, err - } else if account == nil { - return nil, fmt.Errorf("Account does not exist: %s", username) - } else { - return account, nil - } + account, err := s.repo.GetByUsername(username) + if err != nil { return nil, err } + if account == nil { return nil, errors.NewNotExistError("Account does not exist") } + return account, nil } func (s *AccountService) GetByEmail(email string) (*model.Account, error) { - if account, err := s.repo.GetByEmail(email); err != nil { - return nil, err - } else if account == nil { - return nil, fmt.Errorf("Account does not exist with email: %s", email) - } else { - return account, nil - } + account, err := s.repo.GetByEmail(email) + if err != nil { return nil, err } + if account == nil { return nil, errors.NewNotExistError("Account does not exist") } + return account, nil } func (s *AccountService) Create( @@ -64,13 +55,13 @@ func (s *AccountService) Create( email *string, avatarURL *string, ) (string, error) { + if len(username) == 0 { return "", errors.NewValidationError("Username cannot be empty") } + if !validator.ValidateID(username) { return "", errors.NewValidationError("Username contains invalid characters") } + if len(password) == 0 { return "", errors.NewValidationError("Password cannot be empty") } + if email != nil && len(*email) == 0 { return "", errors.NewValidationError("Email cannot be empty") } + var id string var err error - - if len(username) == 0 { return id, errors.New("Username cannot be empty") } - if len(password) == 0 { return id, errors.New("Password cannot be empty") } - if email != nil && len(*email) == 0 { return id, errors.New("Email cannot be empty") } - if id, err = s.repo.Create(username, password, email, avatarURL); err != nil { return id, err } @@ -80,48 +71,31 @@ func (s *AccountService) Create( } func (s *AccountService) ChangeUsername(id string, username string) error { - if len(username) == 0 { return errors.New("Username cannot be empty") } - if err := s.repo.UpdateAvatarURL(id, username); err != nil { - return err - } - + if len(username) == 0 { return errors.NewValidationError("Username cannot be empty") } + if !validator.ValidateID(username) { return errors.NewValidationError("Username contains invalid characters") } + if err := s.repo.UpdateUsername(id, username); err != nil { return err } s.log.Printf("Changed username for %s to '%s'", id, username) return nil } func (s *AccountService) ChangePassword(id string, password string) error { - if len(password) == 0 { return errors.New("Password cannot be empty") } - if err := s.repo.UpdateAvatarURL(id, password); err != nil { - return err - } - + if len(password) == 0 { return errors.NewValidationError("Password cannot be empty") } + if err := s.repo.UpdatePassword(id, password); err != nil { return err } s.log.Printf("Changed password for %s", id) return nil } func (s *AccountService) ChangeEmail(id string, email string) error { if len(email) == 0 { return s.repo.RemoveEmail(id) } - if err := s.repo.UpdateAvatarURL(id, email); err != nil { - return err - } - + if err := s.repo.UpdateEmail(id, email); err != nil { return err } s.log.Printf("Changed email for %s to '%s'", id, email) return nil } func (s *AccountService) ChangeAvatarURL(id string, avatarURL string) error { if len(avatarURL) == 0 { return s.repo.RemoveAvatar(id) } - if err := s.repo.UpdateAvatarURL(id, avatarURL); err != nil { - return err - } - + if err := s.repo.UpdateAvatarURL(id, avatarURL); err != nil { return err } s.log.Printf("Changed avatar URL for %s to '%s'", id, avatarURL) return nil } -func (s *AccountService) Delete(id string) error { - if err := s.repo.Delete(id); err != nil { return err } - s.log.Printf("Deleted account %s", id) - return nil -} - func (s *AccountService) IncrementFails(id string) (int, error) { num, err := s.repo.IncrementFails(id) if err != nil { return 0, err } @@ -146,3 +120,11 @@ func (s *AccountService) Unlock(id string) error { s.log.Printf("Unlocked account %s", id) return nil } + +func (s *AccountService) Delete(id string) error { + deletedID, err := s.repo.Delete(id) + if err != nil { return err } + if deletedID == "" { return errors.NewNotExistError("Account does not exist") } + s.log.Printf("Deleted account %s", id) + return nil +} diff --git a/service/account/account_test.go b/service/account/account_test.go index 0c9a372..5eec680 100644 --- a/service/account/account_test.go +++ b/service/account/account_test.go @@ -1,16 +1,19 @@ -package account +package account_test import ( "arimelody-web/model" repository "arimelody-web/repository/account" + service "arimelody-web/service/account" + "arimelody-web/errors" "log" "os" "testing" - "gotest.tools/v3/assert" + + "gotest.tools/v3/assert" ) var ( - service *AccountService + s *service.AccountService ) func init() { @@ -19,7 +22,7 @@ func init() { defer devNullFile.Close() repo := repository.NewAccountRepositoryMemory(make([]*model.Account, 0)) - service = NewAccountService( + s = service.NewAccountService( repo, log.New(devNullFile, "", model.DEFAULT_LOG_FLAGS), ) @@ -41,14 +44,14 @@ func Test_Account(t *testing.T) { username := "testificate" password := "the amazing digital data breach" email := "goober@arimelody.space" - avatarURL := "/img/default-avatar.webp" + avatarURL := "/img/account-avatar.webp" var id string var err error t.Run("accounts should start empty", func(t *testing.T) { t.Run("count is zero", func(t *testing.T) { - if num, err := service.GetCount(); err != nil { + if num, err := s.GetCount(); err != nil { t.Errorf("Failed to get number of accounts: %v", err) } else { assert.Equal(t, num, 0) @@ -56,8 +59,8 @@ func Test_Account(t *testing.T) { }) t.Run("service returns empty array", func(t *testing.T) { - if accounts, err := service.GetAll(); err != nil { - t.Errorf("Failed to get number of accounts: %v", err) + if accounts, err := s.GetAll(); err != nil { + t.Errorf("Failed to get accounts: %v", err) } else { assert.Equal(t, len(accounts), 0) } @@ -65,13 +68,28 @@ func Test_Account(t *testing.T) { }) t.Run("can create account", func(t *testing.T) { - id, err = service.Create(username, password, &email, &avatarURL) + id, err = s.Create(username, password, &email, &avatarURL) if err != nil { t.Errorf("Failed to create account: %v", err) } + t.Run("but not with invalid username", func(t *testing.T) { + if _, err := s.Create("", password, &email, &avatarURL); err == nil { + t.Error("Could create account with invalid username") + } else if !errors.IsValidationError(err) { + t.Error("Error is not validation error") + } + }) + t.Run("but not with invalid password", func(t *testing.T) { + if _, err := s.Create("test-username", "", &email, &avatarURL); err == nil { + t.Error("Could create account with invalid password") + } else if !errors.IsValidationError(err) { + t.Error("Error is not validation error") + } + }) + t.Run("and fetch by ID", func(t *testing.T) { - account, err := service.GetByID(id) + account, err := s.GetByID(id) if err != nil { t.Errorf("Failed to get account after creation: %v", err) } @@ -84,7 +102,7 @@ func Test_Account(t *testing.T) { assert.Equal(t, account.Locked, false) }) t.Run("and fetch by username", func(t *testing.T) { - account, err := service.GetByUsername(username) + account, err := s.GetByUsername(username) if err != nil { t.Errorf("Failed to get account after creation: %v", err) } @@ -97,7 +115,7 @@ func Test_Account(t *testing.T) { assert.Equal(t, account.Locked, false) }) t.Run("and fetch by email", func(t *testing.T) { - account, err := service.GetByEmail(email) + account, err := s.GetByEmail(email) if err != nil { t.Errorf("Failed to get account after creation: %v", err) } @@ -113,7 +131,7 @@ func Test_Account(t *testing.T) { t.Run("number of accounts should increment", func(t *testing.T) { t.Run("count is one", func(t *testing.T) { - if num, err := service.GetCount(); err != nil { + if num, err := s.GetCount(); err != nil { t.Errorf("Failed to get number of accounts: %v", err) } else { assert.Equal(t, num, 1) @@ -121,8 +139,8 @@ func Test_Account(t *testing.T) { }) t.Run("service returns array with one account", func(t *testing.T) { - if accounts, err := service.GetAll(); err != nil { - t.Errorf("Failed to get number of accounts: %v", err) + if accounts, err := s.GetAll(); err != nil { + t.Errorf("Failed to get accounts: %v", err) } else { assert.Equal(t, len(accounts), 1) } @@ -130,7 +148,7 @@ func Test_Account(t *testing.T) { }) t.Run("can't create duplicate account", func(t *testing.T) { - _, err := service.Create(username, password, &email, &avatarURL) + _, err := s.Create(username, password, &email, &avatarURL) if err == nil { t.Error("Duplicate account was created") } @@ -138,11 +156,11 @@ func Test_Account(t *testing.T) { t.Run("can change username", func(t *testing.T) { testUsername := "some_other_name" - if err := service.ChangeUsername(id, testUsername); err != nil { + if err := s.ChangeUsername(id, testUsername); err != nil { t.Errorf("Failed to change username: %v", err) } - if account, err := service.GetByID(id); err != nil { + if account, err := s.GetByID(id); err != nil { t.Errorf("Failed to get account: %v", err) } else if account == nil { t.Error("Account is nil after update") @@ -151,19 +169,21 @@ func Test_Account(t *testing.T) { } t.Run("but not to an invalid value", func(t *testing.T) { - if err := service.ChangeUsername(id, ""); err == nil { + if err := s.ChangeUsername(id, ""); err == nil { t.Error("Could change username to invalid value") + } else if !errors.IsValidationError(err) { + t.Error("Error is not validation error") } }) }) t.Run("can change password", func(t *testing.T) { testPassword := "other more different password" - if err := service.ChangePassword(id, testPassword); err != nil { + if err := s.ChangePassword(id, testPassword); err != nil { t.Errorf("Failed to change password: %v", err) } - if account, err := service.GetByID(id); err != nil { + if account, err := s.GetByID(id); err != nil { t.Errorf("Failed to get account: %v", err) } else if account == nil { t.Error("Account is nil after update") @@ -172,19 +192,21 @@ func Test_Account(t *testing.T) { } t.Run("but not to an invalid value", func(t *testing.T) { - if err := service.ChangePassword(id, ""); err == nil { + if err := s.ChangePassword(id, ""); err == nil { t.Error("Could change password to invalid value") + } else if !errors.IsValidationError(err) { + t.Error("Error is not validation error") } }) }) t.Run("can change email", func(t *testing.T) { testEmail := "brandnewemail@for.me" - if err := service.ChangeEmail(id, testEmail); err != nil { + if err := s.ChangeEmail(id, testEmail); err != nil { t.Errorf("Failed to change email: %v", err) } - if account, err := service.GetByID(id); err != nil { + if account, err := s.GetByID(id); err != nil { t.Errorf("Failed to get account: %v", err) } else if account == nil { t.Error("Account is nil after update") @@ -194,11 +216,11 @@ func Test_Account(t *testing.T) { }) t.Run("can remove email", func(t *testing.T) { - if err := service.ChangeEmail(id, ""); err != nil { + if err := s.ChangeEmail(id, ""); err != nil { t.Errorf("Failed to change email: %v", err) } - if account, err := service.GetByID(id); err != nil { + if account, err := s.GetByID(id); err != nil { t.Errorf("Failed to get account: %v", err) } else if account == nil { t.Error("Account is nil after update") @@ -209,11 +231,11 @@ func Test_Account(t *testing.T) { t.Run("can change avatar URL", func(t *testing.T) { testAvatarURL := "/img/some-other-avatar.webp" - if err := service.ChangeAvatarURL(id, testAvatarURL); err != nil { + if err := s.ChangeAvatarURL(id, testAvatarURL); err != nil { t.Errorf("Failed to change avatar URL: %v", err) } - if account, err := service.GetByID(id); err != nil { + if account, err := s.GetByID(id); err != nil { t.Errorf("Failed to get account: %v", err) } else if account == nil { t.Error("Account is nil after update") @@ -223,11 +245,11 @@ func Test_Account(t *testing.T) { }) t.Run("can remove avatar URL", func(t *testing.T) { - if err := service.ChangeAvatarURL(id, ""); err != nil { + if err := s.ChangeAvatarURL(id, ""); err != nil { t.Errorf("Failed to change avatar URL: %v", err) } - if account, err := service.GetByID(id); err != nil { + if account, err := s.GetByID(id); err != nil { t.Errorf("Failed to get account: %v", err) } else if account == nil { t.Error("Account is nil after update") @@ -237,13 +259,13 @@ func Test_Account(t *testing.T) { }) t.Run("can increment auth failures", func(t *testing.T) { - if num, err := service.IncrementFails(id); err != nil { + if num, err := s.IncrementFails(id); err != nil { t.Errorf("Failed to increment account auth failures: %v", err) } else { assert.Equal(t, num, 1) } - if account, err := service.GetByID(id); err != nil { + if account, err := s.GetByID(id); err != nil { t.Errorf("Failed to get account: %v", err) } else if account == nil { t.Error("Account is nil after update") @@ -253,11 +275,11 @@ func Test_Account(t *testing.T) { }) t.Run("can reset auth failures", func(t *testing.T) { - if err := service.ResetFails(id); err != nil { + if err := s.ResetFails(id); err != nil { t.Errorf("Failed to reset account auth failures: %v", err) } - if account, err := service.GetByID(id); err != nil { + if account, err := s.GetByID(id); err != nil { t.Errorf("Failed to get account: %v", err) } else if account == nil { t.Error("Account is nil after update") @@ -267,11 +289,11 @@ func Test_Account(t *testing.T) { }) t.Run("can lock account", func(t *testing.T) { - if err := service.Lock(id); err != nil { + if err := s.Lock(id); err != nil { t.Errorf("Failed to lock account: %v", err) } - if account, err := service.GetByID(id); err != nil { + if account, err := s.GetByID(id); err != nil { t.Errorf("Failed to get account: %v", err) } else if account == nil { t.Error("Account is nil after update") @@ -281,11 +303,11 @@ func Test_Account(t *testing.T) { }) t.Run("can unlock account", func(t *testing.T) { - if err := service.Unlock(id); err != nil { + if err := s.Unlock(id); err != nil { t.Errorf("Failed to unlock account: %v", err) } - if account, err := service.GetByID(id); err != nil { + if account, err := s.GetByID(id); err != nil { t.Errorf("Failed to get account: %v", err) } else if account == nil { t.Error("Account is nil after update") @@ -295,12 +317,14 @@ func Test_Account(t *testing.T) { }) t.Run("can delete account", func(t *testing.T) { - if err = service.Delete(id); err != nil { + if err = s.Delete(id); err != nil { t.Errorf("Failed to delete account: %v", err) } - if account, err := service.GetByID(id); err != nil { - t.Errorf("Failed to get account after deletion: %v", err) + if account, err := s.GetByID(id); err != nil { + if !errors.IsNotExistError(err) { + t.Errorf("Failed to get account after deletion: %v", err) + } } else if account != nil { t.Error("Account still exists after deletion") } @@ -308,56 +332,76 @@ func Test_Account(t *testing.T) { t.Run("can't create an account with invalid", func(t *testing.T) { t.Run("username", func(t *testing.T) { - if _, err := service.Create("", password, &email, &avatarURL); err == nil { + if _, err := s.Create("", password, &email, &avatarURL); err == nil { t.Error("Could create account with empty username") } }) t.Run("password", func(t *testing.T) { - if _, err := service.Create(username, "", &email, &avatarURL); err == nil { + if _, err := s.Create(username, "", &email, &avatarURL); err == nil { t.Error("Could create account with empty password") } }) t.Run("email", func(t *testing.T) { testEmail := "" - if _, err := service.Create(username, password, &testEmail, &avatarURL); err == nil { + if _, err := s.Create(username, password, &testEmail, &avatarURL); err == nil { t.Error("Could create account with empty (non-nil) email") } }) }) + t.Run("can't fetch account that doesn't exist", func(t *testing.T) { + t.Run("by ID", func(t *testing.T) { + if account, err := s.GetByID("adsginh534g9405gmb40i9bm"); err != nil { + if !errors.IsNotExistError(err) { t.Errorf("Failed to get account: %v", err) } + } else if account != nil { + t.Error("Could fetch non-existent account") + } + }) + + t.Run("by username", func(t *testing.T) { + if account, err := s.GetByUsername("adsginh534g9405gmb40i9bm"); err != nil { + if !errors.IsNotExistError(err) { t.Errorf("Failed to get account: %v", err) } + } else if account != nil { + t.Error("Could fetch non-existent account") + } + }) + + t.Run("email", func(t *testing.T) { + if account, err := s.GetByEmail("adsginh534g9405gmb40i9bm"); err != nil { + if !errors.IsNotExistError(err) { t.Errorf("Failed to get account: %v", err) } + } else if account != nil { + t.Error("Could fetch non-existent account") + } + }) + }) + t.Run("can't update account that doesn't exist", func(t *testing.T) { garbageAccountID := "adsginh534g9405gmb40i9bm" t.Run("username", func(t *testing.T) { - if err := service.ChangeUsername(garbageAccountID, "some-username"); err == nil { + if err := s.ChangeUsername(garbageAccountID, "some-username"); err == nil { t.Error("Could update non-existent account's username") } }) t.Run("password", func(t *testing.T) { - if err := service.ChangePassword(garbageAccountID, "some-password"); err == nil { + if err := s.ChangePassword(garbageAccountID, "some-password"); err == nil { t.Error("Could update non-existent account's password") } }) t.Run("email", func(t *testing.T) { - if err := service.ChangeEmail(garbageAccountID, "some-email@real.gov"); err == nil { + if err := s.ChangeEmail(garbageAccountID, "some-email@real.gov"); err == nil { t.Error("Could update non-existent account's email") } }) t.Run("avatar URL", func(t *testing.T) { - if err := service.ChangeAvatarURL(garbageAccountID, "/img/null.webp"); err == nil { + if err := s.ChangeAvatarURL(garbageAccountID, "/img/null.webp"); err == nil { t.Error("Could update non-existent account's avatar URL") } }) }) - - t.Cleanup(func() { - if err := service.Delete(id); err != nil { - t.Errorf("Failed to clean up test case: %v", err) - } - }) } diff --git a/service/log/log.go b/service/log/log.go index 21f1650..bf06b7b 100644 --- a/service/log/log.go +++ b/service/log/log.go @@ -3,6 +3,7 @@ package log import ( "arimelody-web/model" repository "arimelody-web/repository/log" + "arimelody-web/errors" "fmt" "log" "time" @@ -43,7 +44,10 @@ func (s *LogService) Warn(logType string, format string, args ...any) { } func (s *LogService) Fetch(id string) (*model.Log, error) { - return s.repo.Get(id) + log, err := s.repo.Get(id) + if err != nil { return nil, err } + if log == nil { return nil, errors.NewNotExistError("Log does not exist") } + return log, nil } func (s *LogService) Search( diff --git a/service/music/artist.go b/service/music/artist.go new file mode 100644 index 0000000..aa643af --- /dev/null +++ b/service/music/artist.go @@ -0,0 +1,82 @@ +package music + +import ( + "arimelody-web/errors" + "arimelody-web/model" + "arimelody-web/service/validator" +) + +func (s *MusicService) GetAllArtists() ([]*model.Artist, error) { + return s.repo.GetAllArtists() +} +func (s *MusicService) GetArtistCount() (int, error) { + return s.repo.GetArtistCount() +} +func (s *MusicService) GetArtistByID(id string) (*model.Artist, error) { + artist, err := s.repo.GetArtistByID(id) + if err != nil { return nil, err } + if artist == nil { return nil, errors.NewNotExistError("Artist does not exist") } + return artist, nil +} +func (s *MusicService) GetArtistsNotOnRelease(releaseID string) ([]*model.Artist, error) { + return s.repo.GetArtistsNotOnRelease(releaseID) +} +func (s *MusicService) GetArtistCredits(artistID string, showHidden bool) ([]*model.Credit, error) { + return s.repo.GetArtistCredits(artistID, showHidden) +} + +func (s *MusicService) CreateArtist( + id string, + name string, + website string, + avatar string, +) error { + if len(id) == 0 { return errors.NewValidationError("Artist ID cannot be empty") } + if !validator.ValidateID(id) { return errors.NewValidationError("Artist ID contains invalid characters") } + if len(name) == 0 { return errors.NewValidationError("Artist name cannot be empty") } + + if err := s.repo.CreateArtist(id, name, website, avatar); err != nil { return err } + s.log.Printf("Created new artist '%s' (%s)", name, id) + return nil +} + +func (s *MusicService) UpdateArtist(artist *model.Artist) error { + if len(artist.ID) == 0 { return errors.NewValidationError("Artist ID cannot be empty") } + if !validator.ValidateID(artist.ID) { return errors.NewValidationError("Artist ID contains invalid characters") } + if len(artist.Name) == 0 { return errors.NewValidationError("Artist name cannot be empty") } + if err := s.repo.UpdateArtist(artist); err != nil { return err } + s.log.Printf("Updated artist %s", artist.ID) + return nil +} + +func (s *MusicService) UpdateArtistID(oldID string, newID string) error { + if len(newID) == 0 { return errors.NewValidationError("Artist ID cannot be empty") } + if !validator.ValidateID(newID) { return errors.NewValidationError("Artist ID contains invalid characters") } + if err := s.repo.UpdateArtistID(oldID, newID); err != nil { return err } + s.log.Printf("Updated artist ID %s to %s", oldID, newID) + return nil +} +func (s *MusicService) UpdateArtistName(id string, name string) error { + if len(name) == 0 { return errors.NewValidationError("Artist name cannot be empty") } + if err := s.repo.UpdateArtistName(id, name); err != nil { return err } + s.log.Printf("Updated artist %s name to %s", id, name) + return nil +} +func (s *MusicService) UpdateArtistWebsite(id string, website string) error { + if err := s.repo.UpdateArtistWebsite(id, website); err != nil { return err } + s.log.Printf("Updated artist %s website to %s", id, website) + return nil +} +func (s *MusicService) UpdateArtistAvatar(id string, avatar string) error { + if err := s.repo.UpdateArtistAvatar(id, avatar); err != nil { return err } + s.log.Printf("Updated artist %s avatar to %s", id, avatar) + return nil +} + +func (s *MusicService) DeleteArtist(id string) error { + deletedID, err := s.repo.DeleteArtist(id) + if err != nil { return err } + if deletedID == "" { return errors.NewNotExistError("Artist does not exist") } + s.log.Printf("Deleted artist %s", id) + return nil +} diff --git a/service/music/artist_test.go b/service/music/artist_test.go new file mode 100644 index 0000000..1a59645 --- /dev/null +++ b/service/music/artist_test.go @@ -0,0 +1,225 @@ +package music_test + +import ( + "arimelody-web/errors" + "arimelody-web/model" + repository "arimelody-web/repository/music" + service "arimelody-web/service/music" + "log" + "os" + "slices" + "testing" + "time" + + "gotest.tools/v3/assert" +) + +func Test_Artist(t *testing.T) { + devNullFile, err := os.OpenFile(os.DevNull, os.O_RDWR, 0666) + if err != nil { panic(err) } + defer devNullFile.Close() + + repo := repository.NewMusicRepositoryMemory( + make([]*model.Artist, 0), + make([]*model.Release, 0), + make([]*model.Track, 0), + ) + s = service.NewMusicService( + repo, + log.New(devNullFile, "", model.DEFAULT_LOG_FLAGS), + ) + + id := "cool-artist" + name := "Cool Artist" + website := "artist.arimelody.space" + avatarURL := "/img/cool-artist.webp" + + t.Run("artists should start empty", func(t *testing.T) { + t.Run("count is zero", func(t *testing.T) { + if num, err := s.GetArtistCount(); err != nil { + t.Errorf("Failed to get number of artists: %v", err) + } else { + assert.Equal(t, num, 0) + } + }) + + t.Run("service returns empty array", func(t *testing.T) { + if artists, err := s.GetAllArtists(); err != nil { + t.Errorf("Failed to get artists: %v", err) + } else { + assert.Equal(t, len(artists), 0) + } + }) + }) + + t.Run("can create artist", func(t *testing.T) { + if err := s.CreateArtist(id, name, website, avatarURL); err != nil { + t.Errorf("Failed to create artist: %v", err) + } + + t.Run("but not with an invalid ID", func(t *testing.T) { + if err := s.CreateArtist("", name, website, avatarURL); err == nil { + t.Error("Created artist with invalid ID") + } + }) + t.Run("but not with an invalid name", func(t *testing.T) { + if err := s.CreateArtist("test-artist", "", website, avatarURL); err == nil { + t.Error("Created artist with invalid name") + } + }) + + t.Run("and retrieve it", func(t *testing.T) { + repoArtist, err := s.GetArtistByID(id) + if err != nil { t.Errorf("Failed to get artist: %v", err) } + + assert.Equal(t, repoArtist.ID, id) + assert.Equal(t, repoArtist.Name, name) + assert.Equal(t, repoArtist.Website, website) + assert.Equal(t, repoArtist.Avatar, avatarURL) + }) + }) + + t.Run("number of artists should increment", func(t *testing.T) { + t.Run("count is one", func(t *testing.T) { + if num, err := s.GetArtistCount(); err != nil { + t.Errorf("Failed to get number of artists: %v", err) + } else { + assert.Equal(t, num, 1) + } + }) + + t.Run("service returns array with one artist", func(t *testing.T) { + if artists, err := s.GetAllArtists(); err != nil { + t.Errorf("Failed to get artists: %v", err) + } else { + assert.Equal(t, len(artists), 1) + } + }) + }) + + releaseID := "test-release" + t.Run("can get artists not on a release", func(t *testing.T) { + if err := s.CreateRelease(releaseID, "test", string(model.Single), time.Now(), ""); err != nil { + t.Errorf("Failed to create release: %v", err) + } + + artists, err := s.GetArtistsNotOnRelease(releaseID) + if err != nil { t.Errorf("Failed to get artists: %v", err) } + + assert.Assert(t, slices.ContainsFunc(artists, func(artist *model.Artist) bool { + return artist.ID == id + })) + }) + + credit := &model.Credit{ + Artist: &model.Artist{ ID: id, Name: name, Website: website, Avatar: avatarURL }, + Role: "did a lot of stuff", + Primary: true, + } + t.Run("can get artist credits", func(t *testing.T) { + if err := s.UpdateReleaseCredits(releaseID, []*model.Credit{ credit }); err != nil { + t.Errorf("Failed to update release credits: %v", err) + } + + credits, err := s.GetArtistCredits(id, true) + if err != nil { t.Errorf("Failed to get credits: %v", err) } + + index := slices.IndexFunc(credits, func(credit *model.Credit) bool { + return credit.Artist.ID == id + }) + assert.Assert(t, index != -1) + assert.Equal(t, credits[index].Artist.ID, id) + assert.Equal(t, credits[index].Artist.Name, name) + assert.Equal(t, credits[index].Artist.Website, website) + assert.Equal(t, credits[index].Artist.Avatar, avatarURL) + assert.Equal(t, credits[index].Role, credit.Role) + assert.Equal(t, credits[index].Primary, credit.Primary) + }) + + t.Run("can update artist", func(t *testing.T) { + testName := "this name is only temporary" + testWebsite := "https://test.example.org" + testAvatar := "/img/test-avatar.webp" + if err := s.UpdateArtist(&model.Artist{ + ID: id, + Name: testName, + Website: testWebsite, + Avatar: testAvatar, + }); err != nil { + t.Errorf("Failed to update artist: %v", err) + } + + artist, err := s.GetArtistByID(id) + if err != nil { t.Errorf("Failed to get artist: %v", err) } + assert.Equal(t, artist.Name, testName) + assert.Equal(t, artist.Website, testWebsite) + assert.Equal(t, artist.GetAvatar(), testAvatar) + + t.Run("but not with invalid name", func(t *testing.T) { + if err := s.UpdateArtist(&model.Artist{ + ID: id, + Name: "", + }); err == nil { + t.Errorf("Updated artist name to invalid value") + } + }) + }) + + t.Run("can update artist name", func(t *testing.T) { + if err := s.UpdateArtistName(id, name); err != nil { + t.Errorf("Failed to update artist: %v", err) + } + + artist, err := s.GetArtistByID(id) + if err != nil { t.Errorf("Failed to get artist: %v", err) } + assert.Equal(t, artist.Name, name) + + t.Run("but not with invalid value", func(t *testing.T) { + if err := s.UpdateArtistName(id, ""); err == nil { + t.Errorf("Updated artist name to invalid value") + } + }) + }) + + t.Run("can update artist website", func(t *testing.T) { + if err := s.UpdateArtistWebsite(id, website); err != nil { + t.Errorf("Failed to update artist: %v", err) + } + + artist, err := s.GetArtistByID(id) + if err != nil { t.Errorf("Failed to get artist: %v", err) } + assert.Equal(t, artist.Website, website) + }) + + t.Run("can update artist avatar", func(t *testing.T) { + if err := s.UpdateArtistAvatar(id, ""); err != nil { + t.Errorf("Failed to update artist: %v", err) + } + + artist, err := s.GetArtistByID(id) + if err != nil { t.Errorf("Failed to get artist: %v", err) } + assert.Equal(t, artist.GetAvatar(), model.DEFAULT_AVATAR_URL) + }) + + t.Run("can delete artist", func(t *testing.T) { + if err := s.DeleteArtist(id); err != nil { + t.Errorf("Failed to delete artist: %v", err) + } + + t.Run("no longer exists", func(t *testing.T) { + if artist, err := s.GetArtistByID(id); err == nil { + t.Error("No error getting artist") + } else if artist != nil { + t.Error("Artist with this ID still exists") + } + }) + + t.Run("but not one that doesn't exist", func(t *testing.T) { + if err := s.DeleteArtist("some-garbage-id"); err == nil { + if !errors.IsNotExistError(err) { + t.Errorf("Failed to delete artist: %v", err) + } + } + }) + }) +} diff --git a/service/music/music.go b/service/music/music.go new file mode 100644 index 0000000..f3037cc --- /dev/null +++ b/service/music/music.go @@ -0,0 +1,18 @@ +package music + +import ( + repository "arimelody-web/repository/music" + "log" +) + +type MusicService struct { + repo repository.MusicRepository + log *log.Logger +} + +func NewMusicService(repo repository.MusicRepository, logger *log.Logger) *MusicService { + return &MusicService{ + repo: repo, + log: logger, + } +} diff --git a/service/music/music_test.go b/service/music/music_test.go new file mode 100644 index 0000000..72447a4 --- /dev/null +++ b/service/music/music_test.go @@ -0,0 +1,9 @@ +package music_test + +import ( + service "arimelody-web/service/music" +) + +var ( + s *service.MusicService +) diff --git a/service/music/release.go b/service/music/release.go new file mode 100644 index 0000000..494477a --- /dev/null +++ b/service/music/release.go @@ -0,0 +1,180 @@ +package music + +import ( + "arimelody-web/errors" + "arimelody-web/model" + "arimelody-web/service/validator" + "fmt" + "time" +) + +// Hydrates a release with its additional data (credits, tracks, links) +func (s *MusicService) fillRelease(release *model.Release) error { + credits, err := s.GetReleaseCredits(release.ID) + if err != nil { return fmt.Errorf("credits: %s", err) } + release.Credits = append(release.Credits, credits...) + + tracks, err := s.GetReleaseTracks(release.ID) + if err != nil { return fmt.Errorf("tracks: %s", err) } + release.Tracks = append(release.Tracks, tracks...) + + links, err := s.GetReleaseLinks(release.ID) + if err != nil { return fmt.Errorf("links: %s", err) } + release.Links = append(release.Links, links...) + + return nil +} + +func (s *MusicService) GetAllReleases(onlyVisible bool, limit int) ([]*model.Release, error) { + return s.repo.GetAllReleases(onlyVisible, limit) +} + +func (s *MusicService) GetAllFullReleases(onlyVisible bool, limit int) ([]*model.Release, error) { + releases, err := s.repo.GetAllReleases(onlyVisible, limit) + if err != nil { return nil, err } + for _, release := range releases { + if err := s.fillRelease(release); err != nil { return nil, err } + } + return releases, nil +} + +func (s *MusicService) GetReleaseCount(onlyVisible bool) (int, error) { + return s.repo.GetReleaseCount(onlyVisible) +} + +func (s *MusicService) GetReleaseByID(id string) (*model.Release, error) { + release, err := s.repo.GetReleaseByID(id) + if err != nil { return nil, err } + if release == nil { return nil, errors.NewNotExistError("Release does not exist") } + return release, nil +} + +func (s *MusicService) GetFullReleaseByID(id string) (*model.Release, error) { + release, err := s.GetReleaseByID(id) + if err != nil { return nil, err } + if err := s.fillRelease(release); err != nil { return nil, err } + return release, nil +} + +func (s *MusicService) GetReleaseTracks(id string) ([]*model.Track, error) { + return s.repo.GetReleaseTracks(id) +} +func (s *MusicService) GetReleaseCredits(id string) ([]*model.Credit, error) { + return s.repo.GetReleaseCredits(id) +} +func (s *MusicService) GetReleaseLinks(id string) ([]*model.Link, error) { + return s.repo.GetReleaseLinks(id) +} + +func (s *MusicService) CreateRelease( + id string, + title string, + releaseType string, + releaseDate time.Time, + artworkURL string, +) error { + if len(id) == 0 { return errors.NewValidationError("Release ID cannot be empty") } + if !validator.ValidateID(id) { return errors.NewValidationError("Release ID contains invalid characters") } + if len(title) == 0 { return errors.NewValidationError("Release title cannot be empty") } + validReleaseType, ok := model.ValidReleaseType(releaseType) + if !ok { return errors.NewValidationError("Invalid release type") } + + if err := s.repo.CreateRelease(id, title, validReleaseType, releaseDate, artworkURL); err != nil { return err } + s.log.Printf("Created new release '%s' (%s)", title, id) + return nil +} + +func (s *MusicService) UpdateRelease(release *model.Release) error { + if len(release.ID) == 0 { return errors.NewValidationError("Release ID cannot be empty") } + if !validator.ValidateID(release.ID) { return errors.NewValidationError("Release ID contains invalid characters") } + if len(release.Title) == 0 { return errors.NewValidationError("Release title cannot be empty") } + + if err := s.repo.UpdateRelease(release); err != nil { return err } + s.log.Printf("Updated release '%s' (%s)", release.Title, release.ID) + return nil +} +func (s *MusicService) UpdateReleaseID(oldID string, newID string) error { + if len(newID) == 0 { return errors.NewValidationError("Release ID cannot be empty") } + if !validator.ValidateID(newID) { return errors.NewValidationError("Release ID contains invalid characters") } + if err := s.repo.UpdateReleaseID(oldID, newID); err != nil { return err } + s.log.Printf("Updated release ID %s to %s", oldID, newID) + return nil +} +func (s *MusicService) UpdateReleaseVisibility(id string, visible bool) error { + if err := s.repo.UpdateReleaseVisibility(id, visible); err != nil { return err } + s.log.Printf("Updated release '%s' visibility to %t", id, visible) + return nil +} +func (s *MusicService) UpdateReleaseTitle(id string, title string) error { + if len(title) == 0 { return errors.NewValidationError("Release title cannot be empty") } + if err := s.repo.UpdateReleaseTitle(id, title); err != nil { return err } + s.log.Printf("Updated release '%s' title to %s", id, title) + return nil +} +func (s *MusicService) UpdateReleaseDescription(id string, description string) error { + if err := s.repo.UpdateReleaseDescription(id, description); err != nil { return err } + s.log.Printf("Updated release '%s' description to %s", id, description) + return nil +} +func (s *MusicService) UpdateReleaseType(id string, releaseType string) error { + validReleaseType, ok := model.ValidReleaseType(releaseType) + if !ok { return errors.NewValidationError("Invalid release type") } + if err := s.repo.UpdateReleaseType(id, validReleaseType); err != nil { return err } + s.log.Printf("Updated release '%s' type to %s", id, releaseType) + return nil +} +func (s *MusicService) UpdateReleaseDate(id string, releaseDate time.Time) error { + if err := s.repo.UpdateReleaseDate(id, releaseDate); err != nil { return err } + s.log.Printf("Updated release '%s' date to %s", id, releaseDate.Format(time.RFC3339)) + return nil +} +func (s *MusicService) UpdateReleaseArtwork(id string, artwork string) error { + if err := s.repo.UpdateReleaseArtwork(id, artwork); err != nil { return err } + s.log.Printf("Updated release '%s' artwork to %s", id, artwork) + return nil +} +func (s *MusicService) UpdateReleaseBuyInfo(id string, buyName string, buyLink string) error { + if err := s.repo.UpdateReleaseBuyInfo(id, buyName, buyLink); err != nil { return err } + s.log.Printf("Updated release '%s' buy info (name='%s', link='%s')", id, buyName, buyLink) + return nil +} +func (s *MusicService) UpdateReleaseCopyright(id string, copyright string, url string) error { + if err := s.repo.UpdateReleaseCopyright(id, copyright, url); err != nil { return err } + s.log.Printf("Updated release '%s' copyright (copyright='%s', url='%s')", id, copyright, url) + return nil +} + +func (s *MusicService) UpdateReleaseTracks(id string, newTrackIDs []string) error { + for _, id := range newTrackIDs { + if len(id) == 0 { return errors.NewValidationError("Track IDs cannot be empty") } + } + + if err := s.repo.UpdateReleaseTracks(id, newTrackIDs); err != nil { return err } + s.log.Printf("Updated release '%s' tracks (%d tracks)", id, len(newTrackIDs)) + return nil +} + +func (s *MusicService) UpdateReleaseCredits(id string, newCredits []*model.Credit) error { + if err := s.repo.UpdateReleaseCredits(id, newCredits); err != nil { return err } + s.log.Printf("Updated release '%s' credits (%d credits)", id, len(newCredits)) + return nil +} + +func (s *MusicService) UpdateReleaseLinks(id string, newLinks []*model.Link) error { + for _, link := range newLinks { + if len(link.Name) == 0 { return errors.NewValidationError("Link names cannot be empty") } + if len(link.URL) == 0 { return errors.NewValidationError("Link URLs cannot be empty") } + } + + if err := s.repo.UpdateReleaseLinks(id, newLinks); err != nil { return err } + s.log.Printf("Updated release '%s' links (%d links)", id, len(newLinks)) + return nil +} + +func (s *MusicService) DeleteRelease(id string) error { + deletedID, err := s.repo.DeleteRelease(id) + if err != nil { return err } + if deletedID == "" { return errors.NewNotExistError("Release does not exist") } + s.log.Printf("Deleted release '%s'", id) + return nil +} diff --git a/service/music/release_test.go b/service/music/release_test.go new file mode 100644 index 0000000..d14a697 --- /dev/null +++ b/service/music/release_test.go @@ -0,0 +1,374 @@ +package music_test + +import ( + "arimelody-web/errors" + "arimelody-web/model" + repository "arimelody-web/repository/music" + service "arimelody-web/service/music" + "log" + "os" + "slices" + "testing" + "time" + + "gotest.tools/v3/assert" +) + +func Test_Release(t *testing.T) { + devNullFile, err := os.OpenFile(os.DevNull, os.O_RDWR, 0666) + if err != nil { panic(err) } + defer devNullFile.Close() + + repo := repository.NewMusicRepositoryMemory( + make([]*model.Artist, 0), + make([]*model.Release, 0), + make([]*model.Track, 0), + ) + s = service.NewMusicService( + repo, + log.New(devNullFile, "", model.DEFAULT_LOG_FLAGS), + ) + + id := "cool-release" + title := "Cool Release" + releaseType := model.Album + releaseDate := time.Now() + artworkURL := "/img/some-cool-artwork.webp" + + t.Run("releases should start empty", func(t *testing.T) { + t.Run("count is zero", func(t *testing.T) { + if num, err := s.GetReleaseCount(false); err != nil { + t.Errorf("Failed to get number of releases: %v", err) + } else { + assert.Equal(t, num, 0) + } + }) + + t.Run("service returns empty array", func(t *testing.T) { + if releases, err := s.GetAllReleases(false, 0); err != nil { + t.Errorf("Failed to get releases: %v", err) + } else { + assert.Equal(t, len(releases), 0) + } + }) + }) + + t.Run("can create release", func(t *testing.T) { + if err := s.CreateRelease(id, title, string(releaseType), releaseDate, artworkURL); err != nil { + t.Errorf("Failed to create release: %v", err) + } + + t.Run("but not with an invalid ID", func(t *testing.T) { + if err := s.CreateRelease("", title, string(releaseType), releaseDate, artworkURL); err == nil { + t.Error("Created release with invalid ID") + } + }) + t.Run("but not with an invalid title", func(t *testing.T) { + if err := s.CreateRelease("test-release", "", string(releaseType), releaseDate, artworkURL); err == nil { + t.Error("Created release with invalid title") + } + }) + t.Run("but not with an invalid type", func(t *testing.T) { + if err := s.CreateRelease("test-release", "", "garbage-type", releaseDate, artworkURL); err == nil { + t.Error("Created release with invalid type") + } + }) + + t.Run("and retrieve it", func(t *testing.T) { + repoRelease, err := s.GetReleaseByID(id) + if err != nil { t.Errorf("Failed to get release: %v", err) } + if repoRelease == nil { t.Error("Release does not exist") } + + assert.Equal(t, repoRelease.ID, id) + assert.Equal(t, repoRelease.Title, title) + assert.Equal(t, repoRelease.ReleaseType, releaseType) + assert.Equal(t, repoRelease.ReleaseDate, releaseDate) + assert.Equal(t, repoRelease.GetArtwork(), artworkURL) + }) + + t.Run("should not be visible by default", func(t *testing.T) { + releases, err := s.GetAllReleases(true, 0) + if err != nil { t.Errorf("Failed to get releases: %v", err) } + + assert.Equal(t, slices.ContainsFunc(releases, func(release *model.Release) bool { + return release.ID == id + }), false) + }) + }) + + t.Run("number of releases should increment", func(t *testing.T) { + t.Run("count is one", func(t *testing.T) { + if num, err := s.GetReleaseCount(false); err != nil { + t.Errorf("Failed to get number of releases: %v", err) + } else { + assert.Equal(t, num, 1) + } + }) + + t.Run("service returns array with one release", func(t *testing.T) { + if releases, err := s.GetAllReleases(false, 0); err != nil { + t.Errorf("Failed to get releases: %v", err) + } else { + assert.Equal(t, len(releases), 1) + } + }) + }) + + t.Run("can update release", func(t *testing.T) { + testTitle := "this title is only temporary" + testReleaseType := model.Compilation + testReleaseDate := time.Now().AddDate(0, 0, 10) + testArtworkURL := "/img/test-artwork.webp" + if err := s.UpdateRelease(&model.Release{ + ID: id, + Title: testTitle, + ReleaseType: testReleaseType, + ReleaseDate: testReleaseDate, + Artwork: testArtworkURL, + }); err != nil { + t.Errorf("Failed to update release: %v", err) + } + + release, err := s.GetReleaseByID(id) + if err != nil { t.Errorf("Failed to get release: %v", err) } + if release == nil { t.Error("Release does not exist after update") } + assert.Equal(t, release.Title, testTitle) + assert.Equal(t, release.ReleaseType, testReleaseType) + assert.Equal(t, release.ReleaseDate, testReleaseDate) + assert.Equal(t, release.GetArtwork(), testArtworkURL) + + t.Run("but not with invalid title", func(t *testing.T) { + if err := s.UpdateRelease(&model.Release{ + ID: id, + Title: "", + }); err == nil { + t.Errorf("Updated release title to invalid value") + } + }) + }) + + t.Run("can update visibility", func(t *testing.T) { + if err := s.UpdateReleaseVisibility(id, true); err != nil { + t.Errorf("Failed to update release: %v", err) + } + + release, err := s.GetReleaseByID(id) + if err != nil { t.Errorf("Failed to get release: %v", err) } + if release == nil { t.Error("Release does not exist after update") } + assert.Equal(t, release.Visible, true) + + t.Run("should be visible after updating", func(t *testing.T) { + releases, err := s.GetAllReleases(true, 0) + if err != nil { t.Errorf("Failed to get releases: %v", err) } + + assert.Equal(t, slices.ContainsFunc(releases, func(release *model.Release) bool { + return release.ID == id + }), true) + }) + }) + + t.Run("can update title", func(t *testing.T) { + if err := s.UpdateReleaseTitle(id, title); err != nil { + t.Errorf("Failed to update release: %v", err) + } + + release, err := s.GetReleaseByID(id) + if err != nil { t.Errorf("Failed to get release: %v", err) } + if release == nil { t.Error("Release does not exist after update") } + assert.Equal(t, release.Title, title) + + t.Run("but not with invalid value", func(t *testing.T) { + if err := s.UpdateReleaseTitle(id, ""); err == nil { + t.Errorf("Updated release title to invalid value") + } + }) + }) + + t.Run("can update description", func(t *testing.T) { + testDescription := "an incredible and thought-provoking description" + if err := s.UpdateReleaseDescription(id, testDescription); err != nil { + t.Errorf("Failed to update release: %v", err) + } + + release, err := s.GetReleaseByID(id) + if err != nil { t.Errorf("Failed to get release: %v", err) } + if release == nil { t.Error("Release does not exist after update") } + assert.Equal(t, release.Description, testDescription) + }) + + t.Run("can update type", func(t *testing.T) { + testType := model.EP + if err := s.UpdateReleaseType(id, string(testType)); err != nil { + t.Errorf("Failed to update release: %v", err) + } + + release, err := s.GetReleaseByID(id) + if err != nil { t.Errorf("Failed to get release: %v", err) } + if release == nil { t.Error("Release does not exist after update") } + assert.Equal(t, release.ReleaseType, testType) + releaseType = testType + + t.Run("but not with invalid value", func(t *testing.T) { + if err := s.UpdateReleaseType(id, "garbage-type"); err == nil { + t.Errorf("Updated release type to invalid value") + } + }) + }) + + t.Run("can update date", func(t *testing.T) { + testDate := time.Now().Add(time.Hour * 24) + if err := s.UpdateReleaseDate(id, testDate); err != nil { + t.Errorf("Failed to update release: %v", err) + } + + release, err := s.GetReleaseByID(id) + if err != nil { t.Errorf("Failed to get release: %v", err) } + if release == nil { t.Error("Release does not exist after update") } + assert.Equal(t, release.ReleaseDate, testDate) + releaseDate = testDate + }) + + t.Run("can update artwork", func(t *testing.T) { + if err := s.UpdateReleaseArtwork(id, ""); err != nil { + t.Errorf("Failed to update release: %v", err) + } + + release, err := s.GetReleaseByID(id) + if err != nil { t.Errorf("Failed to get release: %v", err) } + if release == nil { t.Error("Release does not exist after update") } + assert.Equal(t, release.GetArtwork(), model.DEFAULT_RELEASE_ARTWORK_URL) + }) + + t.Run("can update buy info", func(t *testing.T) { + testBuyName := "get it now!!!" + testBuyLink := "https://arimelody.space/music" + if err := s.UpdateReleaseBuyInfo(id, testBuyName, testBuyLink); err != nil { + t.Errorf("Failed to update release: %v", err) + } + + release, err := s.GetReleaseByID(id) + if err != nil { t.Errorf("Failed to get release: %v", err) } + if release == nil { t.Error("Release does not exist after update") } + assert.Equal(t, release.Buyname, testBuyName) + assert.Equal(t, release.Buylink, testBuyLink) + }) + + t.Run("can update copyright info", func(t *testing.T) { + testCopyright := "CC BY-SA 4.0" + testCopyrightURL := "https://creativecommons.org/licenses/by-sa/4.0/" + if err := s.UpdateReleaseCopyright(id, testCopyright, testCopyrightURL); err != nil { + t.Errorf("Failed to update release: %v", err) + } + + release, err := s.GetReleaseByID(id) + if err != nil { t.Errorf("Failed to get release: %v", err) } + if release == nil { t.Error("Release does not exist after update") } + assert.Equal(t, release.Copyright, testCopyright) + assert.Equal(t, release.CopyrightURL, testCopyrightURL) + }) + + track := &model.Track{ + Title: "test track", + Description: "average description", + Lyrics: "some lyrics", + } + t.Run("can update tracks", func(t *testing.T) { + if trackID, err := s.CreateTrack(track.Title, track.Description, track.Lyrics, ""); err != nil { + t.Errorf("Failed to create track: %v", err) + } else { track.ID = trackID } + + if err := s.UpdateReleaseTracks(id, []string{ track.ID }); err != nil { + t.Errorf("Failed to update release tracks: %v", err) + } + + tracks, err := s.GetReleaseTracks(id) + if err != nil { t.Errorf("Failed to get tracks: %v", err) } + + index := slices.IndexFunc(tracks, func(repoTrack *model.Track) bool { + return repoTrack.ID == track.ID + }) + assert.Assert(t, index != -1) + assert.Equal(t, tracks[index].Title, track.Title) + assert.Equal(t, tracks[index].Description, track.Description) + assert.Equal(t, tracks[index].Lyrics, track.Lyrics) + }) + + t.Run("can update credits", func(t *testing.T) { + artist := &model.Artist{ ID: id, Name: title, Website: "", Avatar: "" } + if err := s.CreateArtist(artist.ID, artist.Name, artist.Website, artist.Avatar); err != nil { + t.Errorf("Failed to create artist: %v", err) + } + + credit := &model.Credit{ + Artist: artist, + Role: "did a lot of stuff", + Primary: true, + } + + if err := s.UpdateReleaseCredits(id, []*model.Credit{ credit }); err != nil { + t.Errorf("Failed to update release credits: %v", err) + } + + credits, err := s.GetReleaseCredits(id) + if err != nil { t.Errorf("Failed to get credits: %v", err) } + assert.Equal(t, len(credits), 1) + + index := slices.IndexFunc(credits, func(repoCredit *model.Credit) bool { + return repoCredit.Artist.ID == credit.Artist.ID + }) + assert.Assert(t, index != -1) + assert.Equal(t, credits[index].Release.ID, id) + assert.Equal(t, credits[index].Release.Title, title) + assert.Equal(t, credits[index].Release.ReleaseType, releaseType) + assert.Equal(t, credits[index].Release.ReleaseDate, releaseDate) + assert.Equal(t, credits[index].Release.GetArtwork(), model.DEFAULT_RELEASE_ARTWORK_URL) + assert.Equal(t, credits[index].Artist.ID, credit.Artist.ID) + assert.Equal(t, credits[index].Artist.Name, credit.Artist.Name) + assert.Equal(t, credits[index].Artist.Website, credit.Artist.Website) + assert.Equal(t, credits[index].Artist.GetAvatar(), credit.Artist.GetAvatar()) + assert.Equal(t, credits[index].Role, credit.Role) + assert.Equal(t, credits[index].Primary, credit.Primary) + }) + + t.Run("can update links", func(t *testing.T) { + link := &model.Link{ + Name: "awesome link you should totally go here", + URL: "https://arimelody.space", + } + + if err := s.UpdateReleaseLinks(id, []*model.Link{ link }); err != nil { + t.Errorf("Failed to update release tracks: %v", err) + } + + links, err := s.GetReleaseLinks(id) + if err != nil { t.Errorf("Failed to get tracks: %v", err) } + + index := slices.IndexFunc(links, func(repoLink *model.Link) bool { + return repoLink.Name == link.Name + }) + assert.Assert(t, index != -1) + assert.Equal(t, links[index].URL, link.URL) + }) + + t.Run("can delete release", func(t *testing.T) { + if err := s.DeleteRelease(id); err != nil { + t.Errorf("Failed to delete release: %v", err) + } + + t.Run("no longer exists", func(t *testing.T) { + if release, err := s.GetReleaseByID(id); err == nil { + t.Error("No error getting release") + } else if release != nil { + t.Error("Release with this ID still exists") + } + }) + + t.Run("but not one that doesn't exist", func(t *testing.T) { + if err := s.DeleteRelease("some-garbage-id"); err == nil { + if !errors.IsNotExistError(err) { + t.Errorf("Failed to delete release: %v", err) + } + } + }) + }) +} diff --git a/service/music/track.go b/service/music/track.go new file mode 100644 index 0000000..5a9e993 --- /dev/null +++ b/service/music/track.go @@ -0,0 +1,81 @@ +package music + +import ( + "arimelody-web/model" + "arimelody-web/errors" +) + + +func (s *MusicService) GetAllTracks() ([]*model.Track, error) { + return s.repo.GetAllTracks() +} +func (s *MusicService) GetTrackCount() (int, error) { + return s.repo.GetTrackCount() +} +func (s *MusicService) GetTrackByID(id string) (*model.Track, error) { + track, err := s.repo.GetTrackByID(id) + if err != nil { return nil, err } + if track == nil { return nil, errors.NewNotExistError("Track does not exist") } + return track, nil +} +func (s *MusicService) GetOrphanTracks() ([]*model.Track, error) { + return s.repo.GetOrphanTracks() +} +func (s *MusicService) GetTracksNotOnRelease(releaseID string) ([]*model.Track, error) { + return s.repo.GetTracksNotOnRelease(releaseID) +} +func (s *MusicService) GetTrackReleases(trackID string) ([]*model.Release, error) { + return s.repo.GetTrackReleases(trackID) +} +func (s *MusicService) GetTrackFullReleases(trackID string) ([]*model.Release, error) { + releases, err := s.GetTrackReleases(trackID) + if err != nil { return nil, err } + for _, release := range releases { + if err := s.fillRelease(release); err != nil { return nil, err } + } + return releases, nil +} + +func (s *MusicService) CreateTrack(title string, description string, lyrics string, previewURL string) (string, error) { + if len(title) == 0 { return "", errors.NewValidationError("Track title cannot be empty") } + id, err := s.repo.CreateTrack(title, description, lyrics, previewURL) + if err != nil { return "", err } + s.log.Printf("Created track '%s' (%s)", title, id) + return id, nil +} + +func (s *MusicService) UpdateTrack(track *model.Track) error { + if len(track.Title) == 0 { return errors.NewValidationError("Track title cannot be empty") } + if err := s.repo.UpdateTrack(track); err != nil { return err } + s.log.Printf("Updated track '%s' (%s)", track.Title, track.ID) + return nil +} +func (s *MusicService) UpdateTrackTitle(id string, title string) error { + if len(title) == 0 { return errors.NewValidationError("Track title cannot be empty") } + if err := s.repo.UpdateTrackTitle(id, title); err != nil { return err } + s.log.Printf("Updated track %s title to '%s'", id, title) + return nil +} +func (s *MusicService) UpdateTrackDescription(id string, description string) error { + if err := s.repo.UpdateTrackDescription(id, description); err != nil { return err } + s.log.Printf("Updated track %s description to '%s'", id, description) + return nil +} +func (s *MusicService) UpdateTrackLyrics(id string, lyrics string) error { + if err := s.repo.UpdateTrackLyrics(id, lyrics); err != nil { return err } + s.log.Printf("Updated track %s lyrics to '%s'", id, lyrics) + return nil +} +func (s *MusicService) UpdateTrackPreviewURL(id string, previewURL string) error { + if err := s.repo.UpdateTrackPreviewURL(id, previewURL); err != nil { return err } + s.log.Printf("Updated track %s preview URL to '%s'", id, previewURL) + return nil +} + +func (s *MusicService) DeleteTrack(id string) error { + deletedID, err := s.repo.DeleteTrack(id) + if err != nil { return err } + if deletedID == "" { return errors.NewNotExistError("Track does not exist") } + s.log.Printf("Deleted track %s", id) + return nil +} diff --git a/service/validator/validator.go b/service/validator/validator.go new file mode 100644 index 0000000..cb23857 --- /dev/null +++ b/service/validator/validator.go @@ -0,0 +1,8 @@ +package validator + +import "regexp" + +var idRegexp = regexp.MustCompile(`^[a-zA-Z0-9\-_\.]+$`) +func ValidateID(id string) bool { + return idRegexp.MatchString(id) +} diff --git a/service/validator/validator_test.go b/service/validator/validator_test.go new file mode 100644 index 0000000..0de2353 --- /dev/null +++ b/service/validator/validator_test.go @@ -0,0 +1,39 @@ +package validator_test + +import ( + "arimelody-web/service/validator" + "testing" + + "gotest.tools/v3/assert" +) + +func Test_ValidateID(t *testing.T) { + t.Run("accepts alphanumberic ID", func(t *testing.T) { + assert.Equal(t, validator.ValidateID("abcDEF123"), true) + }) + + t.Run("accepts hyphens", func(t *testing.T) { + assert.Equal(t, validator.ValidateID("a0-1b"), true) + }) + t.Run("accepts underscores", func(t *testing.T) { + assert.Equal(t, validator.ValidateID("a0_1b"), true) + }) + t.Run("accepts periods", func(t *testing.T) { + assert.Equal(t, validator.ValidateID("a0.1b"), true) + }) + + t.Run("rejects other characters", func(t *testing.T) { + t.Run("emoji", func(t *testing.T) { + assert.Equal(t, validator.ValidateID("🗣️🔥‼️"), false) + }) + t.Run("slashes", func(t *testing.T) { + assert.Equal(t, validator.ValidateID("this/could/be/really/bad/for/the/router"), false) + }) + t.Run("question marks", func(t *testing.T) { + assert.Equal(t, validator.ValidateID("query?these=nuts"), false) + }) + t.Run("hashes", func(t *testing.T) { + assert.Equal(t, validator.ValidateID("unnecessary#tagging"), false) + }) + }) +} diff --git a/view/music.go b/view/music.go index 9cb4e59..ed48c1b 100644 --- a/view/music.go +++ b/view/music.go @@ -22,7 +22,7 @@ func MusicHandler(app *app.AppState) http.Handler { return } - release, err := controller.GetRelease(app.DB, r.URL.Path[1:], true) + release, err := app.MusicService.GetFullReleaseByID(r.URL.Path[1:]) if err != nil { http.NotFound(w, r) return @@ -36,7 +36,7 @@ func MusicHandler(app *app.AppState) http.Handler { func ServeCatalog(app *app.AppState) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - releases, err := controller.GetAllReleases(app.DB, true, 0, true) + releases, err := app.MusicService.GetAllFullReleases(true, 0) if err != nil { fmt.Printf("WARN: Failed to pull releases for catalog: %s\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)