mirror of
https://github.com/gabehf/Koito.git
synced 2026-03-15 02:15:55 -07:00
chore: initial public commit
This commit is contained in:
commit
fc9054b78c
250 changed files with 32809 additions and 0 deletions
270
engine/handlers/alias.go
Normal file
270
engine/handlers/alias.go
Normal 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
153
engine/handlers/apikeys.go
Normal 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
149
engine/handlers/auth.go
Normal 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
137
engine/handlers/delete.go
Normal 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)
|
||||
}
|
||||
}
|
||||
28
engine/handlers/get_album.go
Normal file
28
engine/handlers/get_album.go
Normal 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)
|
||||
}
|
||||
}
|
||||
28
engine/handlers/get_artist.go
Normal file
28
engine/handlers/get_artist.go
Normal 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)
|
||||
}
|
||||
}
|
||||
65
engine/handlers/get_listen_activity.go
Normal file
65
engine/handlers/get_listen_activity.go
Normal 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)
|
||||
}
|
||||
}
|
||||
23
engine/handlers/get_listens.go
Normal file
23
engine/handlers/get_listens.go
Normal 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)
|
||||
}
|
||||
}
|
||||
23
engine/handlers/get_top_albums.go
Normal file
23
engine/handlers/get_top_albums.go
Normal 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)
|
||||
}
|
||||
}
|
||||
23
engine/handlers/get_top_artists.go
Normal file
23
engine/handlers/get_top_artists.go
Normal 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)
|
||||
}
|
||||
}
|
||||
23
engine/handlers/get_top_tracks.go
Normal file
23
engine/handlers/get_top_tracks.go
Normal 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)
|
||||
}
|
||||
}
|
||||
31
engine/handlers/get_track.go
Normal file
31
engine/handlers/get_track.go
Normal 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)
|
||||
}
|
||||
}
|
||||
77
engine/handlers/handlers.go
Normal file
77
engine/handlers/handlers.go
Normal 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
10
engine/handlers/health.go
Normal 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"}`))
|
||||
}
|
||||
}
|
||||
208
engine/handlers/image_handler.go
Normal file
208
engine/handlers/image_handler.go
Normal 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)
|
||||
// }
|
||||
// }
|
||||
278
engine/handlers/lbz_submit_listen.go
Normal file
278
engine/handlers/lbz_submit_listen.go
Normal 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)
|
||||
}
|
||||
41
engine/handlers/lbz_validate.go
Normal file
41
engine/handlers/lbz_validate.go
Normal 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
97
engine/handlers/merge.go
Normal 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)
|
||||
}
|
||||
}
|
||||
178
engine/handlers/replace_image.go
Normal file
178
engine/handlers/replace_image.go
Normal 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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
1
engine/handlers/replace_image_test.go
Normal file
1
engine/handlers/replace_image_test.go
Normal file
|
|
@ -0,0 +1 @@
|
|||
package handlers_test
|
||||
47
engine/handlers/search.go
Normal file
47
engine/handlers/search.go
Normal 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
77
engine/handlers/stats.go
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue