chore: initial public commit

This commit is contained in:
Gabe Farrell 2025-06-11 19:45:39 -04:00
commit fc9054b78c
250 changed files with 32809 additions and 0 deletions

270
engine/handlers/alias.go Normal file
View file

@ -0,0 +1,270 @@
package handlers
import (
"net/http"
"strconv"
"github.com/gabehf/koito/internal/db"
"github.com/gabehf/koito/internal/logger"
"github.com/gabehf/koito/internal/models"
"github.com/gabehf/koito/internal/utils"
)
// GetAliasesHandler retrieves all aliases for a given artist or album ID.
func GetAliasesHandler(store db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
l := logger.FromContext(ctx)
// Parse query parameters
artistIDStr := r.URL.Query().Get("artist_id")
albumIDStr := r.URL.Query().Get("album_id")
trackIDStr := r.URL.Query().Get("track_id")
if artistIDStr == "" && albumIDStr == "" && trackIDStr == "" {
utils.WriteError(w, "artist_id, album_id, or track_id must be provided", http.StatusBadRequest)
return
}
var aliases []models.Alias
if artistIDStr != "" {
artistID, err := strconv.Atoi(artistIDStr)
if err != nil {
utils.WriteError(w, "invalid artist_id", http.StatusBadRequest)
return
}
aliases, err = store.GetAllArtistAliases(ctx, int32(artistID))
if err != nil {
l.Err(err).Msg("Failed to get artist aliases")
utils.WriteError(w, "failed to retrieve aliases", http.StatusInternalServerError)
return
}
} else if albumIDStr != "" {
albumID, err := strconv.Atoi(albumIDStr)
if err != nil {
utils.WriteError(w, "invalid album_id", http.StatusBadRequest)
return
}
aliases, err = store.GetAllAlbumAliases(ctx, int32(albumID))
if err != nil {
l.Err(err).Msg("Failed to get artist aliases")
utils.WriteError(w, "failed to retrieve aliases", http.StatusInternalServerError)
return
}
} else if trackIDStr != "" {
trackID, err := strconv.Atoi(trackIDStr)
if err != nil {
utils.WriteError(w, "invalid track_id", http.StatusBadRequest)
return
}
aliases, err = store.GetAllTrackAliases(ctx, int32(trackID))
if err != nil {
l.Err(err).Msg("Failed to get artist aliases")
utils.WriteError(w, "failed to retrieve aliases", http.StatusInternalServerError)
return
}
}
utils.WriteJSON(w, http.StatusOK, aliases)
}
}
// DeleteAliasHandler deletes an alias for a given artist or album ID.
func DeleteAliasHandler(store db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
l := logger.FromContext(ctx)
// Parse query parameters
artistIDStr := r.URL.Query().Get("artist_id")
albumIDStr := r.URL.Query().Get("album_id")
trackIDStr := r.URL.Query().Get("track_id")
alias := r.URL.Query().Get("alias")
if alias == "" || (artistIDStr == "" && albumIDStr == "" && trackIDStr == "") {
utils.WriteError(w, "alias and artist_id, album_id, or track_id must be provided", http.StatusBadRequest)
return
}
if utils.MoreThanOneString(artistIDStr, albumIDStr, trackIDStr) {
utils.WriteError(w, "only one of artist_id, album_id, or track_id can be provided at a time", http.StatusBadRequest)
return
}
if artistIDStr != "" {
artistID, err := strconv.Atoi(artistIDStr)
if err != nil {
utils.WriteError(w, "invalid artist_id", http.StatusBadRequest)
return
}
err = store.DeleteArtistAlias(ctx, int32(artistID), alias)
if err != nil {
l.Err(err).Msg("Failed to delete alias")
utils.WriteError(w, "failed to delete alias", http.StatusInternalServerError)
return
}
} else if albumIDStr != "" {
albumID, err := strconv.Atoi(albumIDStr)
if err != nil {
utils.WriteError(w, "invalid album_id", http.StatusBadRequest)
return
}
err = store.DeleteAlbumAlias(ctx, int32(albumID), alias)
if err != nil {
l.Err(err).Msg("Failed to delete alias")
utils.WriteError(w, "failed to delete alias", http.StatusInternalServerError)
return
}
} else if trackIDStr != "" {
trackID, err := strconv.Atoi(trackIDStr)
if err != nil {
utils.WriteError(w, "invalid album_id", http.StatusBadRequest)
return
}
err = store.DeleteTrackAlias(ctx, int32(trackID), alias)
if err != nil {
l.Err(err).Msg("Failed to delete alias")
utils.WriteError(w, "failed to delete alias", http.StatusInternalServerError)
return
}
}
w.WriteHeader(http.StatusNoContent)
}
}
// CreateAliasHandler creates new aliases for a given artist, album, or track.
func CreateAliasHandler(store db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
l := logger.FromContext(ctx)
err := r.ParseForm()
if err != nil {
utils.WriteError(w, "invalid request body", http.StatusBadRequest)
return
}
artistIDStr := r.URL.Query().Get("artist_id")
albumIDStr := r.URL.Query().Get("album_id")
trackIDStr := r.URL.Query().Get("track_id")
if artistIDStr == "" && albumIDStr == "" && trackIDStr == "" {
utils.WriteError(w, "artist_id, album_id, or track_id must be provided", http.StatusBadRequest)
return
}
if utils.MoreThanOneString(artistIDStr, albumIDStr, trackIDStr) {
utils.WriteError(w, "only one of artist_id, album_id, or track_id can be provided at a time", http.StatusBadRequest)
return
}
alias := r.FormValue("alias")
if alias == "" {
utils.WriteError(w, "alias must be provided", http.StatusBadRequest)
return
}
if artistIDStr != "" {
artistID, err := strconv.Atoi(artistIDStr)
if err != nil {
utils.WriteError(w, "invalid artist_id", http.StatusBadRequest)
return
}
err = store.SaveArtistAliases(ctx, int32(artistID), []string{alias}, "Manual")
if err != nil {
l.Err(err).Msg("Failed to save alias")
utils.WriteError(w, "failed to save alias", http.StatusInternalServerError)
return
}
} else if albumIDStr != "" {
albumID, err := strconv.Atoi(albumIDStr)
if err != nil {
utils.WriteError(w, "invalid album_id", http.StatusBadRequest)
return
}
err = store.SaveAlbumAliases(ctx, int32(albumID), []string{alias}, "Manual")
if err != nil {
l.Err(err).Msg("Failed to save alias")
utils.WriteError(w, "failed to save alias", http.StatusInternalServerError)
return
}
} else if trackIDStr != "" {
trackID, err := strconv.Atoi(trackIDStr)
if err != nil {
utils.WriteError(w, "invalid track_id", http.StatusBadRequest)
return
}
err = store.SaveTrackAliases(ctx, int32(trackID), []string{alias}, "Manual")
if err != nil {
l.Err(err).Msg("Failed to save alias")
utils.WriteError(w, "failed to save alias", http.StatusInternalServerError)
return
}
}
w.WriteHeader(http.StatusCreated)
}
}
// sets the primary alias for albums, artists, and tracks
func SetPrimaryAliasHandler(store db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
l := logger.FromContext(ctx)
// Parse query parameters
artistIDStr := r.URL.Query().Get("artist_id")
albumIDStr := r.URL.Query().Get("album_id")
trackIDStr := r.URL.Query().Get("track_id")
alias := r.URL.Query().Get("alias")
if alias == "" || (artistIDStr == "" && albumIDStr == "" && trackIDStr == "") {
utils.WriteError(w, "alias and artist_id, album_id, or track_id must be provided", http.StatusBadRequest)
return
}
if utils.MoreThanOneString(artistIDStr, albumIDStr, trackIDStr) {
utils.WriteError(w, "only one of artist_id, album_id, or track_id can be provided", http.StatusBadRequest)
return
}
if artistIDStr != "" {
artistID, err := strconv.Atoi(artistIDStr)
if err != nil {
utils.WriteError(w, "invalid artist_id", http.StatusBadRequest)
return
}
err = store.SetPrimaryArtistAlias(ctx, int32(artistID), alias)
if err != nil {
l.Err(err).Msg("Failed to set primary alias")
utils.WriteError(w, "failed to set primary alias", http.StatusInternalServerError)
return
}
} else if albumIDStr != "" {
albumID, err := strconv.Atoi(albumIDStr)
if err != nil {
utils.WriteError(w, "invalid album_id", http.StatusBadRequest)
return
}
err = store.SetPrimaryAlbumAlias(ctx, int32(albumID), alias)
if err != nil {
l.Err(err).Msg("Failed to set primary alias")
utils.WriteError(w, "failed to set primary alias", http.StatusInternalServerError)
return
}
} else if trackIDStr != "" {
trackID, err := strconv.Atoi(trackIDStr)
if err != nil {
utils.WriteError(w, "invalid track_id", http.StatusBadRequest)
return
}
err = store.SetPrimaryTrackAlias(ctx, int32(trackID), alias)
if err != nil {
l.Err(err).Msg("Failed to set primary alias")
utils.WriteError(w, "failed to set primary alias", http.StatusInternalServerError)
return
}
}
w.WriteHeader(http.StatusNoContent)
}
}

153
engine/handlers/apikeys.go Normal file
View file

@ -0,0 +1,153 @@
package handlers
import (
"net/http"
"strconv"
"github.com/gabehf/koito/engine/middleware"
"github.com/gabehf/koito/internal/db"
"github.com/gabehf/koito/internal/logger"
"github.com/gabehf/koito/internal/utils"
)
func GenerateApiKeyHandler(store db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
l := logger.FromContext(ctx)
user := middleware.GetUserFromContext(ctx)
if user == nil {
utils.WriteError(w, "unauthorized", http.StatusUnauthorized)
return
}
r.ParseForm()
label := r.FormValue("label")
if label == "" {
utils.WriteError(w, "label is required", http.StatusBadRequest)
return
}
apiKey, err := utils.GenerateRandomString(48)
if err != nil {
l.Err(err).Msg("Failed to generate API key")
utils.WriteError(w, "failed to generate api key", http.StatusInternalServerError)
return
}
opts := db.SaveApiKeyOpts{
UserID: user.ID,
Key: apiKey,
Label: label,
}
l.Debug().Any("opts", opts).Send()
key, err := store.SaveApiKey(ctx, opts)
if err != nil {
l.Err(err).Msg("Failed to save API key")
utils.WriteError(w, "failed to save api key", http.StatusInternalServerError)
return
}
utils.WriteJSON(w, 201, key)
}
}
func DeleteApiKeyHandler(store db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
l := logger.FromContext(ctx)
user := middleware.GetUserFromContext(ctx)
if user == nil {
utils.WriteError(w, "unauthorized", http.StatusUnauthorized)
return
}
idStr := r.URL.Query().Get("id")
if idStr == "" {
utils.WriteError(w, "id is required", http.StatusBadRequest)
return
}
apiKey, err := strconv.Atoi(idStr)
if err != nil {
utils.WriteError(w, "id is invalid", http.StatusBadRequest)
return
}
err = store.DeleteApiKey(ctx, int32(apiKey))
if err != nil {
l.Err(err).Msg("Failed to delete API key")
utils.WriteError(w, "failed to delete api key", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
func GetApiKeysHandler(store db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
l := logger.FromContext(ctx)
l.Debug().Msgf("Retrieving user from middleware...")
user := middleware.GetUserFromContext(ctx)
if user == nil {
l.Debug().Msgf("Could not retrieve user from middleware")
utils.WriteError(w, "unauthorized", http.StatusUnauthorized)
return
}
l.Debug().Msgf("Retrieved user '%s' from middleware", user.Username)
apiKeys, err := store.GetApiKeysByUserID(ctx, user.ID)
if err != nil {
l.Err(err).Msg("Failed to retrieve API keys")
utils.WriteError(w, "failed to retrieve api keys", http.StatusInternalServerError)
return
}
utils.WriteJSON(w, http.StatusOK, apiKeys)
}
}
func UpdateApiKeyLabelHandler(store db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
l := logger.FromContext(ctx)
user := middleware.GetUserFromContext(ctx)
if user == nil {
utils.WriteError(w, "unauthorized", http.StatusUnauthorized)
return
}
idStr := r.URL.Query().Get("id")
if idStr == "" {
utils.WriteError(w, "id is required", http.StatusBadRequest)
return
}
apiKeyID, err := strconv.Atoi(idStr)
if err != nil {
utils.WriteError(w, "id is invalid", http.StatusBadRequest)
return
}
label := r.FormValue("label")
if label == "" {
utils.WriteError(w, "label is required", http.StatusBadRequest)
return
}
err = store.UpdateApiKeyLabel(ctx, db.UpdateApiKeyLabelOpts{
UserID: user.ID,
ID: int32(apiKeyID),
Label: label,
})
if err != nil {
l.Err(err).Msg("Failed to update API key label")
utils.WriteError(w, "failed to update api key label", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
}

149
engine/handlers/auth.go Normal file
View file

@ -0,0 +1,149 @@
package handlers
import (
"net/http"
"strings"
"time"
"github.com/gabehf/koito/engine/middleware"
"github.com/gabehf/koito/internal/db"
"github.com/gabehf/koito/internal/logger"
"github.com/gabehf/koito/internal/utils"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
func LoginHandler(store db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
l := logger.FromContext(r.Context())
ctx := r.Context()
l.Debug().Msg("Recieved login request")
r.ParseForm()
username := r.FormValue("username")
password := r.FormValue("password")
if username == "" || password == "" {
utils.WriteError(w, "username and password are required", http.StatusBadRequest)
return
}
user, err := store.GetUserByUsername(ctx, username)
if err != nil {
l.Err(err).Msg("Error searching for user in database")
utils.WriteError(w, "internal server error", http.StatusInternalServerError)
return
} else if user == nil {
utils.WriteError(w, "username or password is incorrect", http.StatusBadRequest)
return
}
err = bcrypt.CompareHashAndPassword(user.Password, []byte(password))
if err != nil {
utils.WriteError(w, "username or password is incorrect", http.StatusBadRequest)
return
}
keepSignedIn := false
expiresAt := time.Now().Add(1 * 24 * time.Hour)
if strings.ToLower(r.FormValue("remember_me")) == "true" {
keepSignedIn = true
expiresAt = time.Now().Add(30 * 24 * time.Hour)
}
session, err := store.SaveSession(ctx, user.ID, expiresAt, keepSignedIn)
if err != nil {
l.Err(err).Msg("Failed to create session")
utils.WriteError(w, "failed to create session", http.StatusInternalServerError)
return
}
cookie := &http.Cookie{
Name: "koito_session",
Value: session.ID.String(),
Path: "/",
HttpOnly: true,
Secure: false,
}
if keepSignedIn {
cookie.Expires = expiresAt
}
http.SetCookie(w, cookie)
w.WriteHeader(http.StatusNoContent)
}
}
func LogoutHandler(store db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
l := logger.FromContext(r.Context())
cookie, err := r.Cookie("koito_session")
if err == nil {
sid, err := uuid.Parse(cookie.Value)
if err != nil {
utils.WriteError(w, "session cookie is invalid", http.StatusUnauthorized)
return
}
err = store.DeleteSession(r.Context(), sid)
if err != nil {
l.Err(err).Msg("Failed to delete session")
utils.WriteError(w, "internal server error", http.StatusInternalServerError)
return
}
}
// Clear the cookie
http.SetCookie(w, &http.Cookie{
Name: "koito_session",
Value: "",
Path: "/",
HttpOnly: true,
MaxAge: -1, // expire immediately
})
w.WriteHeader(http.StatusNoContent)
}
}
func MeHandler(store db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
l := logger.FromContext(ctx)
u := middleware.GetUserFromContext(ctx)
if u == nil {
l.Debug().Msg("Invalid user retrieved from context")
}
utils.WriteJSON(w, 200, u)
}
}
func UpdateUserHandler(store db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
l := logger.FromContext(ctx)
u := middleware.GetUserFromContext(ctx)
if u == nil {
utils.WriteError(w, "unauthorized", http.StatusUnauthorized)
return
}
r.ParseForm()
username := r.FormValue("username")
password := r.FormValue("password")
l.Debug().Msgf("Recieved update request for user with id %d", u.ID)
err := store.UpdateUser(ctx, db.UpdateUserOpts{
ID: u.ID,
Username: username,
Password: password,
})
if err != nil {
l.Err(err).Msg("Failed to update user")
utils.WriteError(w, err.Error(), http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusNoContent)
}
}

137
engine/handlers/delete.go Normal file
View file

@ -0,0 +1,137 @@
package handlers
import (
"net/http"
"strconv"
"time"
"github.com/gabehf/koito/internal/db"
"github.com/gabehf/koito/internal/logger"
"github.com/gabehf/koito/internal/utils"
)
// DeleteTrackHandler deletes a track by its ID.
func DeleteTrackHandler(store db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
l := logger.FromContext(ctx)
trackIDStr := r.URL.Query().Get("id")
if trackIDStr == "" {
utils.WriteError(w, "track_id must be provided", http.StatusBadRequest)
return
}
trackID, err := strconv.Atoi(trackIDStr)
if err != nil {
utils.WriteError(w, "invalid id", http.StatusBadRequest)
return
}
err = store.DeleteTrack(ctx, int32(trackID))
if err != nil {
l.Err(err).Msg("Failed to delete track")
utils.WriteError(w, "failed to delete track", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// DeleteTrackHandler deletes a track by its ID.
func DeleteListenHandler(store db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
l := logger.FromContext(ctx)
trackIDStr := r.URL.Query().Get("track_id")
if trackIDStr == "" {
utils.WriteError(w, "track_id must be provided", http.StatusBadRequest)
return
}
trackID, err := strconv.Atoi(trackIDStr)
if err != nil {
utils.WriteError(w, "invalid id", http.StatusBadRequest)
return
}
unixStr := r.URL.Query().Get("unix")
if trackIDStr == "" {
utils.WriteError(w, "unix timestamp must be provided", http.StatusBadRequest)
return
}
unix, err := strconv.ParseInt(unixStr, 10, 64)
if err != nil {
utils.WriteError(w, "invalid unix timestamp", http.StatusBadRequest)
return
}
err = store.DeleteListen(ctx, int32(trackID), time.Unix(unix, 0))
if err != nil {
l.Err(err).Msg("Failed to delete listen")
utils.WriteError(w, "failed to delete listen", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// DeleteArtistHandler deletes an artist by its ID.
func DeleteArtistHandler(store db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
l := logger.FromContext(ctx)
artistIDStr := r.URL.Query().Get("id")
if artistIDStr == "" {
utils.WriteError(w, "id must be provided", http.StatusBadRequest)
return
}
artistID, err := strconv.Atoi(artistIDStr)
if err != nil {
utils.WriteError(w, "invalid id", http.StatusBadRequest)
return
}
err = store.DeleteArtist(ctx, int32(artistID))
if err != nil {
l.Err(err).Msg("Failed to delete artist")
utils.WriteError(w, "failed to delete artist", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// DeleteAlbumHandler deletes an album by its ID.
func DeleteAlbumHandler(store db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
l := logger.FromContext(ctx)
albumIDStr := r.URL.Query().Get("id")
if albumIDStr == "" {
utils.WriteError(w, "id must be provided", http.StatusBadRequest)
return
}
albumID, err := strconv.Atoi(albumIDStr)
if err != nil {
utils.WriteError(w, "invalid id", http.StatusBadRequest)
return
}
err = store.DeleteAlbum(ctx, int32(albumID))
if err != nil {
l.Err(err).Msg("Failed to delete album")
utils.WriteError(w, "failed to delete album", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
}

View file

@ -0,0 +1,28 @@
package handlers
import (
"net/http"
"strconv"
"github.com/gabehf/koito/internal/db"
"github.com/gabehf/koito/internal/utils"
)
func GetAlbumHandler(store db.DB) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
idStr := r.URL.Query().Get("id")
id, err := strconv.Atoi(idStr)
if err != nil {
utils.WriteError(w, "id is invalid", 400)
return
}
album, err := store.GetAlbum(r.Context(), db.GetAlbumOpts{ID: int32(id)})
if err != nil {
utils.WriteError(w, "album with specified id could not be found", http.StatusNotFound)
return
}
utils.WriteJSON(w, http.StatusOK, album)
}
}

View file

@ -0,0 +1,28 @@
package handlers
import (
"net/http"
"strconv"
"github.com/gabehf/koito/internal/db"
"github.com/gabehf/koito/internal/utils"
)
func GetArtistHandler(store db.DB) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
idStr := r.URL.Query().Get("id")
id, err := strconv.Atoi(idStr)
if err != nil {
utils.WriteError(w, "id is invalid", 400)
return
}
artist, err := store.GetArtist(r.Context(), db.GetArtistOpts{ID: int32(id)})
if err != nil {
utils.WriteError(w, "artist with specified id could not be found", http.StatusNotFound)
return
}
utils.WriteJSON(w, http.StatusOK, artist)
}
}

View file

@ -0,0 +1,65 @@
package handlers
import (
"net/http"
"strconv"
"strings"
"github.com/gabehf/koito/internal/db"
"github.com/gabehf/koito/internal/logger"
"github.com/gabehf/koito/internal/utils"
)
func GetListenActivityHandler(store db.DB) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
l := logger.FromContext(r.Context())
rangeStr := r.URL.Query().Get("range")
_range, _ := strconv.Atoi(rangeStr)
monthStr := r.URL.Query().Get("month")
month, _ := strconv.Atoi(monthStr)
yearStr := r.URL.Query().Get("year")
year, _ := strconv.Atoi(yearStr)
artistIdStr := r.URL.Query().Get("artist_id")
artistId, _ := strconv.Atoi(artistIdStr)
albumIdStr := r.URL.Query().Get("album_id")
albumId, _ := strconv.Atoi(albumIdStr)
trackIdStr := r.URL.Query().Get("track_id")
trackId, _ := strconv.Atoi(trackIdStr)
var step db.StepInterval
switch strings.ToLower(r.URL.Query().Get("step")) {
case "day":
step = db.StepDay
case "week":
step = db.StepWeek
case "month":
step = db.StepMonth
case "year":
step = db.StepYear
default:
l.Debug().Msgf("Using default value '%s' for step", db.StepDefault)
step = db.StepDay
}
opts := db.ListenActivityOpts{
Step: step,
Range: _range,
Month: month,
Year: year,
AlbumID: int32(albumId),
ArtistID: int32(artistId),
TrackID: int32(trackId),
}
activity, err := store.GetListenActivity(r.Context(), opts)
if err != nil {
l.Err(err).Send()
utils.WriteError(w, err.Error(), 500)
return
}
utils.WriteJSON(w, http.StatusOK, activity)
}
}

View file

@ -0,0 +1,23 @@
package handlers
import (
"net/http"
"github.com/gabehf/koito/internal/db"
"github.com/gabehf/koito/internal/logger"
"github.com/gabehf/koito/internal/utils"
)
func GetListensHandler(store db.DB) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
l := logger.FromContext(r.Context())
opts := OptsFromRequest(r)
listens, err := store.GetListensPaginated(r.Context(), opts)
if err != nil {
l.Err(err).Send()
utils.WriteError(w, "failed to get listens: "+err.Error(), 400)
return
}
utils.WriteJSON(w, http.StatusOK, listens)
}
}

View file

@ -0,0 +1,23 @@
package handlers
import (
"net/http"
"github.com/gabehf/koito/internal/db"
"github.com/gabehf/koito/internal/logger"
"github.com/gabehf/koito/internal/utils"
)
func GetTopAlbumsHandler(store db.DB) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
l := logger.FromContext(r.Context())
opts := OptsFromRequest(r)
albums, err := store.GetTopAlbumsPaginated(r.Context(), opts)
if err != nil {
l.Err(err).Msg("Failed to get top albums")
utils.WriteError(w, "failed to get albums", 400)
return
}
utils.WriteJSON(w, http.StatusOK, albums)
}
}

View file

@ -0,0 +1,23 @@
package handlers
import (
"net/http"
"github.com/gabehf/koito/internal/db"
"github.com/gabehf/koito/internal/logger"
"github.com/gabehf/koito/internal/utils"
)
func GetTopArtistsHandler(store db.DB) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
l := logger.FromContext(r.Context())
opts := OptsFromRequest(r)
artists, err := store.GetTopArtistsPaginated(r.Context(), opts)
if err != nil {
l.Err(err).Msg("Failed to get top artists")
utils.WriteError(w, "failed to get artists", 400)
return
}
utils.WriteJSON(w, http.StatusOK, artists)
}
}

View file

@ -0,0 +1,23 @@
package handlers
import (
"net/http"
"github.com/gabehf/koito/internal/db"
"github.com/gabehf/koito/internal/logger"
"github.com/gabehf/koito/internal/utils"
)
func GetTopTracksHandler(store db.DB) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
l := logger.FromContext(r.Context())
opts := OptsFromRequest(r)
tracks, err := store.GetTopTracksPaginated(r.Context(), opts)
if err != nil {
l.Err(err).Msg("Failed to get top tracks")
utils.WriteError(w, "failed to get tracks", 400)
return
}
utils.WriteJSON(w, http.StatusOK, tracks)
}
}

View file

@ -0,0 +1,31 @@
package handlers
import (
"net/http"
"strconv"
"github.com/gabehf/koito/internal/db"
"github.com/gabehf/koito/internal/logger"
"github.com/gabehf/koito/internal/utils"
)
func GetTrackHandler(store db.DB) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
l := logger.FromContext(r.Context())
idStr := r.URL.Query().Get("id")
id, err := strconv.Atoi(idStr)
if err != nil {
utils.WriteError(w, "id is invalid", 400)
return
}
track, err := store.GetTrack(r.Context(), db.GetTrackOpts{ID: int32(id)})
if err != nil {
l.Err(err).Msg("Failed to get top albums")
utils.WriteError(w, "track with specified id could not be found", http.StatusNotFound)
return
}
utils.WriteJSON(w, http.StatusOK, track)
}
}

View file

@ -0,0 +1,77 @@
// package handlers implements route handlers
package handlers
import (
"net/http"
"strconv"
"strings"
"github.com/gabehf/koito/internal/db"
"github.com/gabehf/koito/internal/logger"
)
const defaultLimitSize = 100
const maximumLimit = 500
func OptsFromRequest(r *http.Request) db.GetItemsOpts {
l := logger.FromContext(r.Context())
limitStr := r.URL.Query().Get("limit")
limit, err := strconv.Atoi(limitStr)
if err != nil {
l.Debug().Msgf("query parameter 'limit' not specified, using default %d", defaultLimitSize)
limit = defaultLimitSize
}
if limit > maximumLimit {
l.Debug().Msgf("limit must not be greater than %d, using default %d", maximumLimit, defaultLimitSize)
limit = defaultLimitSize
}
pageStr := r.URL.Query().Get("page")
page, _ := strconv.Atoi(pageStr)
if page < 1 {
page = 1
}
weekStr := r.URL.Query().Get("week")
week, _ := strconv.Atoi(weekStr)
monthStr := r.URL.Query().Get("month")
month, _ := strconv.Atoi(monthStr)
yearStr := r.URL.Query().Get("year")
year, _ := strconv.Atoi(yearStr)
artistIdStr := r.URL.Query().Get("artist_id")
artistId, _ := strconv.Atoi(artistIdStr)
albumIdStr := r.URL.Query().Get("album_id")
albumId, _ := strconv.Atoi(albumIdStr)
trackIdStr := r.URL.Query().Get("track_id")
trackId, _ := strconv.Atoi(trackIdStr)
var period db.Period
switch strings.ToLower(r.URL.Query().Get("period")) {
case "day":
period = db.PeriodDay
case "week":
period = db.PeriodWeek
case "month":
period = db.PeriodMonth
case "year":
period = db.PeriodYear
case "all_time":
period = db.PeriodAllTime
default:
l.Debug().Msgf("Using default value '%s' for period", db.PeriodDay)
period = db.PeriodDay
}
return db.GetItemsOpts{
Limit: limit,
Period: period,
Page: page,
Week: week,
Month: month,
Year: year,
ArtistID: artistId,
AlbumID: albumId,
TrackID: trackId,
}
}

10
engine/handlers/health.go Normal file
View file

@ -0,0 +1,10 @@
package handlers
import "net/http"
func HealthHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"ready"}`))
}
}

View file

@ -0,0 +1,208 @@
package handlers
import (
"bytes"
"net/http"
"os"
"path"
"path/filepath"
"sync"
"github.com/gabehf/koito/internal/catalog"
"github.com/gabehf/koito/internal/cfg"
"github.com/gabehf/koito/internal/db"
"github.com/gabehf/koito/internal/logger"
"github.com/gabehf/koito/internal/utils"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
func ImageHandler(store db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
l := logger.FromContext(r.Context())
size := chi.URLParam(r, "size")
filename := chi.URLParam(r, "filename")
imageSize, err := catalog.ParseImageSize(size)
if err != nil {
w.WriteHeader(http.StatusNotFound)
return
}
imgid, err := uuid.Parse(filename)
if err != nil {
serveDefaultImage(w, r, imageSize)
return
}
desiredImgPath := filepath.Join(cfg.ConfigDir(), catalog.ImageCacheDir, size, filepath.Clean(filename))
if _, err := os.Stat(desiredImgPath); os.IsNotExist(err) {
l.Debug().Msg("Image not found in desired size")
// file doesn't exist in desired size
fullSizePath := filepath.Join(cfg.ConfigDir(), catalog.ImageCacheDir, string(catalog.ImageSizeFull), filepath.Clean(filename))
largeSizePath := filepath.Join(cfg.ConfigDir(), catalog.ImageCacheDir, string(catalog.ImageSizeLarge), filepath.Clean(filename))
// check if file exists at either full or large size
// note: have to check both in case a user switched caching full size on and off
// which would result in cache misses from source changing
var sourcePath string
if _, err = os.Stat(fullSizePath); os.IsNotExist(err) {
if _, err = os.Stat(largeSizePath); os.IsNotExist(err) {
l.Warn().Msgf("Could not find requested image %s. If this image is tied to an album or artist, it should be replaced", imgid.String())
serveDefaultImage(w, r, imageSize)
return
} else if err != nil {
// non-not found error for full file
l.Err(err).Msg("Failed to access source image file")
w.WriteHeader(http.StatusInternalServerError)
return
}
sourcePath = largeSizePath
} else if err != nil {
// non-not found error for full file
l.Err(err).Msg("Failed to access source image file")
w.WriteHeader(http.StatusInternalServerError)
return
} else {
sourcePath = fullSizePath
}
// source size file was found
// create and cache image at desired size
imageBuf, err := os.ReadFile(sourcePath)
if err != nil {
l.Err(err).Msg("Failed to read source image file")
w.WriteHeader(http.StatusInternalServerError)
return
}
err = catalog.CompressAndSaveImage(r.Context(), imgid.String(), imageSize, bytes.NewReader(imageBuf))
if err != nil {
l.Err(err).Msg("Failed to save compressed image to cache")
}
} else if err != nil {
// non-not found error for desired file
l.Err(err).Msg("Failed to access desired image file")
w.WriteHeader(http.StatusInternalServerError)
return
}
// Serve image
http.ServeFile(w, r, desiredImgPath)
}
}
func serveDefaultImage(w http.ResponseWriter, r *http.Request, size catalog.ImageSize) {
var lock sync.Mutex
l := logger.FromContext(r.Context())
defaultImagePath := filepath.Join(cfg.ConfigDir(), catalog.ImageCacheDir, string(size), "default_img")
if _, err := os.Stat(defaultImagePath); os.IsNotExist(err) {
l.Debug().Msg("Default image does not exist in cache at desired size")
defaultImagePath := filepath.Join(catalog.SourceImageDir(), "default_img")
if _, err = os.Stat(defaultImagePath); os.IsNotExist(err) {
l.Debug().Msg("Default image does not exist in cache, attempting to move...")
err = os.MkdirAll(filepath.Dir(defaultImagePath), 0755)
if err != nil {
l.Err(err).Msg("Error when attempting to create image_cache/full dir")
w.WriteHeader(http.StatusInternalServerError)
return
}
lock.Lock()
utils.CopyFile(path.Join("assets", "default_img"), defaultImagePath)
lock.Unlock()
} else if err != nil {
// non-not found error
l.Error().Err(err).Msg("Error when attempting to read default image in cache")
w.WriteHeader(http.StatusInternalServerError)
return
}
// default_img does (or now does) exist in cache at full size
file, err := os.Open(path.Join(catalog.SourceImageDir(), "default_img"))
if err != nil {
l.Err(err).Msg("Error when reading default image from source dir")
w.WriteHeader(http.StatusInternalServerError)
return
}
err = catalog.CompressAndSaveImage(r.Context(), "default_img", size, file)
if err != nil {
l.Err(err).Msg("Error when caching default img at desired size")
w.WriteHeader(http.StatusInternalServerError)
return
}
} else if err != nil {
// non-not found error
l.Error().Err(err).Msg("Error when attempting to read default image in cache")
w.WriteHeader(http.StatusInternalServerError)
return
}
// serve default_img at desired size
http.ServeFile(w, r, path.Join(cfg.ConfigDir(), catalog.ImageCacheDir, string(size), "default_img"))
}
// func SearchMissingAlbumImagesHandler(store db.DB) http.HandlerFunc {
// return func(w http.ResponseWriter, r *http.Request) {
// ctx := r.Context()
// l := logger.FromContext(ctx)
// l.Info().Msg("Beginning search for albums with missing images")
// go func() {
// defer func() {
// if r := recover(); r != nil {
// l.Error().Interface("recover", r).Msg("Panic when searching for missing album images")
// }
// }()
// ctx := logger.NewContext(l)
// from := int32(0)
// count := 0
// for {
// albums, err := store.AlbumsWithoutImages(ctx, from)
// if errors.Is(err, pgx.ErrNoRows) {
// break
// } else if err != nil {
// l.Err(err).Msg("Failed to search for missing images")
// return
// }
// l.Debug().Msgf("Queried %d albums on page %d", len(albums), from)
// if len(albums) < 1 {
// break
// }
// for _, a := range albums {
// l.Debug().Msgf("Searching images for album %s", a.Title)
// img, err := imagesrc.GetAlbumImages(ctx, imagesrc.AlbumImageOpts{
// Artists: utils.FlattenSimpleArtistNames(a.Artists),
// Album: a.Title,
// ReleaseMbzID: a.MbzID,
// })
// if err == nil && img != "" {
// l.Debug().Msg("Image found! Downloading...")
// imgid, err := catalog.DownloadAndCacheImage(ctx, img)
// if err != nil {
// l.Err(err).Msgf("Failed to download image for %s", a.Title)
// continue
// }
// err = store.UpdateAlbum(ctx, db.UpdateAlbumOpts{
// ID: a.ID,
// Image: imgid,
// })
// if err != nil {
// l.Err(err).Msgf("Failed to update image for %s", a.Title)
// continue
// }
// l.Info().Msgf("Found new album image for %s", a.Title)
// count++
// }
// if err != nil {
// l.Err(err).Msgf("Failed to get album images for %s", a.Title)
// }
// }
// from = albums[len(albums)-1].ID
// }
// l.Info().Msgf("Completed search, finding %d new images", count)
// }()
// w.WriteHeader(http.StatusOK)
// }
// }

View file

@ -0,0 +1,278 @@
package handlers
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"github.com/gabehf/koito/engine/middleware"
"github.com/gabehf/koito/internal/catalog"
"github.com/gabehf/koito/internal/cfg"
"github.com/gabehf/koito/internal/db"
"github.com/gabehf/koito/internal/logger"
mbz "github.com/gabehf/koito/internal/mbz"
"github.com/gabehf/koito/internal/utils"
"github.com/google/uuid"
"github.com/rs/zerolog"
"golang.org/x/sync/singleflight"
)
type LbzListenType string
const (
ListenTypeSingle LbzListenType = "single"
ListenTypePlayingNow LbzListenType = "playing_now"
ListenTypeImport LbzListenType = "import"
)
type LbzSubmitListenRequest struct {
ListenType LbzListenType `json:"listen_type,omitempty"`
Payload []LbzSubmitListenPayload `json:"payload,omitempty"`
}
type LbzSubmitListenPayload struct {
ListenedAt int64 `json:"listened_at,omitempty"`
TrackMeta LbzTrackMeta `json:"track_metadata"`
}
type LbzTrackMeta struct {
ArtistName string `json:"artist_name"` // required
TrackName string `json:"track_name"` // required
ReleaseName string `json:"release_name,omitempty"`
AdditionalInfo LbzAdditionalInfo `json:"additional_info,omitempty"`
}
type LbzAdditionalInfo struct {
MediaPlayer string `json:"media_player,omitempty"`
SubmissionClient string `json:"submission_client,omitempty"`
SubmissionClientVersion string `json:"submission_client_version,omitempty"`
ReleaseMBID string `json:"release_mbid,omitempty"`
ReleaseGroupMBID string `json:"release_group_mbid,omitempty"`
ArtistMBIDs []string `json:"artist_mbids,omitempty"`
ArtistNames []string `json:"artist_names,omitempty"`
RecordingMBID string `json:"recording_mbid,omitempty"`
DurationMs int32 `json:"duration_ms,omitempty"`
Duration int32 `json:"duration,omitempty"`
Tags []string `json:"tags,omitempty"`
AlbumArtist string `json:"albumartist,omitempty"`
}
const (
maxListensPerRequest = 1000
)
var sfGroup singleflight.Group
func LbzSubmitListenHandler(store db.DB, mbzc mbz.MusicBrainzCaller) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
l := logger.FromContext(r.Context())
var req LbzSubmitListenRequest
requestBytes, err := io.ReadAll(r.Body)
if err != nil {
utils.WriteError(w, "failed to read request body", http.StatusBadRequest)
return
}
if err := json.NewDecoder(bytes.NewBuffer(requestBytes)).Decode(&req); err != nil {
l.Debug().Err(err).Msg("Failed to decode request")
utils.WriteError(w, "failed to decode request", http.StatusBadRequest)
return
}
u := middleware.GetUserFromContext(r.Context())
if u == nil {
utils.WriteError(w, "unauthorized", http.StatusUnauthorized)
return
}
l.Debug().Any("request_body", req).Msg("Recieved request")
if len(req.Payload) < 1 {
l.Error().Msg("Payload is nil")
utils.WriteError(w, "payload is nil", http.StatusBadRequest)
return
}
if len(req.Payload) > maxListensPerRequest {
l.Error().Msg("Payload exceeds max listens per request")
utils.WriteError(w, "payload exceeds max listens per request", http.StatusBadRequest)
return
}
if len(req.Payload) != 1 && req.ListenType != "import" {
l.Error().Msg("Payload must only contain one listen for non-import requests")
utils.WriteError(w, "payload must only contain one listen for non-import requests", http.StatusBadRequest)
return
}
for _, payload := range req.Payload {
if payload.TrackMeta.ArtistName == "" || payload.TrackMeta.TrackName == "" {
l.Error().Msg("Artist name or track name are missing, unable to process listen")
utils.WriteError(w, "Artist name or track name are missing", http.StatusBadRequest)
return
}
if req.ListenType != ListenTypePlayingNow && req.ListenType != ListenTypeSingle && req.ListenType != ListenTypeImport {
l.Debug().Msg("No listen type provided, assuming 'single'")
req.ListenType = "single"
}
artistMbzIDs, err := utils.ParseUUIDSlice(payload.TrackMeta.AdditionalInfo.ArtistMBIDs)
if err != nil {
l.Debug().Err(err).Msg("Failed to parse one or more uuids")
}
rgMbzID, err := uuid.Parse(payload.TrackMeta.AdditionalInfo.ReleaseGroupMBID)
if err != nil {
rgMbzID = uuid.Nil
}
releaseMbzID, err := uuid.Parse(payload.TrackMeta.AdditionalInfo.ReleaseMBID)
if err != nil {
releaseMbzID = uuid.Nil
}
recordingMbzID, err := uuid.Parse(payload.TrackMeta.AdditionalInfo.RecordingMBID)
if err != nil {
recordingMbzID = uuid.Nil
}
var client string
if payload.TrackMeta.AdditionalInfo.MediaPlayer != "" {
client = payload.TrackMeta.AdditionalInfo.MediaPlayer
} else if payload.TrackMeta.AdditionalInfo.SubmissionClient != "" {
client = payload.TrackMeta.AdditionalInfo.SubmissionClient
}
var duration int32
if payload.TrackMeta.AdditionalInfo.Duration != 0 {
duration = payload.TrackMeta.AdditionalInfo.Duration
} else if payload.TrackMeta.AdditionalInfo.DurationMs != 0 {
duration = payload.TrackMeta.AdditionalInfo.DurationMs / 1000
}
var listenedAt = time.Now()
if payload.ListenedAt != 0 {
listenedAt = time.Unix(payload.ListenedAt, 0)
}
opts := catalog.SubmitListenOpts{
MbzCaller: mbzc,
ArtistNames: payload.TrackMeta.AdditionalInfo.ArtistNames,
Artist: payload.TrackMeta.ArtistName,
ArtistMbzIDs: artistMbzIDs,
TrackTitle: payload.TrackMeta.TrackName,
RecordingMbzID: recordingMbzID,
ReleaseTitle: payload.TrackMeta.ReleaseName,
ReleaseMbzID: releaseMbzID,
ReleaseGroupMbzID: rgMbzID,
Duration: duration,
Time: listenedAt,
UserID: u.ID,
Client: client,
}
if req.ListenType == ListenTypePlayingNow {
opts.SkipSaveListen = true
}
_, err, shared := sfGroup.Do(buildCaolescingKey(payload), func() (interface{}, error) {
return 0, catalog.SubmitListen(r.Context(), store, opts)
})
if shared {
l.Info().Msg("Duplicate requests detected; results were coalesced")
}
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Header().Set("Content-Type", "application/json")
w.Write([]byte("{\"status\": \"internal server error\"}"))
}
}
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "application/json")
w.Write([]byte("{\"status\": \"ok\"}"))
if cfg.LbzRelayEnabled() {
go doLbzRelay(requestBytes, l)
}
}
}
func doLbzRelay(requestBytes []byte, l *zerolog.Logger) {
defer func() {
if r := recover(); r != nil {
l.Error().Interface("recover", r).Msg("Panic in doLbzRelay")
}
}()
const (
maxRetryDuration = 10 * time.Second
initialBackoff = 1 * time.Second
maxBackoff = 4 * time.Second
)
req, err := http.NewRequest("POST", cfg.LbzRelayUrl()+"/submit-listens", bytes.NewBuffer(requestBytes))
if err != nil {
l.Error().Msg("Failed to build ListenBrainz relay request")
l.Error().Err(err).Send()
return
}
req.Header.Add("Authorization", "Token "+cfg.LbzRelayToken())
req.Header.Add("Content-Type", "application/json")
client := &http.Client{
Timeout: 5 * time.Second,
}
var resp *http.Response
var body []byte
start := time.Now()
backoff := initialBackoff
for {
resp, err = client.Do(req)
if err != nil {
l.Err(err).Msg("Failed to send ListenBrainz relay request")
return
}
defer resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
l.Info().Msg("Successfully relayed ListenBrainz submission")
return
}
body, _ = io.ReadAll(resp.Body)
if resp.StatusCode >= 500 && time.Since(start)+backoff <= maxRetryDuration {
l.Warn().
Int("status", resp.StatusCode).
Str("response", string(body)).
Msg("Retryable server error from ListenBrainz relay, retrying...")
time.Sleep(backoff)
backoff *= 2
if backoff > maxBackoff {
backoff = maxBackoff
}
continue
}
// 4xx status or timeout exceeded
l.Warn().
Int("status", resp.StatusCode).
Str("response", string(body)).
Msg("Non-2XX response from ListenBrainz relay")
return
}
}
func buildCaolescingKey(p LbzSubmitListenPayload) string {
// the key not including the listen_type introduces the very rare possibility of a playing_now
// request taking precedence over a single, meaning that a listen will not be logged when it
// should, however that would require a playing_now request to fire a few seconds before a 'single'
// of the same track, which should never happen outside of misbehaving clients
//
// this could be fixed by restructuring the database inserts for idempotency, which would
// eliminate the need to coalesce responses, however i'm not gonna do that right now
return fmt.Sprintf("%s:%s:%s", p.TrackMeta.ArtistName, p.TrackMeta.TrackName, p.TrackMeta.ReleaseName)
}

View file

@ -0,0 +1,41 @@
package handlers
import (
"net/http"
"github.com/gabehf/koito/engine/middleware"
"github.com/gabehf/koito/internal/db"
"github.com/gabehf/koito/internal/logger"
"github.com/gabehf/koito/internal/utils"
)
type LbzValidateResponse struct {
Code int `json:"code"`
Error string `json:"error,omitempty"`
Message string `json:"message,omitempty"`
Valid bool `json:"valid,omitempty"`
UserName string `json:"user_name,omitempty"`
}
func LbzValidateTokenHandler(store db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
l := logger.FromContext(ctx)
l.Debug().Msg("Validating user token...")
u := middleware.GetUserFromContext(ctx)
var response LbzValidateResponse
if u == nil {
response.Code = http.StatusUnauthorized
response.Error = "Incorrect Authorization"
w.WriteHeader(http.StatusUnauthorized)
utils.WriteJSON(w, http.StatusOK, response)
} else {
response.Code = 200
response.Message = "Token valid."
response.Valid = true
response.UserName = u.Username
utils.WriteJSON(w, http.StatusOK, response)
}
}
}

97
engine/handlers/merge.go Normal file
View file

@ -0,0 +1,97 @@
package handlers
import (
"net/http"
"strconv"
"github.com/gabehf/koito/internal/db"
"github.com/gabehf/koito/internal/logger"
"github.com/gabehf/koito/internal/utils"
)
func MergeTracksHandler(store db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
l := logger.FromContext(r.Context())
fromidStr := r.URL.Query().Get("from_id")
fromId, err := strconv.Atoi(fromidStr)
if err != nil {
l.Err(err).Send()
utils.WriteError(w, "from_id is invalid", 400)
return
}
toidStr := r.URL.Query().Get("to_id")
toId, err := strconv.Atoi(toidStr)
if err != nil {
l.Err(err).Send()
utils.WriteError(w, "to_id is invalid", 400)
return
}
err = store.MergeTracks(r.Context(), int32(fromId), int32(toId))
if err != nil {
l.Err(err).Send()
utils.WriteError(w, "Failed to merge tracks: "+err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
func MergeReleaseGroupsHandler(store db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
l := logger.FromContext(r.Context())
fromidStr := r.URL.Query().Get("from_id")
fromId, err := strconv.Atoi(fromidStr)
if err != nil {
l.Err(err).Send()
utils.WriteError(w, "from_id is invalid", 400)
return
}
toidStr := r.URL.Query().Get("to_id")
toId, err := strconv.Atoi(toidStr)
if err != nil {
l.Err(err).Send()
utils.WriteError(w, "to_id is invalid", 400)
return
}
err = store.MergeAlbums(r.Context(), int32(fromId), int32(toId))
if err != nil {
l.Err(err).Send()
utils.WriteError(w, "Failed to merge albums: "+err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
func MergeArtistsHandler(store db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
l := logger.FromContext(r.Context())
fromidStr := r.URL.Query().Get("from_id")
fromId, err := strconv.Atoi(fromidStr)
if err != nil {
l.Err(err).Send()
utils.WriteError(w, "from_id is invalid", 400)
return
}
toidStr := r.URL.Query().Get("to_id")
toId, err := strconv.Atoi(toidStr)
if err != nil {
l.Err(err).Send()
utils.WriteError(w, "to_id is invalid", 400)
return
}
err = store.MergeArtists(r.Context(), int32(fromId), int32(toId))
if err != nil {
l.Err(err).Send()
utils.WriteError(w, "Failed to merge artists: "+err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
}

View file

@ -0,0 +1,178 @@
package handlers
import (
"io"
"net/http"
"strconv"
"strings"
"github.com/gabehf/koito/internal/catalog"
"github.com/gabehf/koito/internal/cfg"
"github.com/gabehf/koito/internal/db"
"github.com/gabehf/koito/internal/logger"
"github.com/gabehf/koito/internal/utils"
"github.com/google/uuid"
)
type ReplaceImageResponse struct {
Success bool `json:"success"`
Image string `json:"image"`
Message string `json:"message,omitempty"`
}
func ReplaceImageHandler(store db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
l := logger.FromContext(ctx)
artistIdStr := r.FormValue("artist_id")
artistId, _ := strconv.Atoi(artistIdStr)
albumIdStr := r.FormValue("album_id")
albumId, _ := strconv.Atoi(albumIdStr)
if artistId != 0 && albumId != 0 {
utils.WriteError(w, "Only one of artist_id and album_id can be set", http.StatusBadRequest)
return
} else if artistId == 0 && albumId == 0 {
utils.WriteError(w, "One of artist_id and album_id must be set", http.StatusBadRequest)
return
}
var oldImage *uuid.UUID
if artistId != 0 {
a, err := store.GetArtist(ctx, db.GetArtistOpts{
ID: int32(artistId),
})
if err != nil {
utils.WriteError(w, "Artist with specified id could not be found", http.StatusBadRequest)
return
}
oldImage = a.Image
} else if albumId != 0 {
a, err := store.GetAlbum(ctx, db.GetAlbumOpts{
ID: int32(albumId),
})
if err != nil {
utils.WriteError(w, "Album with specified id could not be found", http.StatusBadRequest)
return
}
oldImage = a.Image
}
l.Debug().Msgf("Getting image from request...")
var id uuid.UUID
var err error
fileUrl := r.FormValue("image_url")
if fileUrl != "" {
l.Debug().Msg("Image identified as remote file")
err = catalog.ValidateImageURL(fileUrl)
if err != nil {
utils.WriteError(w, "url is invalid or not an image file", http.StatusBadRequest)
return
}
id = uuid.New()
var dlSize catalog.ImageSize
if cfg.FullImageCacheEnabled() {
dlSize = catalog.ImageSizeFull
} else {
dlSize = catalog.ImageSizeLarge
}
l.Debug().Msg("Downloading album image from source...")
err = catalog.DownloadAndCacheImage(ctx, id, fileUrl, dlSize)
if err != nil {
l.Err(err).Msg("Failed to cache image")
}
} else {
file, _, err := r.FormFile("image")
if err != nil {
utils.WriteError(w, "Invalid file", http.StatusBadRequest)
return
}
defer file.Close()
buf := make([]byte, 512)
if _, err := file.Read(buf); err != nil {
utils.WriteError(w, "Could not read file", http.StatusInternalServerError)
return
}
contentType := http.DetectContentType(buf)
if !strings.HasPrefix(contentType, "image/") {
utils.WriteError(w, "Only image uploads are allowed", http.StatusBadRequest)
return
}
if _, err := file.Seek(0, io.SeekStart); err != nil {
utils.WriteError(w, "Could not seek file", http.StatusInternalServerError)
return
}
l.Debug().Msgf("Saving image to cache...")
id = uuid.New()
var dlSize catalog.ImageSize
if cfg.FullImageCacheEnabled() {
dlSize = catalog.ImageSizeFull
} else {
dlSize = catalog.ImageSizeLarge
}
err = catalog.CompressAndSaveImage(ctx, id.String(), dlSize, file)
if err != nil {
utils.WriteError(w, "Could not save file", http.StatusInternalServerError)
return
}
}
l.Debug().Msgf("Updating database...")
var imgsrc string
if fileUrl != "" {
imgsrc = fileUrl
} else {
imgsrc = catalog.ImageSourceUserUpload
}
if artistId != 0 {
err := store.UpdateArtist(ctx, db.UpdateArtistOpts{
ID: int32(artistId),
Image: id,
ImageSrc: imgsrc,
})
if err != nil {
l.Err(err).Msg("Artist image could not be updated")
utils.WriteError(w, "Artist image could not be updated", http.StatusInternalServerError)
return
}
} else if albumId != 0 {
err := store.UpdateAlbum(ctx, db.UpdateAlbumOpts{
ID: int32(albumId),
Image: id,
ImageSrc: imgsrc,
})
if err != nil {
l.Err(err).Msg("Album image could not be updated")
utils.WriteError(w, "Album image could not be updated", http.StatusInternalServerError)
return
}
}
if oldImage != nil {
l.Debug().Msg("Cleaning up old image file...")
err = catalog.DeleteImage(*oldImage)
if err != nil {
l.Err(err).Msg("Failed to delete old image file")
utils.WriteError(w, "Could not delete old image file", http.StatusInternalServerError)
return
}
}
utils.WriteJSON(w, http.StatusOK, ReplaceImageResponse{
Success: true,
Image: id.String(),
})
}
}

View file

@ -0,0 +1 @@
package handlers_test

47
engine/handlers/search.go Normal file
View file

@ -0,0 +1,47 @@
package handlers
import (
"net/http"
"github.com/gabehf/koito/internal/db"
"github.com/gabehf/koito/internal/logger"
"github.com/gabehf/koito/internal/models"
"github.com/gabehf/koito/internal/utils"
)
type SearchResults struct {
Artists []*models.Artist `json:"artists"`
Albums []*models.Album `json:"albums"`
Tracks []*models.Track `json:"tracks"`
}
func SearchHandler(store db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
l := logger.FromContext(ctx)
q := r.URL.Query().Get("q")
artists, err := store.SearchArtists(ctx, q)
if err != nil {
l.Err(err).Msg("Failed to search for artists")
utils.WriteError(w, "failed to search in database", http.StatusInternalServerError)
return
}
albums, err := store.SearchAlbums(ctx, q)
if err != nil {
l.Err(err).Msg("Failed to search for albums")
utils.WriteError(w, "failed to search in database", http.StatusInternalServerError)
return
}
tracks, err := store.SearchTracks(ctx, q)
if err != nil {
l.Err(err).Msg("Failed to search for tracks")
utils.WriteError(w, "failed to search in database", http.StatusInternalServerError)
return
}
utils.WriteJSON(w, http.StatusOK, SearchResults{
Artists: artists,
Albums: albums,
Tracks: tracks,
})
}
}

77
engine/handlers/stats.go Normal file
View file

@ -0,0 +1,77 @@
package handlers
import (
"net/http"
"strings"
"github.com/gabehf/koito/internal/db"
"github.com/gabehf/koito/internal/logger"
"github.com/gabehf/koito/internal/utils"
)
type StatsResponse struct {
ListenCount int64 `json:"listen_count"`
TrackCount int64 `json:"track_count"`
AlbumCount int64 `json:"album_count"`
ArtistCount int64 `json:"artist_count"`
HoursListened int64 `json:"hours_listened"`
}
func StatsHandler(store db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
l := logger.FromContext(r.Context())
var period db.Period
switch strings.ToLower(r.URL.Query().Get("period")) {
case "day":
period = db.PeriodDay
case "week":
period = db.PeriodWeek
case "month":
period = db.PeriodMonth
case "year":
period = db.PeriodYear
case "all_time":
period = db.PeriodAllTime
default:
l.Debug().Msgf("Using default value '%s' for period", db.PeriodDay)
period = db.PeriodDay
}
listens, err := store.CountListens(r.Context(), period)
if err != nil {
l.Err(err).Send()
utils.WriteError(w, "failed to get listens: "+err.Error(), http.StatusInternalServerError)
return
}
tracks, err := store.CountTracks(r.Context(), period)
if err != nil {
l.Err(err).Send()
utils.WriteError(w, "failed to get listens: "+err.Error(), http.StatusInternalServerError)
return
}
albums, err := store.CountAlbums(r.Context(), period)
if err != nil {
l.Err(err).Send()
utils.WriteError(w, "failed to get listens: "+err.Error(), http.StatusInternalServerError)
return
}
artists, err := store.CountArtists(r.Context(), period)
if err != nil {
l.Err(err).Send()
utils.WriteError(w, "failed to get listens: "+err.Error(), http.StatusInternalServerError)
return
}
timeListenedS, err := store.CountTimeListened(r.Context(), period)
if err != nil {
l.Err(err).Send()
utils.WriteError(w, "failed to get listens: "+err.Error(), http.StatusInternalServerError)
return
}
utils.WriteJSON(w, http.StatusOK, StatsResponse{
ListenCount: listens,
TrackCount: tracks,
AlbumCount: albums,
ArtistCount: artists,
HoursListened: timeListenedS / 60 / 60,
})
}
}