diff --git a/.env.test b/.env.test index 5efbdb7..e22b43f 100644 --- a/.env.test +++ b/.env.test @@ -13,3 +13,7 @@ CHARACTERS_PATH=/Users/sebastian/projects/MusicServer/testCharacters PORT=8081 LOG_LEVEL=debug LOG_JSON=false + +# Session Authentication (for testing) +# Set to disable session auth during development +DISABLE_SESSION_AUTH=true diff --git a/Dockerfile b/Dockerfile index d5c84e0..cc1dd39 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,6 @@ COPY go.mod go.sum ./ RUN go mod download COPY cmd/ ./cmd/ COPY internal/ ./internal/ -COPY *.go ./ RUN find . -name "*_test.go" -delete && \ find . -name "integration_test.go" -delete RUN go install github.com/a-h/templ/cmd/templ@latest diff --git a/cmd/docs/docs.go b/cmd/docs/docs.go index 0e4fc35..7c752cb 100644 --- a/cmd/docs/docs.go +++ b/cmd/docs/docs.go @@ -23,6 +23,110 @@ var doc = `{ "host": "{{.Host}}", "basePath": "{{.BasePath}}", "paths": { + "/api/v1/statistics/songs/least-played": { + "get": { + "description": "Returns the top N least played songs with their soundtrack info", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "statistics" + ], + "summary": "Get least played songs", + "parameters": [ + { + "type": "integer", + "description": "Number of results (default: 10)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/backend.SongInfoForStats" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/statistics/songs/most-played": { + "get": { + "description": "Returns the top N most played songs with their soundtrack info", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "statistics" + ], + "summary": "Get most played songs", + "parameters": [ + { + "type": "integer", + "description": "Number of results (default: 10)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/backend.SongInfoForStats" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, "/api/v1/statistics/soundtracks/last-played": { "get": { "description": "Returns the most recently played soundtracks", @@ -266,110 +370,6 @@ var doc = `{ } } }, - "/api/v1/statistics/songs/least-played": { - "get": { - "description": "Returns the top N least played songs with their game info", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "statistics" - ], - "summary": "Get least played songs", - "parameters": [ - { - "type": "integer", - "description": "Number of results (default: 10)", - "name": "limit", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/backend.SongInfoForStats" - } - } - }, - "400": { - "description": "Bad Request", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - } - } - } - }, - "/api/v1/statistics/songs/most-played": { - "get": { - "description": "Returns the top N most played songs with their game info", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "statistics" - ], - "summary": "Get most played songs", - "parameters": [ - { - "type": "integer", - "description": "Number of results (default: 10)", - "name": "limit", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/backend.SongInfoForStats" - } - } - }, - "400": { - "description": "Bad Request", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - } - } - } - }, "/api/v1/statistics/summary": { "get": { "description": "Returns overall statistics about the music library", @@ -404,7 +404,7 @@ var doc = `{ }, "/api/v1/token": { "post": { - "description": "Returns a new session token for API access", + "description": "Returns a new session token for API access. Requires valid API password.", "consumes": [ "application/json" ], @@ -417,7 +417,7 @@ var doc = `{ "summary": "Create session token", "parameters": [ { - "description": "Client type", + "description": "Password and client type", "name": "request", "in": "body", "required": true, @@ -442,6 +442,15 @@ var doc = `{ } } }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, "500": { "description": "Internal Server Error", "schema": { @@ -1369,9 +1378,38 @@ var doc = `{ } }, "definitions": { + "backend.SongInfoForStats": { + "type": "object", + "properties": { + "file_name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "song_name": { + "type": "string" + }, + "soundtrack_id": { + "type": "integer" + }, + "soundtrack_name": { + "type": "string" + }, + "times_played": { + "type": "integer" + } + } + }, "backend.SoundtrackWithSongs": { "type": "object", "properties": { + "songs": { + "type": "array", + "items": { + "$ref": "#/definitions/backend.SongInfoForStats" + } + }, "soundtrack_id": { "type": "integer" }, @@ -1383,35 +1421,6 @@ var doc = `{ }, "soundtrack_played": { "type": "integer" - }, - "songs": { - "type": "array", - "items": { - "$ref": "#/definitions/backend.SongInfoForStats" - } - } - } - }, - "backend.SongInfoForStats": { - "type": "object", - "properties": { - "file_name": { - "type": "string" - }, - "soundtrack_id": { - "type": "integer" - }, - "soundtrack_name": { - "type": "string" - }, - "path": { - "type": "string" - }, - "song_name": { - "type": "string" - }, - "times_played": { - "type": "integer" } } }, @@ -1466,6 +1475,10 @@ var doc = `{ "client_type": { "description": "Optional: \"web\", \"mobile\", \"api\"", "type": "string" + }, + "password": { + "description": "Required: API password", + "type": "string" } } }, diff --git a/cmd/docs/swagger.json b/cmd/docs/swagger.json index 4abba18..2996b4d 100644 --- a/cmd/docs/swagger.json +++ b/cmd/docs/swagger.json @@ -4,6 +4,110 @@ "contact": {} }, "paths": { + "/api/v1/statistics/songs/least-played": { + "get": { + "description": "Returns the top N least played songs with their soundtrack info", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "statistics" + ], + "summary": "Get least played songs", + "parameters": [ + { + "type": "integer", + "description": "Number of results (default: 10)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/backend.SongInfoForStats" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/statistics/songs/most-played": { + "get": { + "description": "Returns the top N most played songs with their soundtrack info", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "statistics" + ], + "summary": "Get most played songs", + "parameters": [ + { + "type": "integer", + "description": "Number of results (default: 10)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/backend.SongInfoForStats" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, "/api/v1/statistics/soundtracks/last-played": { "get": { "description": "Returns the most recently played soundtracks", @@ -247,110 +351,6 @@ } } }, - "/api/v1/statistics/songs/least-played": { - "get": { - "description": "Returns the top N least played songs with their soundtrack info", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "statistics" - ], - "summary": "Get least played songs", - "parameters": [ - { - "type": "integer", - "description": "Number of results (default: 10)", - "name": "limit", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/backend.SongInfoForStats" - } - } - }, - "400": { - "description": "Bad Request", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - } - } - } - }, - "/api/v1/statistics/songs/most-played": { - "get": { - "description": "Returns the top N most played songs with their soundtrack info", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "statistics" - ], - "summary": "Get most played songs", - "parameters": [ - { - "type": "integer", - "description": "Number of results (default: 10)", - "name": "limit", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/backend.SongInfoForStats" - } - } - }, - "400": { - "description": "Bad Request", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - } - } - } - }, "/api/v1/statistics/summary": { "get": { "description": "Returns overall statistics about the music library", @@ -385,7 +385,7 @@ }, "/api/v1/token": { "post": { - "description": "Returns a new session token for API access", + "description": "Returns a new session token for API access. Requires valid API password.", "consumes": [ "application/json" ], @@ -398,7 +398,7 @@ "summary": "Create session token", "parameters": [ { - "description": "Client type", + "description": "Password and client type", "name": "request", "in": "body", "required": true, @@ -423,6 +423,15 @@ } } }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, "500": { "description": "Internal Server Error", "schema": { @@ -1350,9 +1359,38 @@ } }, "definitions": { + "backend.SongInfoForStats": { + "type": "object", + "properties": { + "file_name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "song_name": { + "type": "string" + }, + "soundtrack_id": { + "type": "integer" + }, + "soundtrack_name": { + "type": "string" + }, + "times_played": { + "type": "integer" + } + } + }, "backend.SoundtrackWithSongs": { "type": "object", "properties": { + "songs": { + "type": "array", + "items": { + "$ref": "#/definitions/backend.SongInfoForStats" + } + }, "soundtrack_id": { "type": "integer" }, @@ -1364,35 +1402,6 @@ }, "soundtrack_played": { "type": "integer" - }, - "songs": { - "type": "array", - "items": { - "$ref": "#/definitions/backend.SongInfoForStats" - } - } - } - }, - "backend.SongInfoForStats": { - "type": "object", - "properties": { - "file_name": { - "type": "string" - }, - "soundtrack_id": { - "type": "integer" - }, - "soundtrack_name": { - "type": "string" - }, - "path": { - "type": "string" - }, - "song_name": { - "type": "string" - }, - "times_played": { - "type": "integer" } } }, @@ -1447,6 +1456,10 @@ "client_type": { "description": "Optional: \"web\", \"mobile\", \"api\"", "type": "string" + }, + "password": { + "description": "Required: API password", + "type": "string" } } }, diff --git a/cmd/docs/swagger.yaml b/cmd/docs/swagger.yaml index 27a9895..8e2dd2e 100644 --- a/cmd/docs/swagger.yaml +++ b/cmd/docs/swagger.yaml @@ -1,49 +1,49 @@ definitions: - backend.GameWithSongs: - properties: - game_id: - type: integer - game_last_played: - type: string - game_name: - type: string - game_played: - type: integer - songs: - items: - $ref: '#/definitions/backend.SongInfoForStats' - type: array - type: object backend.SongInfoForStats: properties: file_name: type: string - game_id: - type: integer - game_name: - type: string path: type: string song_name: type: string + soundtrack_id: + type: integer + soundtrack_name: + type: string times_played: type: integer type: object + backend.SoundtrackWithSongs: + properties: + songs: + items: + $ref: '#/definitions/backend.SongInfoForStats' + type: array + soundtrack_id: + type: integer + soundtrack_last_played: + type: string + soundtrack_name: + type: string + soundtrack_played: + type: integer + type: object backend.StatisticsSummary: properties: - avg_game_plays: + avg_soundtrack_plays: type: number - max_game_plays: + max_soundtrack_plays: type: integer - min_game_plays: + min_soundtrack_plays: type: integer - never_played_games: + never_played_soundtracks: type: integer - played_games: + played_soundtracks: type: integer - total_game_plays: + total_soundtrack_plays: type: integer - total_games: + total_soundtracks: type: integer type: object backend.VersionData: @@ -64,6 +64,9 @@ definitions: client_type: description: 'Optional: "web", "mobile", "api"' type: string + password: + description: 'Required: API password' + type: string type: object server.TokenResponse: properties: @@ -77,171 +80,11 @@ definitions: info: contact: {} paths: - /api/v1/statistics/games/last-played: - get: - consumes: - - application/json - description: Returns the most recently played games - parameters: - - description: 'Number of results (default: 10)' - in: query - name: limit - type: integer - produces: - - application/json - responses: - "200": - description: OK - schema: - items: - $ref: '#/definitions/backend.GameWithSongs' - type: array - "400": - description: Bad Request - schema: - additionalProperties: - type: string - type: object - "500": - description: Internal Server Error - schema: - additionalProperties: - type: string - type: object - summary: Get last played games - tags: - - statistics - /api/v1/statistics/games/least-played: - get: - consumes: - - application/json - description: Returns the top N least played games with their songs - parameters: - - description: 'Number of results (default: 10)' - in: query - name: limit - type: integer - produces: - - application/json - responses: - "200": - description: OK - schema: - items: - $ref: '#/definitions/backend.GameWithSongs' - type: array - "400": - description: Bad Request - schema: - additionalProperties: - type: string - type: object - "500": - description: Internal Server Error - schema: - additionalProperties: - type: string - type: object - summary: Get least played games - tags: - - statistics - /api/v1/statistics/games/most-played: - get: - consumes: - - application/json - description: Returns the top N most played games with their songs - parameters: - - description: 'Number of results (default: 10)' - in: query - name: limit - type: integer - produces: - - application/json - responses: - "200": - description: OK - schema: - items: - $ref: '#/definitions/backend.GameWithSongs' - type: array - "400": - description: Bad Request - schema: - additionalProperties: - type: string - type: object - "500": - description: Internal Server Error - schema: - additionalProperties: - type: string - type: object - summary: Get most played games - tags: - - statistics - /api/v1/statistics/games/never-played: - get: - consumes: - - application/json - description: Returns all games that have never been played (times_played = 0) - produces: - - application/json - responses: - "200": - description: OK - schema: - items: - $ref: '#/definitions/backend.GameWithSongs' - type: array - "500": - description: Internal Server Error - schema: - additionalProperties: - type: string - type: object - summary: Get never played games - tags: - - statistics - /api/v1/statistics/games/oldest-played: - get: - consumes: - - application/json - description: Returns the least recently played games (that have been played - at least once) - parameters: - - description: 'Number of results (default: 10)' - in: query - name: limit - type: integer - produces: - - application/json - responses: - "200": - description: OK - schema: - items: - $ref: '#/definitions/backend.GameWithSongs' - type: array - "400": - description: Bad Request - schema: - additionalProperties: - type: string - type: object - "500": - description: Internal Server Error - schema: - additionalProperties: - type: string - type: object - summary: Get oldest played games - tags: - - statistics /api/v1/statistics/songs/least-played: get: consumes: - application/json - description: Returns the top N least played songs with their game info + description: Returns the top N least played songs with their soundtrack info parameters: - description: 'Number of results (default: 10)' in: query @@ -275,7 +118,7 @@ paths: get: consumes: - application/json - description: Returns the top N most played songs with their game info + description: Returns the top N most played songs with their soundtrack info parameters: - description: 'Number of results (default: 10)' in: query @@ -305,6 +148,167 @@ paths: summary: Get most played songs tags: - statistics + /api/v1/statistics/soundtracks/last-played: + get: + consumes: + - application/json + description: Returns the most recently played soundtracks + parameters: + - description: 'Number of results (default: 10)' + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/backend.SoundtrackWithSongs' + type: array + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + summary: Get last played soundtracks + tags: + - statistics + /api/v1/statistics/soundtracks/least-played: + get: + consumes: + - application/json + description: Returns the top N least played soundtracks with their songs + parameters: + - description: 'Number of results (default: 10)' + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/backend.SoundtrackWithSongs' + type: array + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + summary: Get least played soundtracks + tags: + - statistics + /api/v1/statistics/soundtracks/most-played: + get: + consumes: + - application/json + description: Returns the top N most played soundtracks with their songs + parameters: + - description: 'Number of results (default: 10)' + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/backend.SoundtrackWithSongs' + type: array + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + summary: Get most played soundtracks + tags: + - statistics + /api/v1/statistics/soundtracks/never-played: + get: + consumes: + - application/json + description: Returns all soundtracks that have never been played (times_played + = 0) + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/backend.SoundtrackWithSongs' + type: array + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + summary: Get never played soundtracks + tags: + - statistics + /api/v1/statistics/soundtracks/oldest-played: + get: + consumes: + - application/json + description: Returns the least recently played soundtracks (that have been played + at least once) + parameters: + - description: 'Number of results (default: 10)' + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/backend.SoundtrackWithSongs' + type: array + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + summary: Get oldest played soundtracks + tags: + - statistics /api/v1/statistics/summary: get: consumes: @@ -364,9 +368,10 @@ paths: post: consumes: - application/json - description: Returns a new session token for API access + description: Returns a new session token for API access. Requires valid API + password. parameters: - - description: Client type + - description: Password and client type in: body name: request required: true @@ -385,6 +390,12 @@ paths: additionalProperties: type: string type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object "500": description: Internal Server Error schema: @@ -606,7 +617,7 @@ paths: get: consumes: - application/json - description: Returns a list of all games in order + description: Returns a list of all soundtracks in order produces: - application/json responses: @@ -628,7 +639,7 @@ paths: get: consumes: - application/json - description: Returns a list of all games in random order + description: Returns a list of all soundtracks in random order produces: - application/json responses: @@ -852,7 +863,7 @@ paths: get: consumes: - application/json - description: Starts syncing games with only new changes + description: Starts syncing soundtracks with only new changes produces: - application/json responses: @@ -871,7 +882,7 @@ paths: get: consumes: - application/json - description: Starts a full sync of all games + description: Starts a full sync of all soundtracks produces: - application/json responses: @@ -883,7 +894,7 @@ paths: description: Syncing is in progress schema: type: string - summary: Sync all games fully + summary: Sync all soundtracks fully tags: - sync /sync/progress: @@ -906,7 +917,8 @@ paths: get: consumes: - application/json - description: Resets the games database by deleting all games and songs + description: Resets the soundtracks database by deleting all soundtracks and + songs produces: - application/json responses: diff --git a/cmd/web/base.templ b/cmd/web/base.templ index 615affb..bcc3229 100644 --- a/cmd/web/base.templ +++ b/cmd/web/base.templ @@ -2,7 +2,7 @@ package web templ Base() { - + Music Search diff --git a/cmd/web/search.go b/cmd/web/search.go index d91a068..b9ee2a7 100644 --- a/cmd/web/search.go +++ b/cmd/web/search.go @@ -4,40 +4,23 @@ import ( "log" "music-server/internal/backend" "net/http" - "regexp" - "sort" "strings" - "sync" - "unicode" ) var soundtracks_added []string -// Precomputed data for optimization -type SoundtrackData struct { - Original string - Cleaned string - Abbreviation string -} - -var ( - precomputedSoundtracks []SoundtrackData - precomputedOnce sync.Once - regexCache = make(map[string]*regexp.Regexp) - regexCacheMutex sync.Mutex -) - func FindSoundtrackWebHandler(w http.ResponseWriter, r *http.Request) { err := r.ParseForm() if err != nil { http.Error(w, "Bad Request", http.StatusBadRequest) + return } search_term := r.FormValue("search_term") - search(search_term) + results := soundtracks_added - component := FoundSoundtracks(soundtracks_added) + component := FoundSoundtracks(results) err = component.Render(r.Context(), w) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) @@ -47,357 +30,48 @@ func FindSoundtrackWebHandler(w http.ResponseWriter, r *http.Request) { func search(searchText string) { soundtracks_added = nil + soundtrackData := backend.GetSearchCache() + results := searchWithData(searchText, soundtrackData) + soundtracks_added = results +} - // Precompute cleaned search term once - cleanedSearchTerm := clean_term(searchText) +// searchWithData is a testable version of search that takes soundtrack data as a parameter +func searchWithData(searchText string, soundtrackData []backend.SoundtrackData) []string { + var results []string + seen := make(map[string]bool) - // Use precomputed data for efficiency - soundtrackData := getPrecomputedSoundtracks() - seen := make(map[string]bool) // O(1) duplicate checking + // Precompute cleaned and lowercase search terms once + cleanedSearchTerm := strings.ToLower(searchText) + cleanedSearchTerm = strings.ReplaceAll(cleanedSearchTerm, " ", "") + cleanedSearchTerm = strings.ReplaceAll(cleanedSearchTerm, "é", "e") + cleanedSearchTerm = strings.ReplaceAll(cleanedSearchTerm, "+", "plus") + cleanedSearchTerm = strings.ReplaceAll(cleanedSearchTerm, "&", "and") + cleanedSearchTerm = strings.ReplaceAll(cleanedSearchTerm, "'n", "and") + lowercaseSearchText := strings.ToLower(searchText) + // Pass 1: Exact match (case-insensitive contains) for _, data := range soundtrackData { - // Check exact match (case-insensitive contains) - if is_match_exact(searchText, data.Original) && !seen[data.Original] { - soundtracks_added = append(soundtracks_added, data.Original) + if !seen[data.Original] && (searchText == "" || strings.Contains(data.Lowercase, lowercaseSearchText)) { + results = append(results, data.Original) seen[data.Original] = true } } + // Pass 2: Contains match with cleaned terms for _, data := range soundtrackData { - // Check contains match with cleaned terms - if !seen[data.Original] && is_match_contains(cleanedSearchTerm, data.Cleaned) { - soundtracks_added = append(soundtracks_added, data.Original) + if !seen[data.Original] && (cleanedSearchTerm == "" || strings.Contains(data.Cleaned, cleanedSearchTerm)) { + results = append(results, data.Original) seen[data.Original] = true } } + // Pass 3: Regex match with cleaned terms for _, data := range soundtrackData { - // Check regex match with cleaned terms - if !seen[data.Original] && is_match_regex_cached(cleanedSearchTerm, data.Cleaned) { - soundtracks_added = append(soundtracks_added, data.Original) + if !seen[data.Original] && backend.MatchRegexCached(cleanedSearchTerm, data.Cleaned) { + results = append(results, data.Original) seen[data.Original] = true } } -} -func is_match_exact(search_term string, soundtrack_name string) bool { - search_term = strings.ToLower(search_term) - soundtrack_name = strings.ToLower(soundtrack_name) - - if search_term == "" { - return true - } else if strings.Contains(soundtrack_name, search_term) { - return true - } else { - return false - } -} - -func is_match_contains(search_term string, soundtrack_name string) bool { - if search_term == "" { - return true - } else if strings.Contains(soundtrack_name, search_term) { - return true - } else { - return false - } -} - -func is_match_regex(search_term string, soundtrack_name string) bool { - if search_term == "" { - return true - } else if compile_regex(search_term).MatchString(soundtrack_name) { - return true - } else { - return false - } -} - -func compile_regex(search_term string) *regexp.Regexp { - regText := ".*" - for _, letter := range search_term { - regText += string(letter) + ".*" - } - r, _ := regexp.Compile(regText) - - return r -} - -// is_match_regex_cached uses cached compiled regex patterns for better performance -func is_match_regex_cached(search_term string, soundtrack_name string) bool { - if search_term == "" { - return true - } - - // Check cache first - regexCacheMutex.Lock() - re, exists := regexCache[search_term] - if !exists { - re = compile_regex(search_term) - regexCache[search_term] = re - } - regexCacheMutex.Unlock() - - return re.MatchString(soundtrack_name) -} - -func clean_term(term string) string { - term = strings.ReplaceAll(term, " ", "") - term = strings.ReplaceAll(term, "é", "e") - term = strings.ReplaceAll(term, "+", "plus") - term = strings.ReplaceAll(term, "&", "and") - term = strings.ReplaceAll(term, "'n", "and") - return strings.ToLower(term) -} - -// toLower converts a string to lowercase. -func toLower(s string) string { - return strings.Map(unicode.ToLower, s) -} - -// levenshteinWithThreshold calculates Levenshtein distance with early termination and space optimization. -// Uses O(min(n,m)) space instead of O(n*m) and stops early if threshold is exceeded. -func levenshteinWithThreshold(s, t string, threshold int) int { - if threshold < 0 { - threshold = 2 // default threshold - } - - m, n := len(s), len(t) - - // Quick checks for early termination - if m == 0 { - return n - } - if n == 0 { - return m - } - if abs(m-n) > threshold { - return threshold + 1 // Can't be within threshold - } - - // Use the shorter string for the row to minimize space - if m < n { - s, t = t, s - m, n = n, m - } - - // Space optimization: only store two rows - prevRow := make([]int, n+1) - currRow := make([]int, n+1) - - // Initialize first row - for j := 0; j <= n; j++ { - prevRow[j] = j - } - - for i := 1; i <= m; i++ { - currRow[0] = i - minInRow := currRow[0] - - for j := 1; j <= n; j++ { - if s[i-1] == t[j-1] { - currRow[j] = prevRow[j-1] - } else { - currRow[j] = min3(prevRow[j], currRow[j-1], prevRow[j-1]) + 1 - } - if currRow[j] < minInRow { - minInRow = currRow[j] - } - } - - // Early termination: if minimum in current row exceeds threshold - if minInRow > threshold { - return threshold + 1 - } - - // Swap rows for next iteration - prevRow, currRow = currRow, prevRow - } - - return prevRow[n] -} - -// Helper functions for optimized Levenshtein -func abs(x int) int { - if x < 0 { - return -x - } - return x -} - -func min3(a, b, c int) int { - if a < b { - if a < c { - return a - } - return c - } - if b < c { - return b - } - return c -} - -// precomputeSoundtrackData initializes cleaned terms and abbreviations for all soundtracks -func precomputeSoundtrackData() { - precomputedOnce.Do(func() { - soundtracks := backend.GetAllSoundtracks() - precomputedSoundtracks = make([]SoundtrackData, len(soundtracks)) - - for i, soundtrack := range soundtracks { - cleaned := clean_term(soundtrack) - precomputedSoundtracks[i] = SoundtrackData{ - Original: soundtrack, - Cleaned: cleaned, - Abbreviation: extractAbbreviation(soundtrack), - } - } - }) -} - -// getPrecomputedSoundtracks ensures precomputed data is available and returns it -func getPrecomputedSoundtracks() []SoundtrackData { - precomputeSoundtrackData() - return precomputedSoundtracks -} - -// levenshtein maintains backward compatibility by calling the optimized version -func levenshtein(s, t string) int { - return levenshteinWithThreshold(s, t, -1) // -1 uses default threshold -} - -// extractAbbreviation extracts the first letters of each word in a string. -func extractAbbreviation(s string) string { - words := strings.Fields(s) - abbr := "" - for _, word := range words { - if len(word) > 0 { - abbr += string(word[0]) - } - } - return toLower(abbr) -} - -// fuzzyFindSubstring checks if the query fuzzy-matches any substring of the item. -func fuzzyFindSubstring(query, item string, threshold int) (bool, int) { - query = toLower(query) - item = toLower(item) - queryLen := len(query) - itemLen := len(item) - if queryLen > itemLen { - return false, 0 - } - - // Check for exact substring match first (weight: 100) - if strings.Contains(item, query) { - return true, 100 - } - - // Check for fuzzy substring match using optimized Levenshtein - for i := 0; i <= itemLen-queryLen; i++ { - substring := item[i : i+queryLen] - distance := levenshteinWithThreshold(query, substring, threshold) - if distance <= threshold { - // Weight: higher for matches at the start of the string - weight := 50 - i // Higher weight for earlier matches - return true, weight - } - } - return false, 0 -} - -// fuzzyFind checks if the query matches the item (substring or abbreviation). -func fuzzyFind(query string, item string, threshold int) (bool, int) { - query = toLower(query) - item = toLower(item) - - // Check for substring fuzzy match - if matched, weight := fuzzyFindSubstring(query, item, threshold); matched { - return true, weight - } - - // Check for abbreviation match using optimized Levenshtein - abbr := extractAbbreviation(item) - distance := levenshteinWithThreshold(query, abbr, threshold) - if distance <= threshold { - // Weight: higher for exact abbreviation matches - weight := 100 - distance*10 // Higher weight for exact matches - return true, weight - } - - return false, 0 -} - -// getAdaptiveThreshold returns a threshold based on query length -func getAdaptiveThreshold(query string) int { - threshold := 2 // Base threshold for minor typos - queryLen := len(query) - if queryLen > 6 { - // For longer queries, allow more tolerance - threshold = queryLen / 3 - if threshold < 2 { - threshold = 2 - } else if threshold > 4 { - threshold = 4 // Cap at 4 for very long queries - } - } - return threshold -} - -// fuzzySearch performs fuzzy search on soundtracks using the cleaned terms for consistency -func fuzzySearch(searchText string) []string { - query := clean_term(searchText) - threshold := getAdaptiveThreshold(query) - - // Use precomputed data for efficiency - soundtrackData := getPrecomputedSoundtracks() - - type match struct { - item string - weight int - } - var matches []match - seen := make(map[string]bool) // O(1) duplicate checking - - for _, data := range soundtrackData { - if matched, weight := fuzzyFind(query, data.Cleaned, threshold); matched { - if !seen[data.Original] { - matches = append(matches, match{data.Original, weight}) - seen[data.Original] = true - } - } - } - - // Sort matches by weight (descending) - sort.Slice(matches, func(i, j int) bool { - return matches[i].weight > matches[j].weight - }) - - // Extract just the soundtrack names in order - result := make([]string, len(matches)) - for i, m := range matches { - result[i] = m.item - } - - return result -} - -// FindSoundtrackFuzzyWebHandler handles fuzzy search requests -func FindSoundtrackFuzzyWebHandler(w http.ResponseWriter, r *http.Request) { - err := r.ParseForm() - if err != nil { - http.Error(w, "Bad Request", http.StatusBadRequest) - return - } - - search_term := r.FormValue("search_term") - - results := fuzzySearch(search_term) - - component := FoundSoundtracks(results) - err = component.Render(r.Context(), w) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - log.Fatalf("Error rendering in FindSoundtrackFuzzyWebHandler: %e", err) - } + return results } diff --git a/cmd/web/search.templ b/cmd/web/search.templ index 11aab9e..9960039 100644 --- a/cmd/web/search.templ +++ b/cmd/web/search.templ @@ -3,49 +3,20 @@ package web templ SearchForm() { @Base() { -
- -
- - -
- -
+ +
diff --git a/cmd/web/search_test.go b/cmd/web/search_test.go index 006bce7..fe98b0b 100644 --- a/cmd/web/search_test.go +++ b/cmd/web/search_test.go @@ -1,49 +1,18 @@ package web import ( + "music-server/internal/backend" + "strings" "testing" ) -// TestExtractAbbreviation tests that abbreviations are correctly extracted from soundtrack names -func TestExtractAbbreviation(t *testing.T) { - tests := []struct { - input string - expected string - }{ - // extractAbbreviation takes first letter of each word (split by spaces) - {"Metal Gear Solid 4", "mgs4"}, - {"Resident Evil 2", "re2"}, - // "Final Fantasy VII" -> "Final", "Fantasy", "VII" -> "f" + "f" + "v" = "ffv" - {"Final Fantasy VII", "ffv"}, - // "Ys III - Wanderers from Ys (+The Oath in Felghana)" -> - // "Ys", "III", "-", "Wanderers", "from", "Ys", "(+The", "Oath", "in", "Felghana)" - // -> "y" + "i" + "-" + "w" + "f" + "y" + "(" + "o" + "i" + "f" = "yi-wfy(oif" - {"Ys III - Wanderers from Ys (+The Oath in Felghana)", "yi-wfy(oif"}, - // "Call of Duty: Modern Warfare" -> "Call", "of", "Duty:", "Modern", "Warfare" -> "c" + "o" + "d" + "m" + "w" = "codmw" - {"Call of Duty: Modern Warfare", "codmw"}, - // "Half-Life 3" -> "Half-Life", "3" -> "h" + "3" = "h3" - {"Half-Life 3", "h3"}, - {"Metal Gear Solid 5", "mgs5"}, - {"A", "a"}, - {"", ""}, - {"Single", "s"}, - } - - for _, tt := range tests { - result := extractAbbreviation(tt.input) - if result != tt.expected { - t.Errorf("extractAbbreviation(%q) = %q, want %q", tt.input, result, tt.expected) - } - } -} - // TestCleanTerm tests that search terms are cleaned correctly func TestCleanTerm(t *testing.T) { tests := []struct { input string expected string }{ - // clean_term removes spaces, replaces specific chars, and lowercases + // CleanSearchTermForMatch removes spaces, replaces specific chars, and lowercases {"MGS4", "mgs4"}, {"RE2", "re2"}, {"FFVII", "ffvii"}, @@ -52,7 +21,7 @@ func TestCleanTerm(t *testing.T) { {"Test 123", "test123"}, {"A+B", "aplusb"}, {"é", "e"}, - // Note: clean_term does NOT remove apostrophes, it only replaces "'n" with "and" + // Note: CleanSearchTermForMatch does NOT remove apostrophes, it only replaces "'n" with "and" {"It's", "it's"}, // But it does replace "'n" with "and" {"I'n", "iand"}, @@ -60,75 +29,165 @@ func TestCleanTerm(t *testing.T) { } for _, tt := range tests { - result := clean_term(tt.input) + result := backend.CleanSearchTermForMatch(tt.input) if result != tt.expected { - t.Errorf("clean_term(%q) = %q, want %q", tt.input, result, tt.expected) + t.Errorf("CleanSearchTermForMatch(%q) = %q, want %q", tt.input, result, tt.expected) } } } -// TestFuzzyFindAbbreviationMatches tests that abbreviations match the correct soundtracks -func TestFuzzyFindAbbreviationMatches(t *testing.T) { - tests := []struct { - query string - item string - expected bool - }{ - // Positive cases - should match - {"MGS4", "Metal Gear Solid 4", true}, - {"RE2", "Resident Evil 2", true}, - {"FFVII", "Final Fantasy VII", true}, - {"Ys", "Ys III - Wanderers from Ys (+The Oath in Felghana)", true}, - {"MGS", "Metal Gear Solid 4", true}, - {"RE", "Resident Evil 2", true}, - {"FF", "Final Fantasy VII", true}, - - // Note: With fuzzy matching (threshold=2), similar items may also match. - // For example, "MGS4" will also match "Metal Gear Solid 5" because - // the abbreviations "mgs4" and "mgs5" have Levenshtein distance of 1. - // This is intentional for fuzzy matching to allow minor typos. - // The user's requirement is that the CORRECT item is found, not that - // INCORRECT items are excluded. Results are sorted by weight, so the - // exact match should appear first. - } - - threshold := 2 // Default threshold - - for _, tt := range tests { - matched, _ := fuzzyFind(tt.query, tt.item, threshold) - if matched != tt.expected { - t.Errorf("fuzzyFind(%q, %q, %d) = %v, want %v", tt.query, tt.item, threshold, matched, tt.expected) +// searchWithDataHelper is a helper to create backend.SoundtrackData from original names +func searchWithDataHelper(originals []string) []backend.SoundtrackData { + data := make([]backend.SoundtrackData, len(originals)) + for i, orig := range originals { + data[i] = backend.SoundtrackData{ + Original: orig, + Lowercase: strings.ToLower(orig), + Cleaned: backend.CleanSearchTermForMatch(orig), } } + return data } -// TestFuzzyFindWithAllTestData tests all the user's requirements comprehensively -// The user's requirement is that each query SHOULD find the specified item. -// Fuzzy matching may also find similar items, but the correct one should be in the results. -func TestFuzzyFindWithAllTestData(t *testing.T) { +// TestNormalSearch tests the normal search function +func TestNormalSearch(t *testing.T) { + // Set up comprehensive test data with all user-provided soundtracks + testData := searchWithDataHelper([]string{ + "A Bird Story", + "Actraiser", + "Actraiser 2", + "Bangai-O Spirits", + "Ciconia When they Cry", + "Civilization IV", + "Civilization VI", + "Clair Obscur - Expedition 33", + "Club House Games (42 All-Time Classics)", + "Final Fantasy Dissidia 012 Duodecim (Sista Fantasin Meningsskiljaktigheter 012 Tolv)", + "Final Fantasy VII Crisis Core", + "Final Fantasy VII Rebirth", + "Final Fantasy VII Remake", + "Final Fantasy VII", + "Joe & Mac", + "Judgment", + "Kingdom Hearts", + "Kingdom Hearts Birth By Sleep", + "Kingdom Hearts II", + "Kingdom Hearts III", + "Lemmings (SNES + Mega Drive)", + "Lemmings 3D", + "Mega Man X4", + "Metal Gear Rising - Revengeance", + "Metal Gear Solid (+Twin Snakes)", + "Metal Gear Solid 2 - Sons of Liberty", + "Metal Gear Solid 3 - Snake Eater", + "Metal Gear Solid 4 - Guns of the Patriots", + "Metroid Prime", + "Octopath Traveler", + "Octopath Traveler 2", + "Ori and the Blind Forest", + "Ori and the Will of the Wisps", + "OutRun 2006 - Coast 2 Coast", + "Paper Mario - The Thousand Year Door", + "Pokémon HeartGold & SoulSilver", + "Resident Evil 2 (Remake)", + "Shenmue", + "Super Mario Galaxy", + "Trails in the Sky FC (Sora no Kiseki FC)", + "Trails in the Sky SC (Sora no Kiseki SC)", + "Turrican II - The Final Fight", + "Umineko no Naku Koro ni", + "Umineko no Naku Koro ni Chiru", + "VVVVVV", + "YS VIII - Lacrimosa of DANA", + "Yakuza 0", + "Ys (Chronicles)", + "Ys - Memories of Celceta", + "Ys - The Ark of Napishtim", + "Ys II (Chronicles)", + "Ys III - Wanderers from Ys (+The Oath in Felghana)", + "Ys IV - The Dawn of Ys", + "Ys Origin", + "Ys Seven", + "Ys X Nordics", + "Call of Duty: Modern Warfare", + "Half-Life 3", + }) + + // Test cases covering all match types tests := []struct { query string - wantMatch string + wantFound string }{ - {"MGS4", "Metal Gear Solid 4"}, - {"RE2", "Resident Evil 2"}, - {"FFVII", "Final Fantasy VII"}, + // Exact substring matches + {"Metal", "Metal Gear Solid 4 - Guns of the Patriots"}, + {"Resident", "Resident Evil 2 (Remake)"}, + {"Final", "Final Fantasy VII"}, {"Ys", "Ys III - Wanderers from Ys (+The Oath in Felghana)"}, - } - threshold := 2 + // Contains matches (on cleaned terms) + {"gotp", "Metal Gear Solid 4 - Guns of the Patriots"}, + {"mgs", "Metal Gear Solid 4 - Guns of the Patriots"}, + {"kh", "Kingdom Hearts"}, + {"mg", "Metal Gear Solid 4 - Guns of the Patriots"}, + + // Regex matches + {"mgs4go", "Metal Gear Solid 4 - Guns of the Patriots"}, + {"re2r", "Resident Evil 2 (Remake)"}, + {"ffv", "Final Fantasy VII"}, + {"yiwfyt", "Ys III - Wanderers from Ys (+The Oath in Felghana)"}, + {"codmw", "Call of Duty: Modern Warfare"}, + {"mgs4gop", "Metal Gear Solid 4 - Guns of the Patriots"}, + } for _, tt := range tests { t.Run(tt.query, func(t *testing.T) { - // Verify the query matches the expected item - matched, _ := fuzzyFind(tt.query, tt.wantMatch, threshold) - if !matched { - t.Errorf("Query %q did not match expected item %q", tt.query, tt.wantMatch) + results := searchWithData(tt.query, testData) + + // Check that the expected item is in the results + found := false + for _, result := range results { + if result == tt.wantFound { + found = true + break + } + } + if !found { + t.Errorf("searchWithData(%q, ...) did not find expected item %q, got %v", tt.query, tt.wantFound, results) } }) } } -// Note: We don't test fuzzySearch() directly because it depends on precomputed data -// from the database. The unit tests above verify the underlying functions that -// fuzzySearch() uses: extractAbbreviation, clean_term, and fuzzyFind. +// TestSearchOrdering tests that Pass 1 matches come before Pass 2 matches +func TestSearchOrdering(t *testing.T) { + // "MG" matches Pass 1 (exact substring on original) + // "Metal Gear" matches Pass 2 (contains on cleaned) but NOT Pass 1 (no "mg" substring in "metal gear") + testData := searchWithDataHelper([]string{ + "Metal Gear", + "MG", + }) + + results := searchWithData("mg", testData) + + // Find indices + indexPass1 := -1 + indexPass2 := -1 + for i, result := range results { + if result == "MG" { + indexPass1 = i + } + if result == "Metal Gear" { + indexPass2 = i + } + } + + // Pass 1 match (MG) should come before Pass 2 match (Metal Gear) + if indexPass1 >= 0 && indexPass2 >= 0 && indexPass1 >= indexPass2 { + t.Errorf("Expected Pass 1 match 'MG' to come before Pass 2 match 'Metal Gear', but got order: %v", results) + } +} + +// Note: The tests above verify: +// 1. clean_term - the term cleaning logic +// 2. search - the complete normal search with exact, contains, and regex matching +// 3. ordering - exact matches come before contains matches diff --git a/go.mod b/go.mod index b892494..194461c 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.0 require ( github.com/MShekow/directory-checksum v1.4.18 github.com/a-h/templ v0.3.1020 + github.com/docker/go-connections v0.6.0 // indirect github.com/golang-migrate/migrate/v4 v4.19.1 github.com/jackc/pgx/v5 v5.9.2 github.com/labstack/echo/v5 v5.1.1 @@ -18,6 +19,8 @@ require ( go.uber.org/zap v1.28.0 ) +require github.com/google/uuid v1.6.0 + require ( dario.cat/mergo v1.0.2 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect @@ -32,7 +35,6 @@ require ( github.com/cpuguy83/dockercfg v0.3.2 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/distribution/reference v0.6.0 // indirect - github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/ebitengine/purego v0.10.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect @@ -50,7 +52,6 @@ require ( github.com/go-openapi/swag/stringutils v0.26.0 // indirect github.com/go-openapi/swag/typeutils v0.26.0 // indirect github.com/go-openapi/swag/yamlutils v0.26.0 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect diff --git a/internal/backend/search_cache.go b/internal/backend/search_cache.go new file mode 100644 index 0000000..565a9d4 --- /dev/null +++ b/internal/backend/search_cache.go @@ -0,0 +1,100 @@ +package backend + +import ( + "regexp" + "strings" + "sync" +) + +// SoundtrackData holds precomputed search data for a soundtrack +type SoundtrackData struct { + Original string + Lowercase string + Cleaned string +} + +var ( + searchCache []SoundtrackData + searchCacheMutex sync.Mutex + searchRegexCache = make(map[string]*regexp.Regexp) + searchRegexCacheMutex sync.Mutex +) + +// GetSearchCache returns the precomputed search cache, initializing it if needed +func GetSearchCache() []SoundtrackData { + searchCacheMutex.Lock() + defer searchCacheMutex.Unlock() + + if len(searchCache) == 0 { + loadSearchCache() + } + return searchCache +} + +// ReloadSearchCache clears and reloads the search cache. +// This should be called after a sync operation to ensure search results are up to date. +func ReloadSearchCache() { + searchCacheMutex.Lock() + defer searchCacheMutex.Unlock() + searchCache = nil + loadSearchCache() +} + +// loadSearchCache loads the soundtrack data into the cache +func loadSearchCache() { + soundtracks := GetAllSoundtracks() + searchCache = make([]SoundtrackData, len(soundtracks)) + + for i, soundtrack := range soundtracks { + cleaned := cleanSearchTerm(soundtrack) + lowercase := strings.ToLower(soundtrack) + searchCache[i] = SoundtrackData{ + Original: soundtrack, + Lowercase: lowercase, + Cleaned: cleaned, + } + } +} + +// CleanSearchTermForMatch cleans a search term for better matching +func CleanSearchTermForMatch(term string) string { + term = strings.ReplaceAll(term, " ", "") + term = strings.ReplaceAll(term, "é", "e") + term = strings.ReplaceAll(term, "+", "plus") + term = strings.ReplaceAll(term, "&", "and") + term = strings.ReplaceAll(term, "'n", "and") + return strings.ToLower(term) +} + +// cleanSearchTerm cleans a search term for better matching (internal) +func cleanSearchTerm(term string) string { + return CleanSearchTermForMatch(term) +} + +// MatchRegexCached checks if a search term matches using cached compiled regex +func MatchRegexCached(searchTerm string, soundtrackName string) bool { + if searchTerm == "" { + return true + } + + // Check cache first + searchRegexCacheMutex.Lock() + re, exists := searchRegexCache[searchTerm] + if !exists { + re = compileSearchRegex(searchTerm) + searchRegexCache[searchTerm] = re + } + searchRegexCacheMutex.Unlock() + + return re.MatchString(soundtrackName) +} + +// compileSearchRegex compiles a search term into a regex pattern +func compileSearchRegex(searchTerm string) *regexp.Regexp { + regText := ".*" + for _, letter := range searchTerm { + regText += string(letter) + ".*" + } + re, _ := regexp.Compile(regText) + return re +} diff --git a/internal/backend/sync.go b/internal/backend/sync.go index 6dff1b6..61f47c6 100644 --- a/internal/backend/sync.go +++ b/internal/backend/sync.go @@ -16,6 +16,7 @@ import ( "sync" "time" + "github.com/google/uuid" "github.com/panjf2000/ants/v2" "github.com/MShekow/directory-checksum/directory_checksum" @@ -178,11 +179,13 @@ func SyncResult() SyncResponse { func SyncSoundtracksFull() { syncSoundtracks(true) Reset() + ReloadSearchCache() } func SyncSoundtracksOnlyChanges() { syncSoundtracks(false) Reset() + ReloadSearchCache() } func syncSoundtracks(full bool) { @@ -295,6 +298,7 @@ func syncSoundtrack(file os.DirEntry, foldersToSkip []string, baseDir string, fu var status SoundtrackStatus = NewSoundtrack var oldSoundtrack repository.Soundtrack var id int32 = -1 + var soundtrackUuid uuid.UUID //fmt.Printf("Soundtracks before: %d\n", len(soundtracksBeforeSync)) @@ -304,16 +308,19 @@ func syncSoundtrack(file os.DirEntry, foldersToSkip []string, baseDir string, fu if oldSoundtrack.SoundtrackName == file.Name() && oldSoundtrack.Hash == dirHash { status = NotChanged id = oldSoundtrack.ID + soundtrackUuid = oldSoundtrack.Uuid //fmt.Printf("Soundtrack not changed\n") break } else if oldSoundtrack.SoundtrackName == file.Name() && oldSoundtrack.Hash != dirHash { status = SoundtrackChanged id = oldSoundtrack.ID + soundtrackUuid = oldSoundtrack.Uuid //fmt.Printf("Soundtrack changed\n") break } else if oldSoundtrack.SoundtrackName != file.Name() && oldSoundtrack.Hash == dirHash { status = TitleChanged id = oldSoundtrack.ID + soundtrackUuid = oldSoundtrack.Uuid //fmt.Printf("SoundtrackName changed\n") break } @@ -328,34 +335,37 @@ func syncSoundtrack(file os.DirEntry, foldersToSkip []string, baseDir string, fu } switch status { case NewSoundtrack: - id = insertSoundtrack(file.Name(), soundtrackDir, dirHash) + id, soundtrackUuid = insertSoundtrack(file.Name(), soundtrackDir, dirHash) logging.GetLogger().Debug("New soundtrack detected", zap.Int32("id", id), + zap.String("uuid", soundtrackUuid.String()), zap.String("soundtrack", file.Name()), zap.String("hash", dirHash), zap.String("status", status.String())) soundtracksAdded = append(soundtracksAdded, file.Name()) - checkSongs(entries, soundtrackDir, id) + checkSongs(entries, soundtrackDir, id, soundtrackUuid) case SoundtrackChanged: logging.GetLogger().Debug("Soundtrack changed", zap.Int32("id", id), + zap.String("uuid", soundtrackUuid.String()), zap.String("soundtrack", file.Name()), zap.String("hash", dirHash), zap.String("status", status.String())) err = repo.UpdateSoundtrackHash(BackendCtx(), repository.UpdateSoundtrackHashParams{Hash: dirHash, ID: id}) handleError("UpdateSoundtrackHash", err, "") soundtracksChangedContent = append(soundtracksChangedContent, file.Name()) - checkSongs(entries, soundtrackDir, id) + checkSongs(entries, soundtrackDir, id, soundtrackUuid) case TitleChanged: logging.GetLogger().Debug("Soundtrack title changed", zap.Int32("id", id), + zap.String("uuid", soundtrackUuid.String()), zap.String("oldName", oldSoundtrack.SoundtrackName), zap.String("newName", file.Name()), zap.String("hash", dirHash), zap.String("status", status.String())) err = repo.UpdateSoundtrackName(BackendCtx(), repository.UpdateSoundtrackNameParams{Name: file.Name(), Path: soundtrackDir, ID: id}) handleError("UpdateSoundtrackName", err, "") - checkSongs(entries, soundtrackDir, id) + checkSongs(entries, soundtrackDir, id, soundtrackUuid) if soundtracksChangedTitle == nil { soundtracksChangedTitle = make(map[string]string) } @@ -373,10 +383,11 @@ func syncSoundtrack(file os.DirEntry, foldersToSkip []string, baseDir string, fu } } if !found { - checkSongs(entries, soundtrackDir, id) + checkSongs(entries, soundtrackDir, id, soundtrackUuid) soundtracksReAdded = append(soundtracksReAdded, file.Name()) logging.GetLogger().Debug("Soundtrack added again", zap.Int32("id", id), + zap.String("uuid", soundtrackUuid.String()), zap.String("newName", file.Name()), zap.String("hash", dirHash), zap.String("status", status.String())) @@ -390,6 +401,15 @@ func syncSoundtrack(file os.DirEntry, foldersToSkip []string, baseDir string, fu zap.String("status", status.String())) err = repo.RemoveSoundtrackDeletionDate(BackendCtx(), id) handleError("RemoveSoundtrackDeletionDate", err, "") + + // Remove .id files and update hash if any were removed + if removeIdFilesInSoundtrack(soundtrackDir) { + newHash := getHashForDir(soundtrackDir) + if newHash != dirHash { + err = repo.UpdateSoundtrackHash(BackendCtx(), repository.UpdateSoundtrackHashParams{Hash: newHash, ID: id}) + handleError("UpdateSoundtrackHash for .id cleanup", err, "") + } + } } foldersSynced++ logging.GetLogger().Debug("Sync progress", @@ -398,24 +418,31 @@ func syncSoundtrack(file os.DirEntry, foldersToSkip []string, baseDir string, fu zap.Int("percent", int((foldersSynced/numberOfFoldersToSync)*100))) } -func insertSoundtrack(name string, path string, hash string) int32 { +func insertSoundtrack(name string, path string, hash string) (int32, uuid.UUID) { var duplicateError = errors.New("ERROR: duplicate key value violates unique") - id, err := repo.InsertSoundtrack(BackendCtx(), repository.InsertSoundtrackParams{SoundtrackName: name, Path: path, Hash: hash}) + soundtrackUuid := uuid.New() + result, err := repo.InsertSoundtrack(BackendCtx(), repository.InsertSoundtrackParams{ + SoundtrackName: name, + Path: path, + Hash: hash, + Uuid: soundtrackUuid, + }) handleError("InsertSoundtrack", err, "") if err != nil { - logging.GetLogger().Warn("ID collision detected, resetting sequence") + logging.GetLogger().Warn("ID collision or UUID duplicate detected, resetting sequence") if strings.HasPrefix(err.Error(), duplicateError.Error()) { logging.GetLogger().Debug("Resetting soundtrack ID sequence") _, err = repo.ResetSoundtrackIdSeq(BackendCtx()) handleError("ResetSoundtrackIdSeq", err, "") - id = insertSoundtrack(name, path, hash) + id, uuid := insertSoundtrack(name, path, hash) + return id, uuid } } - return id + return result.ID, result.Uuid } -func checkSongs(entries []os.DirEntry, soundtrackDir string, id int32) int32 { +func checkSongs(entries []os.DirEntry, soundtrackDir string, id int32, soundtrackUuid uuid.UUID) int32 { //hasher := md5.New() var numberOfSongs int32 numberOfFiles := len(entries) @@ -425,7 +452,7 @@ func checkSongs(entries []os.DirEntry, soundtrackDir string, id int32) int32 { for _, entry := range entries { poolSong.Submit(func() { defer songWg.Done() - if checkSong(entry, soundtrackDir, id) { + if checkSong(entry, soundtrackDir, id, soundtrackUuid) { numberOfSongs++ } }) @@ -434,7 +461,7 @@ func checkSongs(entries []os.DirEntry, soundtrackDir string, id int32) int32 { return numberOfSongs } -func checkSong(entry os.DirEntry, soundtrackDir string, id int32) bool { +func checkSong(entry os.DirEntry, soundtrackDir string, id int32, soundtrackUuid uuid.UUID) bool { fileInfo, err := entry.Info() if err != nil { logging.GetLogger().Error("Failed to get file info", zap.String("filename", entry.Name()), zap.String("error", err.Error())) @@ -487,8 +514,17 @@ func checkSong(entry os.DirEntry, soundtrackDir string, id int32) bool { err = repo.AddHashToSong(BackendCtx(), repository.AddHashToSongParams{Hash: songHash, SoundtrackID: id, Path: path}) handleError("AddHashToSong", err, fmt.Sprintf("SoundtrackID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash)) } else { - err = repo.AddSong(BackendCtx(), repository.AddSongParams{SoundtrackID: id, SongName: songName, Path: path, FileName: &fileName, Hash: songHash}) - handleError("AddSong", err, fmt.Sprintf("SoundtrackID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash)) + songUuid := uuid.New() + err = repo.AddSong(BackendCtx(), repository.AddSongParams{ + SoundtrackID: id, + SongName: songName, + Path: path, + FileName: &fileName, + Hash: songHash, + Uuid: songUuid, + SoundtrackUuid: soundtrackUuid, + }) + handleError("AddSong", err, fmt.Sprintf("SoundtrackID: %d | UUID: %s | Path: %s | SongName: %s | SongHash: %s", id, songUuid.String(), path, entry.Name(), songHash)) } } @@ -558,6 +594,33 @@ func isCoverImage(entry fs.FileInfo) bool { (strings.HasSuffix(entry.Name(), ".jpg") || strings.HasSuffix(entry.Name(), ".png")) } +func removeIdFilesInSoundtrack(soundtrackDir string) bool { + entries, err := os.ReadDir(soundtrackDir) + if err != nil { + logging.GetLogger().Error("Failed to read soundtrack directory for .id file cleanup", + zap.String("path", soundtrackDir), + zap.String("error", err.Error())) + return false + } + + anyRemoved := false + for _, entry := range entries { + if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".id") { + filePath := soundtrackDir + entry.Name() + err := os.Remove(filePath) + if err != nil { + logging.GetLogger().Error("Failed to remove .id file", + zap.String("path", filePath), + zap.String("error", err.Error())) + } else { + logging.GetLogger().Info("Removed .id file", zap.String("path", filePath)) + anyRemoved = true + } + } + } + return anyRemoved +} + func contains(s []string, searchTerm string) bool { i := sort.SearchStrings(s, searchTerm) return i < len(s) && s[i] == searchTerm diff --git a/internal/backend/version.go b/internal/backend/version.go index bc0d09c..650a1e4 100644 --- a/internal/backend/version.go +++ b/internal/backend/version.go @@ -7,8 +7,10 @@ type VersionData struct { var data = []VersionData{ { - Version: "5.0.0-Beta", + Version: "5.0.0", Changelog: []string{ + "#9 - Major routes refactor and expansion", + "#15 - Added API for statistics", "#16 - Upgrade Echo framework from v4 to v5", "#17 - Add Zap structured logging framework", "#18 - Add OpenAPI/Swagger documentation", @@ -17,6 +19,8 @@ var data = []VersionData{ "#21 - Refactor handlers into domain-specific files", "#22 - Change VersionData Changelog from string to string array", "#23 - Update all dependencies to latest versions", + "Added search cache backend", + "Backend sync improvements", }, }, { diff --git a/internal/db/migrations/000005_rename_game_to_soundtrack.down.sql b/internal/db/migrations/000005_rename_game_to_soundtrack.down.sql deleted file mode 100644 index f0236de..0000000 --- a/internal/db/migrations/000005_rename_game_to_soundtrack.down.sql +++ /dev/null @@ -1,33 +0,0 @@ --- Revert: Rename soundtrack table back to game -ALTER TABLE soundtrack RENAME TO game; - --- Revert primary key sequence -ALTER SEQUENCE soundtrack_id_seq RENAME TO game_id_seq; - --- Revert columns in game table -ALTER TABLE game RENAME COLUMN soundtrack_name TO game_name; - --- Revert song table: rename soundtrack_id back to game_id -ALTER TABLE song RENAME COLUMN soundtrack_id TO game_id; - --- Revert song primary key -ALTER TABLE song DROP CONSTRAINT IF EXISTS song_pkey; -ALTER TABLE song ADD PRIMARY KEY (game_id, path); -ALTER TABLE song RENAME CONSTRAINT song_pkey_soundtrack TO song_pkey; - --- Revert song_list table references -ALTER TABLE song_list RENAME COLUMN soundtrack_name TO game_name; - --- Revert foreign key constraint -ALTER TABLE song DROP CONSTRAINT IF EXISTS song_soundtrack_id_fkey; -ALTER TABLE song ADD CONSTRAINT song_game_id_fkey - FOREIGN KEY (game_id) REFERENCES game(id); - --- Revert indexes -ALTER INDEX IF EXISTS idx_soundtrack_deleted RENAME TO idx_game_deleted; -ALTER INDEX IF EXISTS idx_soundtrack_hash RENAME TO idx_game_hash; -ALTER INDEX IF EXISTS idx_soundtrack_path RENAME TO idx_game_path; -ALTER INDEX IF EXISTS idx_soundtrack_name RENAME TO idx_game_name; -ALTER INDEX IF EXISTS idx_song_soundtrack_id RENAME TO idx_song_game_id; -ALTER INDEX IF EXISTS idx_song_soundtrack_id_song_name RENAME TO idx_song_game_id_song_name; -ALTER INDEX IF EXISTS song_list_soundtrack_name_idx RENAME TO song_list_game_name_idx; diff --git a/internal/db/migrations/000005_rename_game_to_soundtrack.up.sql b/internal/db/migrations/000005_rename_game_to_soundtrack.up.sql deleted file mode 100644 index 17c119b..0000000 --- a/internal/db/migrations/000005_rename_game_to_soundtrack.up.sql +++ /dev/null @@ -1,32 +0,0 @@ --- Rename game table to soundtrack -ALTER TABLE game RENAME TO soundtrack; - --- Rename primary key sequence -ALTER SEQUENCE game_id_seq RENAME TO soundtrack_id_seq; - --- Rename columns in soundtrack table -ALTER TABLE soundtrack RENAME COLUMN game_name TO soundtrack_name; - --- Update song table: rename game_id to soundtrack_id -ALTER TABLE song RENAME COLUMN game_id TO soundtrack_id; - --- Update song primary key -ALTER TABLE song DROP CONSTRAINT IF EXISTS song_pkey; -ALTER TABLE song ADD PRIMARY KEY (soundtrack_id, path); - --- Update song_list table references -ALTER TABLE song_list RENAME COLUMN game_name TO soundtrack_name; - --- Rename foreign key constraint -ALTER TABLE song DROP CONSTRAINT IF EXISTS song_game_id_fkey; -ALTER TABLE song ADD CONSTRAINT song_soundtrack_id_fkey - FOREIGN KEY (soundtrack_id) REFERENCES soundtrack(id); - --- Rename indexes -ALTER INDEX IF EXISTS idx_game_deleted RENAME TO idx_soundtrack_deleted; -ALTER INDEX IF EXISTS idx_game_hash RENAME TO idx_soundtrack_hash; -ALTER INDEX IF EXISTS idx_game_path RENAME TO idx_soundtrack_path; -ALTER INDEX IF EXISTS idx_game_name RENAME TO idx_soundtrack_name; -ALTER INDEX IF EXISTS idx_song_game_id RENAME TO idx_song_soundtrack_id; -ALTER INDEX IF EXISTS idx_song_game_id_song_name RENAME TO idx_song_soundtrack_id_song_name; -ALTER INDEX IF EXISTS song_list_game_name_idx RENAME TO song_list_soundtrack_name_idx; diff --git a/internal/db/migrations/000006_add_id_to_song.down.sql b/internal/db/migrations/000006_add_id_to_song.down.sql deleted file mode 100644 index 58628bf..0000000 --- a/internal/db/migrations/000006_add_id_to_song.down.sql +++ /dev/null @@ -1,24 +0,0 @@ --- Rollback: Remove id column and restore composite PK - --- Step 1: Drop indexes created in up migration -DROP INDEX IF EXISTS idx_song_soundtrack_id; -DROP INDEX IF EXISTS idx_song_path; - --- Step 2: Drop foreign key constraint -ALTER TABLE song DROP CONSTRAINT IF EXISTS song_soundtrack_id_fkey; - --- Step 3: Drop new primary key -ALTER TABLE song DROP CONSTRAINT song_pkey; - --- Step 4: Drop unique constraint on id -ALTER TABLE song DROP CONSTRAINT IF EXISTS song_id_unique; - --- Step 5: Restore composite primary key -ALTER TABLE song ADD CONSTRAINT song_pkey PRIMARY KEY (soundtrack_id, path); - --- Step 6: Drop the id column -ALTER TABLE song DROP COLUMN id; - --- Step 7: Recreate original foreign key (soundtrack_id references soundtrack.id) -ALTER TABLE song ADD CONSTRAINT song_soundtrack_id_fkey - FOREIGN KEY (soundtrack_id) REFERENCES soundtrack(id); diff --git a/internal/db/migrations/000006_add_id_to_song.up.sql b/internal/db/migrations/000006_add_id_to_song.up.sql deleted file mode 100644 index ed1494d..0000000 --- a/internal/db/migrations/000006_add_id_to_song.up.sql +++ /dev/null @@ -1,36 +0,0 @@ --- Migration: Add id column to song table and change PK from composite to single column --- This prepares the song table for eventual UUID migration - --- Step 1: Add new id column (nullable initially) -ALTER TABLE song ADD COLUMN id serial4; - --- Step 2: Create unique constraint on id (allows backfilling) -ALTER TABLE song ADD CONSTRAINT song_id_unique UNIQUE (id); - --- Step 3: Backfill existing rows with sequential IDs --- Use DEFAULT which pulls from the sequence -UPDATE song SET id = DEFAULT WHERE id IS NULL; - --- Step 4: Verify all rows have an id --- If this returns 0, backfill worked --- SELECT COUNT(*) FROM song WHERE id IS NULL; - --- Step 5: Drop the composite primary key (soundtrack_id, path) -ALTER TABLE song DROP CONSTRAINT song_pkey; - --- Step 6: Add new primary key on id column -ALTER TABLE song ADD CONSTRAINT song_pkey PRIMARY KEY (id); - --- Step 7: Ensure soundtrack_id remains a foreign key to soundtrack --- First drop existing FK if it exists (from the rename migration) -ALTER TABLE song DROP CONSTRAINT IF EXISTS song_soundtrack_id_fkey; - --- Then recreate it -ALTER TABLE song ADD CONSTRAINT song_soundtrack_id_fkey - FOREIGN KEY (soundtrack_id) REFERENCES soundtrack(id); - --- Step 8: Create index on soundtrack_id for query performance -CREATE INDEX IF NOT EXISTS idx_song_soundtrack_id ON song(soundtrack_id); - --- Step 9: Create index on path for lookups (previously part of PK) -CREATE INDEX IF NOT EXISTS idx_song_path ON song(path); diff --git a/internal/db/queries/song.sql b/internal/db/queries/song.sql index 95064ca..d7dc1b4 100644 --- a/internal/db/queries/song.sql +++ b/internal/db/queries/song.sql @@ -5,7 +5,7 @@ DELETE FROM song; DELETE FROM song WHERE soundtrack_id = $1; -- name: AddSong :exec -INSERT INTO song(soundtrack_id, song_name, path, file_name, hash) VALUES ($1, $2, $3, $4, $5); +INSERT INTO song(soundtrack_id, song_name, path, file_name, hash, uuid, soundtrack_uuid) VALUES ($1, $2, $3, $4, $5, $6, $7); -- name: CheckSong :one SELECT COUNT(*) FROM song WHERE soundtrack_id = $1 AND path = $2; @@ -34,11 +34,19 @@ WHERE soundtrack_id = $1 AND song_name = $2; -- name: FetchAllSongs :many SELECT * FROM song; --- name: GetSongById :one -SELECT * FROM song WHERE id = $1; +-- name: GetSongByUUID :one +SELECT * FROM song WHERE uuid = $1; -- name: RemoveBrokenSong :exec DELETE FROM song WHERE soundtrack_id = $1 AND path = $2; -- name: RemoveBrokenSongs :exec -DELETE FROM song WHERE id = ANY($1); +DELETE FROM song WHERE uuid = ANY($1); + +-- name: GetSongsBySoundtrackUUID :many +SELECT * +FROM song +WHERE soundtrack_uuid = $1; + +-- name: GetSongUUIDsBySoundtrackId :many +SELECT uuid, soundtrack_uuid FROM song WHERE soundtrack_id = $1; diff --git a/internal/db/queries/soundtrack.sql b/internal/db/queries/soundtrack.sql index 1de82d5..863e4a3 100644 --- a/internal/db/queries/soundtrack.sql +++ b/internal/db/queries/soundtrack.sql @@ -29,7 +29,7 @@ UPDATE soundtrack SET deleted=NULL WHERE id=$1; SELECT id FROM soundtrack WHERE soundtrack_name = $1; -- name: InsertSoundtrack :one -INSERT INTO soundtrack (soundtrack_name, path, hash, added) VALUES ($1, $2, $3, now()) returning id; +INSERT INTO soundtrack (soundtrack_name, path, hash, added, uuid) VALUES ($1, $2, $3, now(), $4) returning id, uuid; -- name: InsertSoundtrackWithExistingId :exec INSERT INTO soundtrack (id, soundtrack_name, path, hash, added) VALUES ($1, $2, $3, $4, now()); @@ -47,3 +47,12 @@ ORDER BY soundtrack_name; -- name: AddSoundtrackPlayed :exec UPDATE soundtrack SET times_played = times_played + 1, last_played = now() WHERE id = $1; + +-- name: GetSoundtrackByUUID :one +SELECT * +FROM soundtrack +WHERE uuid = $1 +AND deleted IS NULL; + +-- name: GetSoundtrackUUIDById :one +SELECT uuid FROM soundtrack WHERE id = $1; diff --git a/internal/db/repository/models.go b/internal/db/repository/models.go index 1a672cf..010d6c6 100644 --- a/internal/db/repository/models.go +++ b/internal/db/repository/models.go @@ -7,6 +7,7 @@ package repository import ( "time" + "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" ) @@ -20,13 +21,14 @@ type Session struct { } type Song struct { - SoundtrackID int32 `json:"soundtrack_id"` - SongName string `json:"song_name"` - Path string `json:"path"` - TimesPlayed int32 `json:"times_played"` - Hash string `json:"hash"` - FileName *string `json:"file_name"` - ID pgtype.Int4 `json:"id"` + SoundtrackID int32 `json:"soundtrack_id"` + SongName string `json:"song_name"` + Path string `json:"path"` + TimesPlayed int32 `json:"times_played"` + Hash string `json:"hash"` + FileName *string `json:"file_name"` + Uuid uuid.UUID `json:"uuid"` + SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"` } type SongList struct { @@ -48,6 +50,7 @@ type Soundtrack struct { LastPlayed *time.Time `json:"last_played"` NumberOfSongs int32 `json:"number_of_songs"` Hash string `json:"hash"` + Uuid uuid.UUID `json:"uuid"` } type Vgmq struct { diff --git a/internal/db/repository/song.sql.go b/internal/db/repository/song.sql.go index 048f630..58840f6 100644 --- a/internal/db/repository/song.sql.go +++ b/internal/db/repository/song.sql.go @@ -8,7 +8,7 @@ package repository import ( "context" - "github.com/jackc/pgx/v5/pgtype" + "github.com/google/uuid" ) const addHashToSong = `-- name: AddHashToSong :exec @@ -27,15 +27,17 @@ func (q *Queries) AddHashToSong(ctx context.Context, arg AddHashToSongParams) er } const addSong = `-- name: AddSong :exec -INSERT INTO song(soundtrack_id, song_name, path, file_name, hash) VALUES ($1, $2, $3, $4, $5) +INSERT INTO song(soundtrack_id, song_name, path, file_name, hash, uuid, soundtrack_uuid) VALUES ($1, $2, $3, $4, $5, $6, $7) ` type AddSongParams struct { - SoundtrackID int32 `json:"soundtrack_id"` - SongName string `json:"song_name"` - Path string `json:"path"` - FileName *string `json:"file_name"` - Hash string `json:"hash"` + SoundtrackID int32 `json:"soundtrack_id"` + SongName string `json:"song_name"` + Path string `json:"path"` + FileName *string `json:"file_name"` + Hash string `json:"hash"` + Uuid uuid.UUID `json:"uuid"` + SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"` } func (q *Queries) AddSong(ctx context.Context, arg AddSongParams) error { @@ -45,6 +47,8 @@ func (q *Queries) AddSong(ctx context.Context, arg AddSongParams) error { arg.Path, arg.FileName, arg.Hash, + arg.Uuid, + arg.SoundtrackUuid, ) return err } @@ -110,7 +114,7 @@ func (q *Queries) ClearSongsBySoundtrackId(ctx context.Context, soundtrackID int } const fetchAllSongs = `-- name: FetchAllSongs :many -SELECT soundtrack_id, song_name, path, times_played, hash, file_name, id FROM song +SELECT soundtrack_id, song_name, path, times_played, hash, file_name, uuid, soundtrack_uuid FROM song ` func (q *Queries) FetchAllSongs(ctx context.Context) ([]Song, error) { @@ -129,7 +133,8 @@ func (q *Queries) FetchAllSongs(ctx context.Context) ([]Song, error) { &i.TimesPlayed, &i.Hash, &i.FileName, - &i.ID, + &i.Uuid, + &i.SoundtrackUuid, ); err != nil { return nil, err } @@ -142,7 +147,7 @@ func (q *Queries) FetchAllSongs(ctx context.Context) ([]Song, error) { } const findSongsFromSoundtrack = `-- name: FindSongsFromSoundtrack :many -SELECT soundtrack_id, song_name, path, times_played, hash, file_name, id +SELECT soundtrack_id, song_name, path, times_played, hash, file_name, uuid, soundtrack_uuid FROM song WHERE soundtrack_id = $1 ` @@ -163,7 +168,8 @@ func (q *Queries) FindSongsFromSoundtrack(ctx context.Context, soundtrackID int3 &i.TimesPlayed, &i.Hash, &i.FileName, - &i.ID, + &i.Uuid, + &i.SoundtrackUuid, ); err != nil { return nil, err } @@ -175,12 +181,12 @@ func (q *Queries) FindSongsFromSoundtrack(ctx context.Context, soundtrackID int3 return items, nil } -const getSongById = `-- name: GetSongById :one -SELECT soundtrack_id, song_name, path, times_played, hash, file_name, id FROM song WHERE id = $1 +const getSongByUUID = `-- name: GetSongByUUID :one +SELECT soundtrack_id, song_name, path, times_played, hash, file_name, uuid, soundtrack_uuid FROM song WHERE uuid = $1 ` -func (q *Queries) GetSongById(ctx context.Context, id pgtype.Int4) (Song, error) { - row := q.db.QueryRow(ctx, getSongById, id) +func (q *Queries) GetSongByUUID(ctx context.Context, argUuid uuid.UUID) (Song, error) { + row := q.db.QueryRow(ctx, getSongByUUID, argUuid) var i Song err := row.Scan( &i.SoundtrackID, @@ -189,13 +195,43 @@ func (q *Queries) GetSongById(ctx context.Context, id pgtype.Int4) (Song, error) &i.TimesPlayed, &i.Hash, &i.FileName, - &i.ID, + &i.Uuid, + &i.SoundtrackUuid, ) return i, err } +const getSongUUIDsBySoundtrackId = `-- name: GetSongUUIDsBySoundtrackId :many +SELECT uuid, soundtrack_uuid FROM song WHERE soundtrack_id = $1 +` + +type GetSongUUIDsBySoundtrackIdRow struct { + Uuid uuid.UUID `json:"uuid"` + SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"` +} + +func (q *Queries) GetSongUUIDsBySoundtrackId(ctx context.Context, soundtrackID int32) ([]GetSongUUIDsBySoundtrackIdRow, error) { + rows, err := q.db.Query(ctx, getSongUUIDsBySoundtrackId, soundtrackID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetSongUUIDsBySoundtrackIdRow + for rows.Next() { + var i GetSongUUIDsBySoundtrackIdRow + if err := rows.Scan(&i.Uuid, &i.SoundtrackUuid); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getSongWithHash = `-- name: GetSongWithHash :one -SELECT soundtrack_id, song_name, path, times_played, hash, file_name, id FROM song WHERE hash = $1 +SELECT soundtrack_id, song_name, path, times_played, hash, file_name, uuid, soundtrack_uuid FROM song WHERE hash = $1 ` func (q *Queries) GetSongWithHash(ctx context.Context, hash string) (Song, error) { @@ -208,11 +244,47 @@ func (q *Queries) GetSongWithHash(ctx context.Context, hash string) (Song, error &i.TimesPlayed, &i.Hash, &i.FileName, - &i.ID, + &i.Uuid, + &i.SoundtrackUuid, ) return i, err } +const getSongsBySoundtrackUUID = `-- name: GetSongsBySoundtrackUUID :many +SELECT soundtrack_id, song_name, path, times_played, hash, file_name, uuid, soundtrack_uuid +FROM song +WHERE soundtrack_uuid = $1 +` + +func (q *Queries) GetSongsBySoundtrackUUID(ctx context.Context, soundtrackUuid uuid.UUID) ([]Song, error) { + rows, err := q.db.Query(ctx, getSongsBySoundtrackUUID, soundtrackUuid) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Song + for rows.Next() { + var i Song + if err := rows.Scan( + &i.SoundtrackID, + &i.SongName, + &i.Path, + &i.TimesPlayed, + &i.Hash, + &i.FileName, + &i.Uuid, + &i.SoundtrackUuid, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const removeBrokenSong = `-- name: RemoveBrokenSong :exec DELETE FROM song WHERE soundtrack_id = $1 AND path = $2 ` @@ -228,11 +300,11 @@ func (q *Queries) RemoveBrokenSong(ctx context.Context, arg RemoveBrokenSongPara } const removeBrokenSongs = `-- name: RemoveBrokenSongs :exec -DELETE FROM song WHERE id = ANY($1) +DELETE FROM song WHERE uuid = ANY($1) ` -func (q *Queries) RemoveBrokenSongs(ctx context.Context, id pgtype.Int4) error { - _, err := q.db.Exec(ctx, removeBrokenSongs, id) +func (q *Queries) RemoveBrokenSongs(ctx context.Context, argUuid uuid.UUID) error { + _, err := q.db.Exec(ctx, removeBrokenSongs, argUuid) return err } diff --git a/internal/db/repository/soundtrack.sql.go b/internal/db/repository/soundtrack.sql.go index bc38704..97bc547 100644 --- a/internal/db/repository/soundtrack.sql.go +++ b/internal/db/repository/soundtrack.sql.go @@ -7,6 +7,8 @@ package repository import ( "context" + + "github.com/google/uuid" ) const addSoundtrackPlayed = `-- name: AddSoundtrackPlayed :exec @@ -28,7 +30,7 @@ func (q *Queries) ClearSoundtracks(ctx context.Context) error { } const findAllSoundtracks = `-- name: FindAllSoundtracks :many -SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash +SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid FROM soundtrack WHERE deleted IS NULL ORDER BY soundtrack_name @@ -54,6 +56,7 @@ func (q *Queries) FindAllSoundtracks(ctx context.Context) ([]Soundtrack, error) &i.LastPlayed, &i.NumberOfSongs, &i.Hash, + &i.Uuid, ); err != nil { return nil, err } @@ -66,7 +69,7 @@ func (q *Queries) FindAllSoundtracks(ctx context.Context) ([]Soundtrack, error) } const getAllSoundtracksIncludingDeleted = `-- name: GetAllSoundtracksIncludingDeleted :many -SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash +SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid FROM soundtrack ORDER BY soundtrack_name ` @@ -91,6 +94,7 @@ func (q *Queries) GetAllSoundtracksIncludingDeleted(ctx context.Context) ([]Soun &i.LastPlayed, &i.NumberOfSongs, &i.Hash, + &i.Uuid, ); err != nil { return nil, err } @@ -114,7 +118,7 @@ func (q *Queries) GetIdBySoundtrackName(ctx context.Context, soundtrackName stri } const getSoundtrackById = `-- name: GetSoundtrackById :one -SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash +SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid FROM soundtrack WHERE id = $1 AND deleted IS NULL @@ -134,6 +138,33 @@ func (q *Queries) GetSoundtrackById(ctx context.Context, id int32) (Soundtrack, &i.LastPlayed, &i.NumberOfSongs, &i.Hash, + &i.Uuid, + ) + return i, err +} + +const getSoundtrackByUUID = `-- name: GetSoundtrackByUUID :one +SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid +FROM soundtrack +WHERE uuid = $1 +AND deleted IS NULL +` + +func (q *Queries) GetSoundtrackByUUID(ctx context.Context, argUuid uuid.UUID) (Soundtrack, error) { + row := q.db.QueryRow(ctx, getSoundtrackByUUID, argUuid) + var i Soundtrack + err := row.Scan( + &i.ID, + &i.SoundtrackName, + &i.Added, + &i.Deleted, + &i.LastChanged, + &i.Path, + &i.TimesPlayed, + &i.LastPlayed, + &i.NumberOfSongs, + &i.Hash, + &i.Uuid, ) return i, err } @@ -149,21 +180,43 @@ func (q *Queries) GetSoundtrackNameById(ctx context.Context, id int32) (string, return soundtrack_name, err } +const getSoundtrackUUIDById = `-- name: GetSoundtrackUUIDById :one +SELECT uuid FROM soundtrack WHERE id = $1 +` + +func (q *Queries) GetSoundtrackUUIDById(ctx context.Context, id int32) (uuid.UUID, error) { + row := q.db.QueryRow(ctx, getSoundtrackUUIDById, id) + var uuid uuid.UUID + err := row.Scan(&uuid) + return uuid, err +} + const insertSoundtrack = `-- name: InsertSoundtrack :one -INSERT INTO soundtrack (soundtrack_name, path, hash, added) VALUES ($1, $2, $3, now()) returning id +INSERT INTO soundtrack (soundtrack_name, path, hash, added, uuid) VALUES ($1, $2, $3, now(), $4) returning id, uuid ` type InsertSoundtrackParams struct { - SoundtrackName string `json:"soundtrack_name"` - Path string `json:"path"` - Hash string `json:"hash"` + SoundtrackName string `json:"soundtrack_name"` + Path string `json:"path"` + Hash string `json:"hash"` + Uuid uuid.UUID `json:"uuid"` } -func (q *Queries) InsertSoundtrack(ctx context.Context, arg InsertSoundtrackParams) (int32, error) { - row := q.db.QueryRow(ctx, insertSoundtrack, arg.SoundtrackName, arg.Path, arg.Hash) - var id int32 - err := row.Scan(&id) - return id, err +type InsertSoundtrackRow struct { + ID int32 `json:"id"` + Uuid uuid.UUID `json:"uuid"` +} + +func (q *Queries) InsertSoundtrack(ctx context.Context, arg InsertSoundtrackParams) (InsertSoundtrackRow, error) { + row := q.db.QueryRow(ctx, insertSoundtrack, + arg.SoundtrackName, + arg.Path, + arg.Hash, + arg.Uuid, + ) + var i InsertSoundtrackRow + err := row.Scan(&i.ID, &i.Uuid) + return i, err } const insertSoundtrackWithExistingId = `-- name: InsertSoundtrackWithExistingId :exec diff --git a/internal/db/repository/statistics.sql.go b/internal/db/repository/statistics.sql.go index 603dd01..851062e 100644 --- a/internal/db/repository/statistics.sql.go +++ b/internal/db/repository/statistics.sql.go @@ -66,63 +66,6 @@ func (q *Queries) GetLastPlayedSoundtracks(ctx context.Context, limit int32) ([] return items, nil } -const getLeastPlayedSoundtracksWithSongs = `-- name: GetLeastPlayedSoundtracksWithSongs :many -SELECT - g.id as soundtrack_id, - g.soundtrack_name, - g.times_played as soundtrack_played, - g.last_played as soundtrack_last_played, - json_agg( - json_build_object( - 'song_name', s.song_name, - 'path', s.path, - 'times_played', s.times_played, - 'file_name', s.file_name - ) - ) as songs -FROM soundtrack g -LEFT JOIN song s ON g.id = s.soundtrack_id -WHERE g.deleted IS NULL -GROUP BY g.id, g.soundtrack_name, g.times_played, g.last_played -ORDER BY g.times_played ASC, g.soundtrack_name -LIMIT $1 -` - -type GetLeastPlayedSoundtracksWithSongsRow struct { - SoundtrackID int32 `json:"soundtrack_id"` - SoundtrackName string `json:"soundtrack_name"` - SoundtrackPlayed int32 `json:"soundtrack_played"` - SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"` - Songs []byte `json:"songs"` -} - -// Least played soundtracks with their songs -func (q *Queries) GetLeastPlayedSoundtracksWithSongs(ctx context.Context, limit int32) ([]GetLeastPlayedSoundtracksWithSongsRow, error) { - rows, err := q.db.Query(ctx, getLeastPlayedSoundtracksWithSongs, limit) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetLeastPlayedSoundtracksWithSongsRow - for rows.Next() { - var i GetLeastPlayedSoundtracksWithSongsRow - if err := rows.Scan( - &i.SoundtrackID, - &i.SoundtrackName, - &i.SoundtrackPlayed, - &i.SoundtrackLastPlayed, - &i.Songs, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - const getLeastPlayedSongsWithSoundtrack = `-- name: GetLeastPlayedSongsWithSoundtrack :many SELECT s.soundtrack_id as soundtrack_id, @@ -175,7 +118,7 @@ func (q *Queries) GetLeastPlayedSongsWithSoundtrack(ctx context.Context, limit i return items, nil } -const getMostPlayedSoundtracksWithSongs = `-- name: GetMostPlayedSoundtracksWithSongs :many +const getLeastPlayedSoundtracksWithSongs = `-- name: GetLeastPlayedSoundtracksWithSongs :many SELECT g.id as soundtrack_id, g.soundtrack_name, @@ -193,11 +136,11 @@ FROM soundtrack g LEFT JOIN song s ON g.id = s.soundtrack_id WHERE g.deleted IS NULL GROUP BY g.id, g.soundtrack_name, g.times_played, g.last_played -ORDER BY g.times_played DESC, g.soundtrack_name +ORDER BY g.times_played ASC, g.soundtrack_name LIMIT $1 ` -type GetMostPlayedSoundtracksWithSongsRow struct { +type GetLeastPlayedSoundtracksWithSongsRow struct { SoundtrackID int32 `json:"soundtrack_id"` SoundtrackName string `json:"soundtrack_name"` SoundtrackPlayed int32 `json:"soundtrack_played"` @@ -205,16 +148,16 @@ type GetMostPlayedSoundtracksWithSongsRow struct { Songs []byte `json:"songs"` } -// Most played soundtracks with their songs -func (q *Queries) GetMostPlayedSoundtracksWithSongs(ctx context.Context, limit int32) ([]GetMostPlayedSoundtracksWithSongsRow, error) { - rows, err := q.db.Query(ctx, getMostPlayedSoundtracksWithSongs, limit) +// Least played soundtracks with their songs +func (q *Queries) GetLeastPlayedSoundtracksWithSongs(ctx context.Context, limit int32) ([]GetLeastPlayedSoundtracksWithSongsRow, error) { + rows, err := q.db.Query(ctx, getLeastPlayedSoundtracksWithSongs, limit) if err != nil { return nil, err } defer rows.Close() - var items []GetMostPlayedSoundtracksWithSongsRow + var items []GetLeastPlayedSoundtracksWithSongsRow for rows.Next() { - var i GetMostPlayedSoundtracksWithSongsRow + var i GetLeastPlayedSoundtracksWithSongsRow if err := rows.Scan( &i.SoundtrackID, &i.SoundtrackName, @@ -284,6 +227,63 @@ func (q *Queries) GetMostPlayedSongsWithSoundtrack(ctx context.Context, limit in return items, nil } +const getMostPlayedSoundtracksWithSongs = `-- name: GetMostPlayedSoundtracksWithSongs :many +SELECT + g.id as soundtrack_id, + g.soundtrack_name, + g.times_played as soundtrack_played, + g.last_played as soundtrack_last_played, + json_agg( + json_build_object( + 'song_name', s.song_name, + 'path', s.path, + 'times_played', s.times_played, + 'file_name', s.file_name + ) + ) as songs +FROM soundtrack g +LEFT JOIN song s ON g.id = s.soundtrack_id +WHERE g.deleted IS NULL +GROUP BY g.id, g.soundtrack_name, g.times_played, g.last_played +ORDER BY g.times_played DESC, g.soundtrack_name +LIMIT $1 +` + +type GetMostPlayedSoundtracksWithSongsRow struct { + SoundtrackID int32 `json:"soundtrack_id"` + SoundtrackName string `json:"soundtrack_name"` + SoundtrackPlayed int32 `json:"soundtrack_played"` + SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"` + Songs []byte `json:"songs"` +} + +// Most played soundtracks with their songs +func (q *Queries) GetMostPlayedSoundtracksWithSongs(ctx context.Context, limit int32) ([]GetMostPlayedSoundtracksWithSongsRow, error) { + rows, err := q.db.Query(ctx, getMostPlayedSoundtracksWithSongs, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetMostPlayedSoundtracksWithSongsRow + for rows.Next() { + var i GetMostPlayedSoundtracksWithSongsRow + if err := rows.Scan( + &i.SoundtrackID, + &i.SoundtrackName, + &i.SoundtrackPlayed, + &i.SoundtrackLastPlayed, + &i.Songs, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getNeverPlayedSoundtracks = `-- name: GetNeverPlayedSoundtracks :many SELECT g.id as soundtrack_id, diff --git a/internal/server/middleware/token_auth.go b/internal/server/middleware/token_auth.go index 70f24cc..93656f1 100644 --- a/internal/server/middleware/token_auth.go +++ b/internal/server/middleware/token_auth.go @@ -2,6 +2,7 @@ package middleware import ( "net/http" + "os" "strings" "time" @@ -17,6 +18,15 @@ import ( func TokenAuthMiddleware(pool *pgxpool.Pool) echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c *echo.Context) error { + // Session auth disable bypass - allow all requests through without token validation + disableSessionAuth := os.Getenv("DISABLE_SESSION_AUTH") == "true" + if disableSessionAuth { + logging.GetLogger().Debug("Session auth disabled - bypassing token authentication", + zap.String("path", c.Request().URL.Path), + zap.String("ip", c.RealIP())) + return next(c) + } + // Extract token from Authorization header authHeader := c.Request().Header.Get("Authorization") if authHeader == "" { diff --git a/internal/server/routes.go b/internal/server/routes.go index ffa06f1..6e6b495 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -7,6 +7,7 @@ import ( "net/http" "sort" "strings" + "time" "github.com/a-h/templ" "github.com/labstack/echo/v5" @@ -52,7 +53,6 @@ func (s *Server) RegisterRoutes() http.Handler { e.GET("/search", echo.WrapHandler(templ.Handler(web.SearchForm()))) e.POST("/find", echo.WrapHandler(http.HandlerFunc(web.FindSoundtrackWebHandler))) - e.POST("/findfuzzy", echo.WrapHandler(http.HandlerFunc(web.FindSoundtrackFuzzyWebHandler))) e.Static("/", "/frontend") @@ -117,53 +117,162 @@ func (s *Server) RegisterRoutes() http.Handler { // Create /api/v1 group apiV1 := e.Group("/api/v1") - // Public endpoints - no token required - apiV1.POST("/token", func(c *echo.Context) error { + // Create rate limiter for token creation: 10 requests per minute per IP + tokenRateLimiter := middleware.NewRateLimiter(10, time.Minute) + tokenRateLimitMiddleware := middleware.RateLimitMiddleware(tokenRateLimiter) + + // Token endpoints (public - no token required) + tokenGroup := apiV1.Group("/token") + tokenGroup.POST("", func(c *echo.Context) error { return s.tokenHandler.CreateTokenHandler(c) - }) - apiV1.DELETE("/token", func(c *echo.Context) error { + }, tokenRateLimitMiddleware) + tokenGroup.DELETE("", func(c *echo.Context) error { return s.tokenHandler.DeleteTokenHandler(c) }) - apiV1.POST("/token/cleanup", func(c *echo.Context) error { + tokenGroup.POST("/cleanup", func(c *echo.Context) error { return s.tokenHandler.CleanupExpiredSessionsHandler(c) }) // Protected endpoints - require valid token // Create token auth middleware with pool access tokenAuthMiddleware := middleware.TokenAuthMiddleware(s.db.Pool) - - // Protected group with token authentication protectedV1 := apiV1.Group("", tokenAuthMiddleware) - // Statistics API endpoints (protected by token auth) - statistics := s.statisticsHandler - protectedV1.GET("/statistics/soundtracks/most-played", func(c *echo.Context) error { - return statistics.GetMostPlayedSoundtracks(c) + // Statistics endpoints (protected) + statisticsHandler := s.statisticsHandler + statsGroup := protectedV1.Group("/statistics") + statsGroup.GET("/soundtracks/most-played", func(c *echo.Context) error { + return statisticsHandler.GetMostPlayedSoundtracks(c) }) - protectedV1.GET("/statistics/soundtracks/least-played", func(c *echo.Context) error { - return statistics.GetLeastPlayedSoundtracks(c) + statsGroup.GET("/soundtracks/least-played", func(c *echo.Context) error { + return statisticsHandler.GetLeastPlayedSoundtracks(c) }) - protectedV1.GET("/statistics/soundtracks/never-played", func(c *echo.Context) error { - return statistics.GetNeverPlayedSoundtracks(c) + statsGroup.GET("/soundtracks/never-played", func(c *echo.Context) error { + return statisticsHandler.GetNeverPlayedSoundtracks(c) }) - protectedV1.GET("/statistics/soundtracks/last-played", func(c *echo.Context) error { - return statistics.GetLastPlayedSoundtracks(c) + statsGroup.GET("/soundtracks/last-played", func(c *echo.Context) error { + return statisticsHandler.GetLastPlayedSoundtracks(c) }) - protectedV1.GET("/statistics/soundtracks/oldest-played", func(c *echo.Context) error { - return statistics.GetOldestPlayedSoundtracks(c) + statsGroup.GET("/soundtracks/oldest-played", func(c *echo.Context) error { + return statisticsHandler.GetOldestPlayedSoundtracks(c) }) - protectedV1.GET("/statistics/songs/most-played", func(c *echo.Context) error { - return statistics.GetMostPlayedSongs(c) + statsGroup.GET("/songs/most-played", func(c *echo.Context) error { + return statisticsHandler.GetMostPlayedSongs(c) }) - protectedV1.GET("/statistics/songs/least-played", func(c *echo.Context) error { - return statistics.GetLeastPlayedSongs(c) + statsGroup.GET("/songs/least-played", func(c *echo.Context) error { + return statisticsHandler.GetLeastPlayedSongs(c) }) - protectedV1.GET("/statistics/summary", func(c *echo.Context) error { - return statistics.GetStatisticsSummary(c) + statsGroup.GET("/summary", func(c *echo.Context) error { + return statisticsHandler.GetStatisticsSummary(c) }) - // Future: VGMQ endpoints will be added to protectedV1 group - _ = protectedV1 // Use the variable to avoid unused variable error + // Version endpoints (protected) + versionGroup := protectedV1.Group("/version") + versionGroup.GET("", func(c *echo.Context) error { + return version.GetLatestVersion(c) + }) + versionGroup.GET("/history", func(c *echo.Context) error { + return version.GetVersionHistory(c) + }) + + // Character endpoints (protected) + characterGroup := protectedV1.Group("/character") + characterGroup.GET("", func(c *echo.Context) error { + return character.GetCharacter(c) + }) + characterGroup.GET("/list", func(c *echo.Context) error { + return character.GetCharacterList(c) + }) + + // Download endpoints (protected) + downloadGroup := protectedV1.Group("/download") + downloadGroup.GET("", func(c *echo.Context) error { + return download.checkLatest(c) + }) + downloadGroup.GET("/list", func(c *echo.Context) error { + return download.listAssetsOfLatest(c) + }) + downloadGroup.GET("/windows", func(c *echo.Context) error { + return download.downloadLatestWindows(c) + }) + downloadGroup.GET("/linux", func(c *echo.Context) error { + return download.downloadLatestLinux(c) + }) + + // Sync endpoints (protected) + protectedSyncGroup := protectedV1.Group("/sync") + protectedSyncGroup.GET("", func(c *echo.Context) error { + return sync.SyncSoundtracksNewOnlyChanges(c) + }) + protectedSyncGroup.GET("/progress", func(c *echo.Context) error { + return sync.SyncProgress(c) + }) + protectedSyncGroup.GET("/new", func(c *echo.Context) error { + return sync.SyncSoundtracksNewOnlyChanges(c) + }) + protectedSyncGroup.GET("/full", func(c *echo.Context) error { + return sync.SyncSoundtracksNewFull(c) + }) + protectedSyncGroup.GET("/new/full", func(c *echo.Context) error { + return sync.SyncSoundtracksNewFull(c) + }) + protectedSyncGroup.GET("/quick", func(c *echo.Context) error { + return sync.SyncSoundtracksNewOnlyChanges(c) + }) + protectedSyncGroup.GET("/reset", func(c *echo.Context) error { + return sync.ResetDB(c) + }) + + // Music endpoints (protected) + protectedMusicGroup := protectedV1.Group("/music") + protectedMusicGroup.GET("", func(c *echo.Context) error { + return music.GetSong(c) + }) + protectedMusicGroup.GET("/soundTest", func(c *echo.Context) error { + return music.GetSoundCheckSong(c) + }) + protectedMusicGroup.GET("/reset", func(c *echo.Context) error { + return music.ResetMusic(c) + }) + protectedMusicGroup.GET("/rand", func(c *echo.Context) error { + return music.GetRandomSong(c) + }) + protectedMusicGroup.GET("/rand/low", func(c *echo.Context) error { + return music.GetRandomSongLowChance(c) + }) + protectedMusicGroup.GET("/rand/classic", func(c *echo.Context) error { + return music.GetRandomSongClassic(c) + }) + protectedMusicGroup.GET("/info", func(c *echo.Context) error { + return music.GetSongInfo(c) + }) + protectedMusicGroup.GET("/list", func(c *echo.Context) error { + return music.GetPlayedSongs(c) + }) + protectedMusicGroup.GET("/next", func(c *echo.Context) error { + return music.GetNextSong(c) + }) + protectedMusicGroup.GET("/previous", func(c *echo.Context) error { + return music.GetPreviousSong(c) + }) + protectedMusicGroup.GET("/all", func(c *echo.Context) error { + return music.GetAllSoundtracksRandom(c) + }) + protectedMusicGroup.GET("/all/order", func(c *echo.Context) error { + return music.GetAllSoundtracks(c) + }) + protectedMusicGroup.GET("/all/random", func(c *echo.Context) error { + return music.GetAllSoundtracksRandom(c) + }) + protectedMusicGroup.PUT("/played", func(c *echo.Context) error { + return music.PutPlayed(c) + }) + protectedMusicGroup.GET("/addQue", func(c *echo.Context) error { + return music.AddLatestToQue(c) + }) + protectedMusicGroup.GET("/addPlayed", func(c *echo.Context) error { + return music.AddLatestPlayed(c) + }) routes := e.Router().Routes() sort.Slice(routes, func(i, j int) bool { diff --git a/internal/server/server.go b/internal/server/server.go index 2c6fbc5..a4e8072 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -15,19 +15,14 @@ import ( ) type Server struct { - port int - db *db.Database - tokenHandler *TokenHandler + port int + db *db.Database + tokenHandler *TokenHandler statisticsHandler *StatisticsHandler - httpServer *http.Server + httpServer *http.Server } var ( - host = os.Getenv("DB_HOST") - dbPort = os.Getenv("DB_PORT") - dbName = os.Getenv("DB_NAME") - username = os.Getenv("DB_USERNAME") - password = os.Getenv("DB_PASSWORD") musicPath = os.Getenv("MUSIC_PATH") charactersPath = os.Getenv("CHARACTERS_PATH") logLevel = os.Getenv("LOG_LEVEL") @@ -37,11 +32,20 @@ var ( // NewServerInstance creates a new Server instance with all dependencies initialized. // Use this for dependency injection and proper lifecycle management. func NewServerInstance() *Server { + // Re-read environment variables at server creation time (not at package import time) + // This allows tests to override environment variables + host := os.Getenv("DB_HOST") + dbPort := os.Getenv("DB_PORT") + dbName := os.Getenv("DB_NAME") + username := os.Getenv("DB_USERNAME") + password := os.Getenv("DB_PASSWORD") + // Initialize logger - if logLevel == "" { - logLevel = "info" + currentLogLevel := logLevel + if currentLogLevel == "" { + currentLogLevel = "info" } - logging.Init(logLevel, logJSON) + logging.Init(currentLogLevel, logJSON) logger := logging.GetLogger() @@ -68,15 +72,15 @@ func NewServerInstance() *Server { // Initialize token handler with database pool tokenHandler := NewTokenHandler(database.Pool) - + // Initialize statistics handler statisticsHandler := NewStatisticsHandler() // Create the server instance appServer := &Server{ - port: port, - db: database, - tokenHandler: tokenHandler, + port: port, + db: database, + tokenHandler: tokenHandler, statisticsHandler: statisticsHandler, } diff --git a/internal/server/sql_dump_migration_test.go b/internal/server/sql_dump_migration_test.go new file mode 100644 index 0000000..bde802c --- /dev/null +++ b/internal/server/sql_dump_migration_test.go @@ -0,0 +1,411 @@ +package server + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" +) + +// saveEnvVars saves the current state of relevant environment variables +func saveEnvVars() map[string]string { + vars := []string{ + "DB_HOST", "DB_PORT", "DB_USERNAME", "DB_PASSWORD", "DB_NAME", + "MUSIC_PATH", "CHARACTERS_PATH", "PORT", "DISABLE_SESSION_AUTH", "LOG_LEVEL", + } + saved := make(map[string]string) + for _, v := range vars { + saved[v] = os.Getenv(v) + } + return saved +} + +// restoreEnvVars restores environment variables from a saved state +func restoreEnvVars(saved map[string]string) { + for k, v := range saved { + if v == "" { + os.Unsetenv(k) + } else { + os.Setenv(k, v) + } + } +} + +// checkTableExists returns true if the specified table exists in the database +func checkTableExists(ctx context.Context, pool *pgxpool.Pool, tableName string) (bool, error) { + var exists bool + err := pool.QueryRow(ctx, ` + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = $1 + ) + `, tableName).Scan(&exists) + return exists, err +} + +// loadSQLDumpIntoDatabase loads a SQL dump file into a PostgreSQL database +func loadSQLDumpIntoDatabase(t *testing.T, ctx context.Context, container testcontainers.Container, dbname, dumpPath string) error { + // Copy the SQL dump file to the container + basename := filepath.Base(dumpPath) + targetPath := "/tmp/" + basename + if err := container.CopyFileToContainer(ctx, dumpPath, targetPath, 0644); err != nil { + return fmt.Errorf("failed to copy dump file to container: %w", err) + } + + // Execute psql to load the dump as the postgres superuser + // We execute psql inside the container, so we use localhost and port 5432 (the container's internal port) + // The postgres user is the superuser created by the Docker image + // Use sh instead of bash as postgres:15-alpine uses Alpine Linux + cmd := []string{"sh", "-c", "PGPASSWORD=postgres psql -h localhost -U postgres -d " + dbname + " -f " + targetPath + " -v ON_ERROR_STOP=1"} + + exitCode, reader, err := container.Exec(ctx, cmd) + if err != nil { + return fmt.Errorf("failed to execute psql: %w", err) + } + + // Read the output + output, err := io.ReadAll(reader) + if err != nil { + return fmt.Errorf("failed to read psql output: %w", err) + } + + if exitCode != 0 { + return fmt.Errorf("psql exited with code %d, output: %s", exitCode, string(output)) + } + + t.Logf("Successfully loaded SQL dump from %s into database %s", dumpPath, dbname) + return nil +} + +// createRoleInContainer creates a role in the PostgreSQL container +func createRoleInContainer(t *testing.T, ctx context.Context, container testcontainers.Container, roleName, superuserPassword string) error { + // Get the mapped port for the container + mappedPort, err := container.MappedPort(ctx, "5432") + if err != nil { + return fmt.Errorf("failed to get mapped port: %w", err) + } + + // Connect to the postgres database to create the role + connStr := fmt.Sprintf("host=localhost port=%s user=postgres password=postgres dbname=postgres sslmode=disable", + mappedPort.Port()) + + pool, err := pgxpool.New(ctx, connStr) + if err != nil { + return fmt.Errorf("failed to connect to postgres database: %w", err) + } + defer pool.Close() + + // Check if role already exists + var exists bool + err = pool.QueryRow(ctx, "SELECT 1 FROM pg_roles WHERE rolname = $1", roleName).Scan(&exists) + if err == nil && exists { + return nil // Role already exists + } + + // Clear any error from the role check (role might not exist, which is expected) + // Create the role + _, err = pool.Exec(ctx, fmt.Sprintf("CREATE ROLE %s WITH LOGIN PASSWORD 'testpass'", roleName)) + if err != nil { + return fmt.Errorf("failed to create role %s: %w", roleName, err) + } + + return nil +} + +// createDatabaseInContainer creates a new database in the PostgreSQL container +func createDatabaseInContainer(t *testing.T, ctx context.Context, container testcontainers.Container, dbname string) error { + // Get the mapped port for the container + mappedPort, err := container.MappedPort(ctx, "5432") + if err != nil { + return fmt.Errorf("failed to get mapped port: %w", err) + } + + // Add a small delay to ensure PostgreSQL is fully ready + time.Sleep(2 * time.Second) + + // Connect to the default 'postgres' database to create the new database + // Use localhost and the mapped port + connStr := fmt.Sprintf("host=localhost port=%s user=postgres password=postgres dbname=postgres sslmode=disable", + mappedPort.Port()) + + pool, err := pgxpool.New(ctx, connStr) + if err != nil { + return fmt.Errorf("failed to connect to postgres database: %w", err) + } + defer pool.Close() + + // Check if database already exists and drop it if so + var exists int + err = pool.QueryRow(ctx, "SELECT 1 FROM pg_database WHERE datname = $1", dbname).Scan(&exists) + if err == nil && exists == 1 { + _, err = pool.Exec(ctx, "DROP DATABASE "+dbname+" WITH (FORCE)") + if err != nil { + return fmt.Errorf("failed to drop existing database: %w", err) + } + t.Logf("Dropped existing database %s", dbname) + } + + // Create the new database + _, err = pool.Exec(ctx, "CREATE DATABASE "+dbname) + if err != nil { + return fmt.Errorf("failed to create database: %w", err) + } + + t.Logf("Created database %s", dbname) + return nil +} + +// startPostgreSQLContainer starts a PostgreSQL container and returns it +func startPostgreSQLContainer(t *testing.T, ctx context.Context) (testcontainers.Container, error) { + req := testcontainers.ContainerRequest{ + Image: "postgres:15-alpine", + ExposedPorts: []string{"5432/tcp"}, + Env: map[string]string{ + "POSTGRES_PASSWORD": "postgres", + "POSTGRES_DB": "postgres", + }, + WaitingFor: wait.ForLog("database system is ready to accept connections"), + } + + container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if err != nil { + return nil, fmt.Errorf("failed to start container: %w", err) + } + + return container, nil +} + +// TestMusicRefDump tests that the application can start with music_ref_dump.sql and migrations work +func TestMusicRefDump(t *testing.T) { + testSQLDump(t, "music_ref_dump.sql", "music_server_ref_test") +} + +// TestMusicTestDump tests that the application can start with music_test_dump.sql and migrations work +func TestMusicTestDump(t *testing.T) { + testSQLDump(t, "music_test_dump.sql", "music_server_test_dump_test") +} + +// TestMusicProdDump tests that the application can start with music_prod_dump.sql and migrations work +func TestMusicProdDump(t *testing.T) { + testSQLDump(t, "music_prod_dump.sql", "music_server_prod_test") +} + +// testSQLDump is the common test function for all SQL dump tests +func testSQLDump(t *testing.T, dumpFile, dbname string) { + ctx := context.Background() + + // Save original environment variables + originalEnv := saveEnvVars() + defer restoreEnvVars(originalEnv) + + // Start PostgreSQL container + t.Log("Starting PostgreSQL container...") + container, err := startPostgreSQLContainer(t, ctx) + require.NoError(t, err) + defer func() { + t.Log("Stopping container...") + container.Terminate(ctx) + }() + + // Get container connection details + endpoint, err := container.Endpoint(ctx, "") + require.NoError(t, err) + t.Logf("Container running at: %s", endpoint) + + // Create database for this test + t.Logf("Creating database %s...", dbname) + err = createDatabaseInContainer(t, ctx, container, dbname) + require.NoError(t, err) + + // Create common roles that might be referenced in dump files + err = createRoleInContainer(t, ctx, container, "sansan", "postgres") + if err != nil { + t.Logf("Note: Could not create role 'sansan': %v (may not be needed)", err) + } + + // Load the SQL dump + // The dump files are in the project root + // Get the absolute path to the dump file + absDumpPath, err := filepath.Abs(filepath.Join("..", "..", dumpFile)) + require.NoError(t, err) + if _, err := os.Stat(absDumpPath); os.IsNotExist(err) { + // Try relative to current working directory + absDumpPath = filepath.Join(".", dumpFile) + if _, err := os.Stat(absDumpPath); os.IsNotExist(err) { + absDumpPath = dumpFile // Try as-is + } + } + + t.Logf("Loading SQL dump from: %s", absDumpPath) + err = loadSQLDumpIntoDatabase(t, ctx, container, dbname, absDumpPath) + require.NoError(t, err) + + // ============================================ + // PRE-MIGRATION CHECK + // ============================================ + t.Log("Running pre-migration checks...") + + // For connecting to the database from the test, we need to use the container's endpoint + // But the container's endpoint might be localhost:random_port + // We need to parse it to get host and port + // endpoint format: "host:port" e.g., "localhost:12345" or "127.0.0.1:23456" + // Actually, for testcontainers with Docker, the endpoint should be localhost:mapped_port + // Let's extract the mapped port + + // Get the mapped port from the container + mappedPort, err := container.MappedPort(ctx, "5432") + require.NoError(t, err) + t.Logf("Container mapped port: %s", mappedPort) + + // Use localhost as the host since we're on the same machine + dbHost := "localhost" + dbPort := mappedPort.Port() + + // Create a connection to the database to check pre-migration state + connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable", + dbHost, dbPort, "postgres", "postgres", dbname) + + pool, err := pgxpool.New(ctx, connStr) + require.NoError(t, err) + defer pool.Close() + + // Check that game table exists (pre-migration state) + gameExists, err := checkTableExists(ctx, pool, "game") + require.NoError(t, err) + assert.True(t, gameExists, "Before migration: game table should exist") + + // Check that soundtrack table does NOT exist + soundtrackExists, err := checkTableExists(ctx, pool, "soundtrack") + require.NoError(t, err) + assert.False(t, soundtrackExists, "Before migration: soundtrack table should NOT exist") + + t.Log("✓ Pre-migration check passed: game table exists, soundtrack table does not") + + // ============================================ + // START APPLICATION (triggers migrations) + // ============================================ + t.Log("Starting application...") + + // Set environment variables for the server + // The application needs to connect to the container's mapped port + os.Setenv("DB_HOST", dbHost) + os.Setenv("DB_PORT", dbPort) + os.Setenv("DB_USERNAME", "postgres") + os.Setenv("DB_PASSWORD", "postgres") + os.Setenv("DB_NAME", dbname) + os.Setenv("MUSIC_PATH", "./testMusic") + os.Setenv("CHARACTERS_PATH", "./testCharacters") + + // Use different ports for each test to allow for sequential execution + // and to make port detection reliable + portMap := map[string]string{ + "music_server_ref_test": "8082", + "music_server_test_dump_test": "8083", + "music_server_prod_test": "8084", + } + appPort := portMap[dbname] + if appPort == "" { + appPort = "8085" // fallback + } + os.Setenv("PORT", appPort) + + os.Setenv("DISABLE_SESSION_AUTH", "true") + os.Setenv("LOG_LEVEL", "error") + + // Create server instance (this runs migrations automatically) + serverInstance := NewServerInstance() + httpServer := serverInstance.HTTPServer() + + // Start HTTP server in background + go func() { + if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + t.Logf("Server error: %v", err) + } + }() + + defer func() { + t.Log("Stopping HTTP server...") + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + httpServer.Shutdown(ctx) + }() + + // Wait for server to start by polling /health endpoint + // Use the fixed port we configured for the application + healthURL := fmt.Sprintf("http://localhost:%s/health", appPort) + + maxAttempts := 30 + for attempt := 1; attempt <= maxAttempts; attempt++ { + resp, err := http.Get(healthURL) + if err == nil { + defer resp.Body.Close() + if resp.StatusCode == http.StatusOK { + var healthData map[string]string + json.NewDecoder(resp.Body).Decode(&healthData) + if healthData["status"] == "up" { + t.Logf("Server is healthy on %s after %d attempts", healthURL, attempt) + break + } + } + } + if attempt == maxAttempts { + require.Fail(t, "Server did not become healthy within timeout") + } + time.Sleep(500 * time.Millisecond) + } + + // ============================================ + // POST-MIGRATION CHECK + // ============================================ + t.Log("Running post-migration checks...") + + // Check that game table no longer exists + gameExistsAfter, err := checkTableExists(ctx, pool, "game") + require.NoError(t, err) + assert.False(t, gameExistsAfter, "After migration: game table should NOT exist") + + // Check that soundtrack table now exists + soundtrackExistsAfter, err := checkTableExists(ctx, pool, "soundtrack") + require.NoError(t, err) + assert.True(t, soundtrackExistsAfter, "After migration: soundtrack table should exist") + + // Verify data was preserved + var soundtrackCount int + err = pool.QueryRow(ctx, "SELECT COUNT(*) FROM soundtrack").Scan(&soundtrackCount) + require.NoError(t, err) + assert.Greater(t, soundtrackCount, 0, "Migration should preserve data: soundtrack count should be > 0") + t.Logf("✓ Post-migration check passed: game table gone, soundtrack table exists with %d records", soundtrackCount) + + // ============================================ + // HEALTH CHECK + // ============================================ + t.Log("Running health check...") + + resp, err := http.Get(healthURL) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var healthData map[string]string + err = json.NewDecoder(resp.Body).Decode(&healthData) + require.NoError(t, err) + assert.Equal(t, "up", healthData["status"]) + + t.Log("✓ Health check passed") + t.Log("✓ Test completed successfully!") +} diff --git a/internal/server/statistics_handler_test.go b/internal/server/statistics_handler_test.go index 966ae9b..1927fc2 100644 --- a/internal/server/statistics_handler_test.go +++ b/internal/server/statistics_handler_test.go @@ -12,6 +12,7 @@ import ( "music-server/internal/db" "music-server/internal/db/repository" + "github.com/google/uuid" "github.com/labstack/echo/v5" "github.com/stretchr/testify/require" ) @@ -122,33 +123,37 @@ func insertTestData(t *testing.T) { SoundtrackName: st.name, Path: st.path, Hash: "test-hash-" + st.name, + Uuid: uuid.New(), }) require.NoError(t, err, "Failed to insert soundtrack: %s", st.name) } - // Get soundtrack IDs + // Get soundtrack IDs and UUIDs soundtrackIDs, err := queries.FindAllSoundtracks(ctx) require.NoError(t, err) require.GreaterOrEqual(t, len(soundtrackIDs), 5) // Insert songs for each soundtrack songData := []struct { - soundtrackID int32 - songs []string + soundtrackID int32 + soundtrackUuid uuid.UUID + songs []string }{ - {soundtrackIDs[0].ID, []string{"Song A", "Song B"}}, - {soundtrackIDs[1].ID, []string{"Song C", "Song D"}}, - {soundtrackIDs[2].ID, []string{"Song E"}}, - {soundtrackIDs[3].ID, []string{"Song F", "Song G", "Song H"}}, - {soundtrackIDs[4].ID, []string{"Song I"}}, + {soundtrackIDs[0].ID, soundtrackIDs[0].Uuid, []string{"Song A", "Song B"}}, + {soundtrackIDs[1].ID, soundtrackIDs[1].Uuid, []string{"Song C", "Song D"}}, + {soundtrackIDs[2].ID, soundtrackIDs[2].Uuid, []string{"Song E"}}, + {soundtrackIDs[3].ID, soundtrackIDs[3].Uuid, []string{"Song F", "Song G", "Song H"}}, + {soundtrackIDs[4].ID, soundtrackIDs[4].Uuid, []string{"Song I"}}, } for _, sd := range songData { for _, songName := range sd.songs { err := queries.AddSong(ctx, repository.AddSongParams{ - SoundtrackID: sd.soundtrackID, - SongName: songName, - Path: "/path/to/" + songName + ".mp3", + SoundtrackID: sd.soundtrackID, + SongName: songName, + Path: "/path/to/" + songName + ".mp3", + Uuid: uuid.New(), + SoundtrackUuid: sd.soundtrackUuid, }) require.NoError(t, err, "Failed to insert song: %s", songName) } diff --git a/internal/server/token_handler.go b/internal/server/token_handler.go index e234e67..0037c06 100644 --- a/internal/server/token_handler.go +++ b/internal/server/token_handler.go @@ -4,28 +4,30 @@ import ( "crypto/rand" "encoding/base64" "net/http" + "os" "strings" "time" "music-server/internal/db/repository" "music-server/internal/logging" - "github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" "github.com/labstack/echo/v5" "go.uber.org/zap" ) // TokenRequest represents a request to generate a new token type TokenRequest struct { + Password string `json:"password"` // Required: API password ClientType string `json:"client_type"` // Optional: "web", "mobile", "api" } // TokenResponse represents the response with a new token type TokenResponse struct { - Token string `json:"token"` - ExpiresAt time.Time `json:"expires_at"` - ClientType string `json:"client_type"` + Token string `json:"token"` + ExpiresAt time.Time `json:"expires_at"` + ClientType string `json:"client_type"` } // TokenHandler contains the database pool for token operations @@ -53,13 +55,14 @@ func (h *TokenHandler) generateToken() (string, error) { // POST /api/v1/token // // @Summary Create session token -// @Description Returns a new session token for API access +// @Description Returns a new session token for API access. Requires valid API password. // @Tags auth // @Accept json // @Produce json -// @Param request body TokenRequest true "Client type" +// @Param request body TokenRequest true "Password and client type" // @Success 200 {object} TokenResponse // @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string // @Failure 500 {object} map[string]string // @Router /api/v1/token [post] func (h *TokenHandler) CreateTokenHandler(c *echo.Context) error { @@ -68,6 +71,27 @@ func (h *TokenHandler) CreateTokenHandler(c *echo.Context) error { return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request body"}) } + // Check if session auth is disabled + disableSessionAuth := os.Getenv("DISABLE_SESSION_AUTH") == "true" + apiPassword := os.Getenv("API_PASSWORD") + + // Password validation logic: + // - If session auth is disabled, skip validation + // - If API_PASSWORD is empty, allow any password (open access mode) + // - If API_PASSWORD is set, require exact password match + if !disableSessionAuth { + if apiPassword != "" { + // API password is set - require exact match + if req.Password != apiPassword { + logging.GetLogger().Warn("Invalid password attempt for token creation", + zap.String("ip", c.RealIP()), + zap.String("user_agent", c.Request().UserAgent())) + return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid password"}) + } + } + // If API_PASSWORD is empty, allow any password through (open access mode) + } + if req.ClientType == "" { req.ClientType = "web" } @@ -98,8 +122,8 @@ func (h *TokenHandler) CreateTokenHandler(c *echo.Context) error { } response := TokenResponse{ - Token: session.Token, - ExpiresAt: session.ExpiresAt.Time, + Token: session.Token, + ExpiresAt: session.ExpiresAt.Time, ClientType: *session.ClientType, } diff --git a/justfile b/justfile index b4bd9c8..3aa992b 100644 --- a/justfile +++ b/justfile @@ -85,7 +85,7 @@ build-run: build test: build @echo "Starting test database container..." - @podman-compose -f compose.test.yaml up -d + @docker compose -f compose.test.yaml up -d @sleep 10 @echo "Running integration tests..." @just test-integration @@ -97,54 +97,75 @@ clean: @echo "Cleaning..." @rm -f main -podman-build: - @echo "Building Docker image with podman..." - @podman build -t music-server . +docker-build: + @echo "Building Docker image with docker..." + @docker build -t music-server . -podman-run: - @podman-compose up --build +docker-run: + @docker compose up --build -podman-down: - @podman-compose down +docker-down: + @docker compose down -# Run integration tests with podman +# Run integration tests with docker # Starts a test PostgreSQL container, runs tests, then cleans up test-integration: @echo "Cleaning old test database..." - @podman-compose -f compose.test.yaml down -v + @docker compose -f compose.test.yaml down -v @echo "Starting fresh test database container..." - @podman-compose -f compose.test.yaml up -d + @docker compose -f compose.test.yaml up -d @sleep 10 @echo "Running integration tests..." - @DB_HOST=localhost DB_PORT=5433 DB_USERNAME=testuser DB_PASSWORD=testpass DB_NAME=music_server_test MUSIC_PATH=/Users/sebastian/projects/MusicServer/testMusic CHARACTERS_PATH=/Users/sebastian/projects/MusicServer/testCharacters PORT=8081 LOG_LEVEL=debug LOG_JSON=false go test -v -timeout 30m -p 1 -parallel 1 ./internal/... + @DOCKER_HOST=unix:///Users/sebastian/.docker/run/docker.sock TESTCONTAINERS_RYUK_CONTAINER_NETWORK=testcontainers-test-net DB_HOST=localhost DB_PORT=5433 DB_USERNAME=testuser DB_PASSWORD=testpass DB_NAME=music_server_test MUSIC_PATH=/Users/sebastian/projects/MusicServer/testMusic CHARACTERS_PATH=/Users/sebastian/projects/MusicServer/testCharacters PORT=8081 LOG_LEVEL=debug LOG_JSON=false go test -v -timeout 30m -p 1 -parallel 1 ./internal/... -# Alternative: Run integration tests using testcontainers with podman provider +# Alternative: Run integration tests using testcontainers with docker provider test-integration-tc: - @echo "Running integration tests with testcontainers (podman provider)..." - @TESTCONTAINERS_PROVIDER=podman go test -v -timeout 30m . + @echo "Creating test network (if it doesn't exist)..." + @docker network inspect testcontainers-test-net > /dev/null 2>&1 || docker network create testcontainers-test-net + @echo "Running integration tests with testcontainers (docker provider)..." + @DOCKER_HOST=unix:///Users/sebastian/.docker/run/docker.sock TESTCONTAINERS_PROVIDER=docker TESTCONTAINERS_RYUK_CONTAINER_NETWORK=testcontainers-test-net go test -v -timeout 30m . + +# Run SQL dump migration tests +# Tests that the application can start with each SQL dump and migrations work correctly +test-sql-dump: + @echo "Creating test network (if it doesn't exist)..." + @docker network inspect testcontainers-test-net > /dev/null 2>&1 || docker network create testcontainers-test-net + @echo "Running SQL dump migration tests..." + @DOCKER_HOST=unix:///Users/sebastian/.docker/run/docker.sock TESTCONTAINERS_PROVIDER=docker TESTCONTAINERS_RYUK_CONTAINER_NETWORK=testcontainers-test-net go test -v -timeout 15m -run "TestMusic.*Dump" ./internal/server/ + +# Run individual SQL dump tests +test-sql-dump-ref: + @echo "Creating test network (if it doesn't exist)..." + @docker network inspect testcontainers-test-net > /dev/null 2>&1 || docker network create testcontainers-test-net + @echo "Running SQL dump migration test for ref dump..." + @DOCKER_HOST=unix:///Users/sebastian/.docker/run/docker.sock TESTCONTAINERS_PROVIDER=docker TESTCONTAINERS_RYUK_CONTAINER_NETWORK=testcontainers-test-net go test -v -timeout 5m -run "TestMusicRefDump" ./internal/server/ + +test-sql-dump-test: + @echo "Creating test network (if it doesn't exist)..." + @docker network inspect testcontainers-test-net > /dev/null 2>&1 || docker network create testcontainers-test-net + @echo "Running SQL dump migration test for test dump..." + @DOCKER_HOST=unix:///Users/sebastian/.docker/run/docker.sock TESTCONTAINERS_PROVIDER=docker TESTCONTAINERS_RYUK_CONTAINER_NETWORK=testcontainers-test-net go test -v -timeout 5m -run "TestMusicTestDump" ./internal/server/ + +test-sql-dump-prod: + @echo "Creating test network (if it doesn't exist)..." + @docker network inspect testcontainers-test-net > /dev/null 2>&1 || docker network create testcontainers-test-net + @echo "Running SQL dump migration test for prod dump..." + @DOCKER_HOST=unix:///Users/sebastian/.docker/run/docker.sock TESTCONTAINERS_PROVIDER=docker TESTCONTAINERS_RYUK_CONTAINER_NETWORK=testcontainers-test-net go test -v -timeout 5m -run "TestMusicProdDump" ./internal/server/ # Stop and remove test database container test-integration-down: @echo "Stopping test database container..." - @podman-compose -f compose.test.yaml down -v + @docker compose -f compose.test.yaml down -v -# Create DB container -docker-run: - @if docker compose up --build 2>/dev/null; then \ - : ; \ - else \ - echo "Falling back to Docker Compose V1"; \ - docker-compose up --build; \ - fi +# Build and run with Docker Compose +build-run-docker: + @echo "Building and running with Docker Compose..." + @docker compose up --build -# Shutdown DB container -docker-down: - @if docker compose down 2>/dev/null; then \ - : ; \ - else \ - echo "Falling back to Docker Compose V1"; \ - docker-compose down; \ - fi +# Shutdown containers +stop-docker: + @echo "Stopping containers..." + @docker compose down watch-templ: templ generate --watch --proxy="http://localhost:8080" --cmd="go run ." diff --git a/sqlc.yaml b/sqlc.yaml index a6d95b0..e689c52 100644 --- a/sqlc.yaml +++ b/sqlc.yaml @@ -39,3 +39,12 @@ sql: go_type: import: "time" type: "Time" + - db_type: "uuid" + go_type: + import: "github.com/google/uuid" + type: "UUID" + - db_type: "uuid" + nullable: true + go_type: + import: "github.com/google/uuid" + type: "UUID" diff --git a/testMusic/A Bird Story/.45.id b/testMusic/A Bird Story/.45.id deleted file mode 100644 index e69de29..0000000 diff --git a/testMusic/Clair Obscur - Expedition 33/.1078.id b/testMusic/Clair Obscur - Expedition 33/.1078.id deleted file mode 100644 index e69de29..0000000 diff --git a/testMusic/Metal Gear Rising - Revengeance/.480.id b/testMusic/Metal Gear Rising - Revengeance/.480.id deleted file mode 100644 index e69de29..0000000