#9 Major routes refactor and expansion, #15 Added API for statistics, Added search cache backend and functionality tests, Backend sync improvements
Build / build (push) Successful in 56s
Build / build (push) Successful in 56s
This commit is contained in:
1 parent
e86c0f8e04
commit
a8a2602f50
34 files changed
+1819
-1294
No files matched your search
+148
-135
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+148
-135
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+208
-196
@@ -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:
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ package web
|
||||
|
||||
templ Base() {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<title>Music Search</title>
|
||||
|
||||
+29
-355
@@ -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
|
||||
}
|
||||
+8
-38
@@ -3,49 +3,20 @@ package web
|
||||
templ SearchForm() {
|
||||
@Base() {
|
||||
<button id="dark-mode-toggle">🌙</button>
|
||||
<div id="search-container">
|
||||
<input id="search_term" name="search_term" type="text" hx-post="/findfuzzy" hx-trigger="keyup changed delay:0.25s" hx-target="#soundtracks-container"/>
|
||||
<div class="radio-group" style="display: inline-block; margin-left: 10px;">
|
||||
<label><input type="radio" name="search_type" value="fuzzy" checked> Fuzzy</label>
|
||||
<label><input type="radio" name="search_type" value="normal"> Normal</label>
|
||||
</div>
|
||||
<button type="button" id="clear" name="clear">Clear</button>
|
||||
</div>
|
||||
<input id="search_term" name="search_term" type="text" hx-post="/find" hx-trigger="keyup changed delay:0.25s" hx-target="#soundtracks-container"/>
|
||||
<button type="button" id="clear" name="clear">Clear</button>
|
||||
<div id="soundtracks-container"></div>
|
||||
<script>
|
||||
// Get current search type from radio buttons
|
||||
function getSearchType() {
|
||||
return document.querySelector('input[name="search_type"]:checked').value;
|
||||
}
|
||||
|
||||
// Get endpoint based on search type
|
||||
function getSearchEndpoint() {
|
||||
return getSearchType() === 'fuzzy' ? '/findfuzzy' : '/find';
|
||||
}
|
||||
|
||||
// Update search input endpoint
|
||||
function updateSearchEndpoint() {
|
||||
const endpoint = getSearchEndpoint();
|
||||
document.getElementById('search_term').setAttribute('hx-post', endpoint);
|
||||
}
|
||||
|
||||
document.addEventListener('readystatechange', () => {
|
||||
if (document.readyState == 'complete') {
|
||||
// Initialize with fuzzy search (default)
|
||||
htmlx.ajax('POST', '/findfuzzy', '#soundtracks-container');
|
||||
// Initialize search
|
||||
htmlx.ajax('POST', '/find', '#soundtracks-container');
|
||||
document.getElementById("search_term").focus();
|
||||
|
||||
// Add event listeners for radio buttons
|
||||
document.querySelectorAll('input[name="search_type"]').forEach(radio => {
|
||||
radio.addEventListener('change', updateSearchEndpoint);
|
||||
});
|
||||
|
||||
// Initialize dark mode from localStorage (default to dark)
|
||||
// Initialize theme from localStorage (default to dark)
|
||||
const savedTheme = localStorage.getItem('theme') || 'dark';
|
||||
if (savedTheme === 'dark') {
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
document.getElementById('dark-mode-toggle').textContent = '☀️';
|
||||
}
|
||||
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||
document.getElementById('dark-mode-toggle').textContent = savedTheme === 'dark' ? '☀️' : '🌙';
|
||||
}
|
||||
});
|
||||
|
||||
@@ -64,8 +35,7 @@ templ SearchForm() {
|
||||
|
||||
document.getElementById("clear").addEventListener("click", function (event) {
|
||||
document.getElementById("search_term").value = "";
|
||||
// Use current search type endpoint
|
||||
htmlx.ajax('POST', getSearchEndpoint(), '#soundtracks-container');
|
||||
htmlx.ajax('POST', '/find', '#soundtracks-container');
|
||||
document.getElementById("search_term").focus();
|
||||
});
|
||||
</script>
|
||||
|
||||
+144
-85
@@ -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
|
||||
Reference in new issue
Block a user