Compare commits
350 Commits
84e4ddefa2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ecab93de84 | ||
| 7701ec5a7e | |||
| 9141fe4f09 | |||
| 9026f96b04 | |||
| 0c413782e6 | |||
| 4ecd9599c7 | |||
| dbc675d79b | |||
| 6040e3254e | |||
| b16b3edf4d | |||
| 2bfeb6325c | |||
| 76cee8ce21 | |||
| 2565cdfcc7 | |||
| 2c6e03eee6 | |||
| 5da2769288 | |||
| 6ad6d8b197 | |||
| 775ca09389 | |||
| 5c8f1d6359 | |||
| ce91822a25 | |||
| d55a9087eb | |||
| 496aea9d0d | |||
| f940c678d6 | |||
| 63a404bf48 | |||
| 201d3479cd | |||
| 3c50fc5d53 | |||
| 3dfbcdb815 | |||
| 6a039dc9ac | |||
| 3515476374 | |||
| 4c7abea589 | |||
| 3b53bde103 | |||
| 648eb568ff | |||
| 2724f0f7ed | |||
| e40e657d60 | |||
| 7e26f2ee77 | |||
| 9a0a6d74bb | |||
| 454b5a0cb3 | |||
| e48c719a68 | |||
| fe2f5be812 | |||
| 18861593f8 | |||
| a014ad40a9 | |||
| 0d53d5efdc | |||
| 546ab66b1a | |||
| c1e8cf63b4 | |||
| e333ae36e8 | |||
| 01564ffd52 | |||
| 1250c591b7 | |||
| 3d76046762 | |||
| 66cd131756 | |||
| a1aa5d2540 | |||
| b5281df6a5 | |||
| e87af49dff | |||
| a9a00dbf3b | |||
| 86d0c2b5c0 | |||
| 963f6e925b | |||
| 216febc02d | |||
| 680d2a1a33 | |||
| de2488216c | |||
| 7e850ec740 | |||
| dacd1b300a | |||
| cdf322602f | |||
| fb8433a435 | |||
| 4bb9caa972 | |||
| 34c1cfa084 | |||
| 45e69dd38d | |||
| b0bebec656 | |||
| cf641ce79b | |||
| 81e1b861b5 | |||
| ce64efaf5f | |||
| 8ebaac758c | |||
| b793566221 | |||
| 23fa885053 | |||
| 4ca27faf08 | |||
| c0e2e7f8fb | |||
| ebb5e59134 | |||
| 379ade5fd4 | |||
| f59aca5e92 | |||
| 7ad0b74730 | |||
| c3bd8840b7 | |||
| 0afb4e4c6d | |||
| b7e06810c6 | |||
| 2cc6eb3224 | |||
| cc071ce9a7 | |||
| 65d5d1774c | |||
| a602fa085b | |||
| 3b39b1abce | |||
| bb83966491 | |||
| cffaa143a9 | |||
| ce3571c88b | |||
| 14d08e93b3 | |||
| 7050ef3cb7 | |||
| 4d4ee7bd58 | |||
| ff710a354c | |||
| 292f779ee8 | |||
| 69d7cad5c1 | |||
| 4815080ec9 | |||
| 445e37c2d8 | |||
| b1cbc5d3fe | |||
| d1d6ea9f24 | |||
| d3e294b7c9 | |||
| 0d343dfff9 | |||
| 967c897300 | |||
| 9054f43a11 | |||
| ffc08ccb9c | |||
| e66432ac0a | |||
| 4b739ac149 | |||
| 239dd501aa | |||
| 9c8c9c9d3c | |||
| 9d82a6dce8 | |||
| 9ca3eb0a27 | |||
| 8cd9ac94e9 | |||
| a1582226ef | |||
| 3228b5cfa6 | |||
| 327af5f75a | |||
| 6832625260 | |||
| 3404dfe511 | |||
| 8f1fae8141 | |||
| a91d0cd87b | |||
| fc56734a74 | |||
| 7ab263ae2d | |||
| 8219e83135 | |||
| 4c5d52dfee | |||
| 5234d567c3 | |||
| 6df9c1b9f6 | |||
| c5bd09623e | |||
| 193fede0ab | |||
| 55eeb052e5 | |||
| 7d68095d87 | |||
| 41b5246111 | |||
| 95db00f389 | |||
| 85d986039b | |||
| 3aa25aeef3 | |||
| f91d9733a1 | |||
| 64eb94f128 | |||
| 32bcb1a188 | |||
| 72facaad68 | |||
| 9b251d5191 | |||
| 077499cf9e | |||
| 4835cf9835 | |||
| 77b9802751 | |||
| e64ce1dc47 | |||
| c732d86018 | |||
| 82cee146de | |||
| 171a45c015 | |||
| 3fe8059e77 | |||
| 1de75db825 | |||
| c0808fe5f3 | |||
| 30a23dae5e | |||
| c29f6fad92 | |||
| 511bf8338d | |||
| d319be4492 | |||
| 64de95cdee | |||
| 8e9d2586e1 | |||
| 9b3f972766 | |||
| d2b8649af2 | |||
| 76373faf8f | |||
| d3241fc146 | |||
| 363a125e31 | |||
| 9d8b09a9a7 | |||
| 9a961d9815 | |||
| c20a22b2a8 | |||
| 81cc3e2d0b | |||
| e91120dd63 | |||
| f0f9337c31 | |||
| c045e00b40 | |||
| 20ee50c2b9 | |||
| 8af1808d4a | |||
| ee90a78adf | |||
| e7aca4afb8 | |||
| c88833feb1 | |||
| 4cb1bc1179 | |||
| 2593a45cc3 | |||
| 2dca69c9f4 | |||
| 6e29cb59ef | |||
| b39add4362 | |||
| 6f6d09e24b | |||
| 584754c0ca | |||
| 87eb4c6403 | |||
| 9ae57ad2b1 | |||
| 734b59f760 | |||
| 353adb3eed | |||
| 39e96ec073 | |||
| 5397759192 | |||
| 921c476b5b | |||
| d696981821 | |||
| 60fd2fe90c | |||
| 74d3a6d7e7 | |||
| f1573ce802 | |||
| 31308b20ab | |||
| 31010ed51c | |||
| f31dc1dc9e | |||
| 51f1c60c81 | |||
| 57604d5be6 | |||
| e38760d62d | |||
| 5ddfd78240 | |||
| dcf506f94d | |||
| b634c950d0 | |||
| c1125ee44c | |||
| 54439bccd1 | |||
| 8380f32228 | |||
| 9549fda1b1 | |||
| 41ee7a1d72 | |||
| 890ab5e3f3 | |||
| 3c7c22310d | |||
| e784d7d2a8 | |||
| 3430541aef | |||
| a00d854062 | |||
| 226bb69709 | |||
| ec78f11b2e | |||
| 8d1c1640ce | |||
| e11a15383c | |||
| 2f035ebdd9 | |||
| fed837f868 | |||
| 98f6b1c6cf | |||
| 1828306c27 | |||
| 04521675ed | |||
| 1917b22e77 | |||
| 7930ece337 | |||
| 7eb51e853f | |||
| 0957329c41 | |||
| b99acf719b | |||
| 9571310cfc | |||
| 30ba627016 | |||
| 2705244dcb | |||
| b73f96fa0b | |||
| c85977c728 | |||
| c6d11d83b9 | |||
| bcf9d48d8e | |||
| 8909fb9229 | |||
| 1c24dc221f | |||
| 0acefe636e | |||
| 0c685e6c09 | |||
| 10fafcc848 | |||
| 690bd6a82e | |||
| d994647e62 | |||
| 1c21474ff6 | |||
| c668914edd | |||
| 087ff429ab | |||
| 6bf91f293b | |||
| f137e6be58 | |||
| 2ccb23abf1 | |||
| 69a1fe3707 | |||
| ce41785ffa | |||
| 9e8e49691c | |||
| 86206127d6 | |||
| 6248cd75e9 | |||
| 3dcfc6157e | |||
| bb37b8e18a | |||
| e1ab6e714e | |||
| bda3c58a98 | |||
| 9e0f2231b5 | |||
| aed61b8b61 | |||
| dcefb08cdb | |||
| 16ba3d25ba | |||
| ff1ce6588a | |||
| 99d5d89fe1 | |||
| ac91bd945e | |||
| 59e25d414c | |||
| 8b4963e1c2 | |||
| ab268ab698 | |||
| 7c636455c1 | |||
| 1c286e0194 | |||
| 2f41e95864 | |||
| d8f51a74f8 | |||
| 1f159edf07 | |||
| ff24e85cd8 | |||
| f478de537e | |||
| 7fb6309a25 | |||
| cdcc21c6c6 | |||
| 2eae804dad | |||
| eaabb28b23 | |||
| bb8aac06eb | |||
| d4e6de9e98 | |||
| ed3c50f452 | |||
| 5788216bb6 | |||
| 4557d8552c | |||
| 795bbe825f | |||
| 43a1fff446 | |||
| 2ec1cdec38 | |||
| 2a8294c405 | |||
| 3a1a2129d9 | |||
| 0cd47ab0fe | |||
| 262dc6e406 | |||
| 34d26c7ecb | |||
| bb5ec87654 | |||
| 8c146fa06e | |||
| bc7a3f58cf | |||
| 8f0549b290 | |||
| a83377671e | |||
| ac33f1c0dd | |||
| 656ddbd005 | |||
| dc2366cbcc | |||
| 1d531ab181 | |||
| 06b50509e8 | |||
| 0d1ae305b5 | |||
| e545ef1a06 | |||
| a5a8df096a | |||
| 5f531aa771 | |||
| 5be8bce461 | |||
| 966eced0f8 | |||
| e170d81652 | |||
| ca08af2dbb | |||
| 290dc36298 | |||
| ff54e9c5db | |||
| 7aaead6c67 | |||
| b569b06591 | |||
| 4d8486e6ea | |||
| 1770492b00 | |||
| a37e609880 | |||
| 510549c6ec | |||
| 99fa808d30 | |||
| 8e43731d1f | |||
| 51d26943df | |||
| f0ad92a8f9 | |||
| f39fcacadc | |||
| f4486655d1 | |||
| 9d8a497c48 | |||
| c3b3c606db | |||
| c70ec383c5 | |||
| 50e74326c5 | |||
| 71ab6a3abd | |||
| c9bdc4a75e | |||
| 7c25907c92 | |||
| c1e313d684 | |||
| d2a3b0ccda | |||
| e7fb4264f7 | |||
| a2d16caea0 | |||
| e836d464cb | |||
| 22f05580df | |||
| 641f97fb8e | |||
| 12b72b227d | |||
| eaabdc5475 | |||
| 941a282d3f | |||
| 622418f96c | |||
| ec10fa56b4 | |||
| 31a8da10b4 | |||
| 3c30688058 | |||
| 2a04876754 | |||
| 9e25745804 | |||
| 4f73b0ca97 | |||
| 1e4a5612e8 | |||
| 2146876f24 | |||
| b88a859b66 | |||
| aa9375eff2 | |||
| 0a483ad2a2 | |||
| 8224934046 | |||
| 57a2ff874a | |||
| 5a0c8b7476 | |||
| 82e850070c | |||
| a1c5726eee | |||
| fda2346d9a | |||
| 0bde5ac778 |
34
.air.toml
Normal file
34
.air.toml
Normal file
@@ -0,0 +1,34 @@
|
||||
root = "."
|
||||
tmp_dir = "tmp"
|
||||
|
||||
[build]
|
||||
cmd = "just build-dev"
|
||||
entrypoint = "./tmp/server"
|
||||
full_bin = "./tmp/server"
|
||||
delay = 300
|
||||
exclude_dir = [".git", ".mise", "dist", "node_modules", "tmp"]
|
||||
exclude_file = ["mal.db", "mal.db-shm", "mal.db-wal"]
|
||||
exclude_regex = ["_test\\.go"]
|
||||
exclude_unchanged = true
|
||||
follow_symlink = false
|
||||
include_ext = ["css", "go", "gohtml", "html", "sql", "toml", "ts"]
|
||||
kill_delay = "500ms"
|
||||
log = "air-build.log"
|
||||
send_interrupt = true
|
||||
stop_on_error = true
|
||||
|
||||
[color]
|
||||
app = "white"
|
||||
build = "yellow"
|
||||
main = "magenta"
|
||||
runner = "green"
|
||||
watcher = "cyan"
|
||||
|
||||
[log]
|
||||
main_only = false
|
||||
silent = false
|
||||
time = true
|
||||
|
||||
[misc]
|
||||
clean_on_exit = true
|
||||
startup_banner = ""
|
||||
@@ -2,7 +2,13 @@ node_modules
|
||||
dist
|
||||
.env
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-journal
|
||||
*.db-wal
|
||||
server
|
||||
main_server
|
||||
create_user
|
||||
*.log
|
||||
*.pid
|
||||
.DS_Store
|
||||
.git
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -5,11 +5,13 @@ node_modules
|
||||
out
|
||||
dist
|
||||
*.tgz
|
||||
dist/
|
||||
|
||||
# code coverage
|
||||
coverage
|
||||
*.lcov
|
||||
playwright-report/
|
||||
test-results/
|
||||
blob-report/
|
||||
|
||||
# logs
|
||||
logs
|
||||
|
||||
6
.mise.toml
Normal file
6
.mise.toml
Normal file
@@ -0,0 +1,6 @@
|
||||
[tools]
|
||||
go = "1.25.7"
|
||||
bun = "1.3.14"
|
||||
just = "1.53.0"
|
||||
golangci-lint = "2.12.2"
|
||||
"go:github.com/air-verse/air" = "latest"
|
||||
@@ -1,4 +1,56 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||
"ignorePatterns": []
|
||||
"arrowParens": "always",
|
||||
"bracketSameLine": false,
|
||||
"bracketSpacing": true,
|
||||
"embeddedLanguageFormatting": "auto",
|
||||
"endOfLine": "lf",
|
||||
"htmlWhitespaceSensitivity": "css",
|
||||
"ignorePatterns": ["dist/**", "node_modules/**", "README.md", "static/assets/manifest.json"],
|
||||
"insertFinalNewline": true,
|
||||
"jsdoc": true,
|
||||
"jsxSingleQuote": false,
|
||||
"objectWrap": "collapse",
|
||||
"printWidth": 100,
|
||||
"proseWrap": "always",
|
||||
"quoteProps": "as-needed",
|
||||
"semi": true,
|
||||
"singleAttributePerLine": true,
|
||||
"singleQuote": false,
|
||||
"sortImports": {
|
||||
"groups": [
|
||||
"side_effect_style",
|
||||
"side_effect",
|
||||
{ "newlinesBetween": true },
|
||||
"type",
|
||||
"builtin",
|
||||
"external",
|
||||
["internal", "subpath"],
|
||||
["parent", "sibling", "index"],
|
||||
"style",
|
||||
"unknown"
|
||||
],
|
||||
"ignoreCase": false,
|
||||
"internalPattern": ["~/**", "@/**", "#/**"],
|
||||
"newlinesBetween": true,
|
||||
"order": "asc",
|
||||
"partitionByComment": false,
|
||||
"partitionByNewline": false,
|
||||
"sortSideEffects": false
|
||||
},
|
||||
"sortPackageJson": { "sortScripts": true },
|
||||
"sortTailwindcss": {
|
||||
"attributes": ["class"],
|
||||
"functions": ["clsx", "cn", "cva", "tw"],
|
||||
"preserveDuplicates": false,
|
||||
"preserveWhitespace": false,
|
||||
"stylesheet": "./static/assets/style.css"
|
||||
},
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "all",
|
||||
"useTabs": false,
|
||||
"overrides": [
|
||||
{ "files": ["*.md", "**/*.md"], "options": { "proseWrap": "always" } },
|
||||
{ "files": ["*.json", "**/*.json"], "options": { "printWidth": 120 } }
|
||||
]
|
||||
}
|
||||
|
||||
209
.oxlintrc.json
209
.oxlintrc.json
@@ -1,15 +1,208 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["typescript", "unicorn", "oxc"],
|
||||
"plugins": ["eslint", "import", "typescript", "unicorn", "oxc", "promise", "node"],
|
||||
"categories": {
|
||||
"correctness": "error"
|
||||
"correctness": "error",
|
||||
"nursery": "error",
|
||||
"pedantic": "error",
|
||||
"perf": "error",
|
||||
"restriction": "off",
|
||||
"style": "error",
|
||||
"suspicious": "error"
|
||||
},
|
||||
"options": {
|
||||
"denyWarnings": true,
|
||||
"maxWarnings": 0,
|
||||
"reportUnusedDisableDirectives": "error",
|
||||
"respectEslintDisableDirectives": true,
|
||||
"typeAware": true,
|
||||
"typeCheck": true
|
||||
},
|
||||
"ignorePatterns": ["dist/**", "node_modules/**", "static/assets/**"],
|
||||
"env": { "browser": true, "builtin": true, "es2026": true, "node": true },
|
||||
"rules": {
|
||||
"typescript/unbound-method": "off",
|
||||
"typescript/no-base-to-string": "off",
|
||||
"typescript/no-floating-promises": "off"
|
||||
"import/exports-last": "off",
|
||||
"import/group-exports": "off",
|
||||
"import/no-default-export": "off",
|
||||
"import/no-mutable-exports": "error",
|
||||
"import/no-named-export": "off",
|
||||
"import/no-named-default": "error",
|
||||
"import/no-self-import": "error",
|
||||
"import/no-unassigned-import": "off",
|
||||
"import/no-relative-parent-imports": "off",
|
||||
"capitalized-comments": "off",
|
||||
"curly": "error",
|
||||
"id-length": "off",
|
||||
"max-lines": "off",
|
||||
"max-lines-per-function": "off",
|
||||
"max-statements": "off",
|
||||
"no-console": "error",
|
||||
"no-debugger": "error",
|
||||
"no-empty-function": "error",
|
||||
"no-eval": "error",
|
||||
"no-implicit-coercion": "error",
|
||||
"no-magic-numbers": "off",
|
||||
"no-negated-condition": "off",
|
||||
"no-param-reassign": "error",
|
||||
"no-plusplus": "off",
|
||||
"no-process-exit": "error",
|
||||
"no-restricted-globals": [
|
||||
"error",
|
||||
{ "name": "event", "message": "Use the event parameter instead of the legacy global." },
|
||||
{ "name": "name", "message": "Avoid the ambiguous window.name global." }
|
||||
],
|
||||
"no-ternary": "off",
|
||||
"no-undefined": "off",
|
||||
"no-use-before-define": "off",
|
||||
"no-warning-comments": "warn",
|
||||
"oxc/no-async-await": "off",
|
||||
"oxc/no-barrel-file": "off",
|
||||
"oxc/no-optional-chaining": "off",
|
||||
"oxc/no-rest-spread-properties": "off",
|
||||
"sort-imports": "off",
|
||||
"sort-keys": "off",
|
||||
"typescript/array-type": ["error", { "default": "array-simple" }],
|
||||
"typescript/consistent-type-definitions": ["error", "type"],
|
||||
"typescript/consistent-type-exports": "error",
|
||||
"typescript/consistent-type-imports": [
|
||||
"error",
|
||||
{ "disallowTypeAnnotations": false, "fixStyle": "separate-type-imports", "prefer": "type-imports" }
|
||||
],
|
||||
"typescript/explicit-function-return-type": "off",
|
||||
"typescript/explicit-member-accessibility": "error",
|
||||
"typescript/explicit-module-boundary-types": "off",
|
||||
"typescript/no-base-to-string": "error",
|
||||
"typescript/no-confusing-non-null-assertion": "error",
|
||||
"typescript/no-explicit-any": "error",
|
||||
"typescript/no-floating-promises": "error",
|
||||
"typescript/no-inferrable-types": "error",
|
||||
"typescript/no-invalid-void-type": "error",
|
||||
"typescript/no-misused-promises": "error",
|
||||
"typescript/no-non-null-assertion": "error",
|
||||
"typescript/no-unsafe-type-assertion": "off",
|
||||
"typescript/no-unnecessary-condition": "error",
|
||||
"typescript/no-unsafe-argument": "error",
|
||||
"typescript/no-unsafe-assignment": "error",
|
||||
"typescript/no-unsafe-call": "error",
|
||||
"typescript/no-unsafe-member-access": "error",
|
||||
"typescript/no-unsafe-return": "error",
|
||||
"typescript/no-var-requires": "error",
|
||||
"typescript/prefer-readonly": "error",
|
||||
"typescript/prefer-readonly-parameter-types": "off",
|
||||
"typescript/require-await": "error",
|
||||
"typescript/restrict-plus-operands": "error",
|
||||
"typescript/restrict-template-expressions": "error",
|
||||
"typescript/strict-boolean-expressions": "error",
|
||||
"typescript/strict-void-return": "off",
|
||||
"typescript/switch-exhaustiveness-check": "error",
|
||||
"typescript/unbound-method": "error",
|
||||
"unicorn/filename-case": "off",
|
||||
"unicorn/no-array-for-each": "off",
|
||||
"unicorn/no-array-reduce": "off",
|
||||
"unicorn/no-null": "off",
|
||||
"unicorn/no-useless-undefined": "off",
|
||||
"unicorn/prefer-global-this": "off",
|
||||
"unicorn/prefer-module": "error",
|
||||
"unicorn/prefer-query-selector": "error",
|
||||
"unicorn/prefer-string-replace-all": "error"
|
||||
},
|
||||
"env": {
|
||||
"builtin": true
|
||||
}
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["static/**/*.ts"],
|
||||
"rules": {
|
||||
"curly": "off",
|
||||
"eqeqeq": "off",
|
||||
"import/first": "off",
|
||||
"import/max-dependencies": "off",
|
||||
"import/no-duplicates": "off",
|
||||
"import/no-named-as-default-member": "off",
|
||||
"import/prefer-default-export": "off",
|
||||
"init-declarations": "off",
|
||||
"max-params": "off",
|
||||
"no-console": "off",
|
||||
"no-continue": "off",
|
||||
"no-duplicate-imports": "off",
|
||||
"no-useless-assignment": "off",
|
||||
"no-inline-comments": "off",
|
||||
"no-negated-condition": "off",
|
||||
"no-underscore-dangle": "off",
|
||||
"no-useless-return": "off",
|
||||
"prefer-const": "off",
|
||||
"prefer-destructuring": "off",
|
||||
"require-await": "off",
|
||||
"require-unicode-regexp": "off",
|
||||
"promise/always-return": "off",
|
||||
"promise/avoid-new": "off",
|
||||
"promise/param-names": "off",
|
||||
"promise/prefer-await-to-callbacks": "off",
|
||||
"promise/prefer-await-to-then": "off",
|
||||
"oxc/no-map-spread": "off",
|
||||
"typescript/consistent-type-definitions": "off",
|
||||
"typescript/explicit-member-accessibility": "off",
|
||||
"typescript/no-base-to-string": "off",
|
||||
"typescript/no-floating-promises": "off",
|
||||
"typescript/no-inferrable-types": "off",
|
||||
"typescript/no-misused-promises": "off",
|
||||
"typescript/no-unnecessary-condition": "off",
|
||||
"typescript/no-unnecessary-type-assertion": "off",
|
||||
"typescript/no-unnecessary-type-conversion": "off",
|
||||
"typescript/no-unnecessary-type-parameters": "off",
|
||||
"typescript/no-unsafe-argument": "off",
|
||||
"typescript/no-unsafe-assignment": "off",
|
||||
"typescript/no-unsafe-call": "off",
|
||||
"typescript/no-unsafe-member-access": "off",
|
||||
"typescript/no-unsafe-return": "off",
|
||||
"typescript/prefer-nullish-coalescing": "off",
|
||||
"typescript/prefer-optional-chain": "off",
|
||||
"typescript/strict-boolean-expressions": "off",
|
||||
"typescript/unbound-method": "off",
|
||||
"unicorn/consistent-function-scoping": "off",
|
||||
"unicorn/no-array-callback-reference": "off",
|
||||
"unicorn/no-lonely-if": "off",
|
||||
"unicorn/no-negated-condition": "off",
|
||||
"unicorn/prefer-at": "off",
|
||||
"unicorn/prefer-dom-node-append": "off",
|
||||
"unicorn/prefer-query-selector": "off",
|
||||
"unicorn/prefer-spread": "off",
|
||||
"unicorn/prefer-string-replace-all": "off",
|
||||
"unicorn/require-module-specifiers": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["static/**/*.test.ts", "static/**/*.spec.ts"],
|
||||
"env": { "node": true },
|
||||
"rules": { "import/no-nodejs-modules": "off" }
|
||||
},
|
||||
{
|
||||
"files": ["**/*.test.ts", "**/*.spec.ts"],
|
||||
"env": { "vitest": true },
|
||||
"rules": { "typescript/no-explicit-any": "off" }
|
||||
},
|
||||
{
|
||||
"files": ["tests/e2e/**/*.ts"],
|
||||
"env": { "browser": false, "node": true },
|
||||
"rules": {
|
||||
"import/no-nodejs-modules": "off",
|
||||
"no-console": "off",
|
||||
"no-duplicate-imports": "off",
|
||||
"no-process-exit": "off",
|
||||
"promise/prefer-await-to-then": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["scripts/**/*.ts"],
|
||||
"env": { "browser": false, "node": true },
|
||||
"rules": {
|
||||
"import/no-nodejs-modules": "off",
|
||||
"no-console": "off",
|
||||
"no-process-exit": "off",
|
||||
"promise/prefer-await-to-callbacks": "off",
|
||||
"promise/prefer-await-to-then": "off",
|
||||
"typescript/no-unnecessary-condition": "off",
|
||||
"unicorn/no-array-sort": "off",
|
||||
"unicorn/prefer-string-replace-all": "off",
|
||||
"unicorn/prefer-top-level-await": "off"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
11
.prettierrc
11
.prettierrc
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 100,
|
||||
"bracketSpacing": true,
|
||||
"bracketSameLine": false,
|
||||
"arrowParens": "avoid",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
55
CODE_OF_CONDUCT.md
Normal file
55
CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# Code of Conduct
|
||||
|
||||
## Our Standard
|
||||
|
||||
This project should be a respectful, constructive place to discuss code, design decisions, issues,
|
||||
and improvements. Contributions and conversations are expected to be professional, specific, and
|
||||
generous in intent.
|
||||
|
||||
Examples of positive behavior include:
|
||||
|
||||
- giving feedback that is clear, actionable, and focused on the work;
|
||||
- assuming good intent while still naming problems directly;
|
||||
- welcoming questions from people with different experience levels;
|
||||
- crediting ideas, reports, and contributions accurately;
|
||||
- disagreeing without making the conversation personal.
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
- harassment, insults, threats, or discriminatory language;
|
||||
- sexualized language or imagery in project spaces;
|
||||
- personal attacks, trolling, or repeated disruptive comments;
|
||||
- publishing private information without explicit permission;
|
||||
- pressuring maintainers or contributors outside the scope of the project.
|
||||
|
||||
## Scope
|
||||
|
||||
This code of conduct applies to project spaces such as issues, pull requests, discussions, commits,
|
||||
reviews, and any other forum used to coordinate work on this repository. It also applies when
|
||||
someone is representing the project in public.
|
||||
|
||||
## Reporting
|
||||
|
||||
If you notice behavior that violates this code of conduct, please contact the maintainer privately.
|
||||
Include the relevant context, links, screenshots, or timestamps when possible so the report can be
|
||||
reviewed fairly.
|
||||
|
||||
Reports will be handled with care and discretion. The goal is to protect contributors, keep the
|
||||
project healthy, and respond proportionally to the situation.
|
||||
|
||||
## Enforcement
|
||||
|
||||
The maintainer may take any action needed to keep the project environment constructive, including:
|
||||
|
||||
- clarifying expectations in a thread;
|
||||
- editing or removing inappropriate comments;
|
||||
- closing or locking conversations;
|
||||
- declining contributions;
|
||||
- limiting or blocking future participation.
|
||||
|
||||
Enforcement decisions are based on the behavior, its impact, and the needs of the project community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This code of conduct is adapted from common open source community standards and tailored for this
|
||||
repository.
|
||||
12
Dockerfile
12
Dockerfile
@@ -29,11 +29,11 @@ RUN bun install --frozen-lockfile
|
||||
COPY . .
|
||||
|
||||
# Ensure dist is clean at build time (belt + suspenders)
|
||||
RUN rm -rf dist/ && bun run build:assets
|
||||
RUN rm -rf dist/ && bun run build:assets && bun run build:ts
|
||||
|
||||
# Build the server and CLI tools
|
||||
RUN go build -ldflags="-s -w" -o main_server ./cmd/server
|
||||
RUN go build -ldflags="-s -w" -o create-user ./cmd/user
|
||||
RUN go build -ldflags="-s -w" -o user_admin ./cmd/user
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
@@ -49,13 +49,15 @@ RUN mkdir -p /app/data
|
||||
ENV DATABASE_FILE=/app/data/mal.db
|
||||
|
||||
COPY --from=builder /app/main_server .
|
||||
COPY --from=builder /app/create-user .
|
||||
COPY --from=builder /app/user_admin .
|
||||
COPY --from=builder /app/templates ./templates
|
||||
COPY --from=builder /app/static ./static
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/internal/database/migrations ./migrations
|
||||
COPY entrypoint.sh ./entrypoint.sh
|
||||
|
||||
RUN printf '%s\n' '#!/bin/sh' 'set -e' 'exec /app/user_admin "$@"' > /app/create-user \
|
||||
&& chmod +x /app/create-user
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENTRYPOINT ["./entrypoint.sh"]
|
||||
ENTRYPOINT ["/app/main_server"]
|
||||
|
||||
178
README.md
178
README.md
@@ -4,52 +4,186 @@
|
||||
<img src="/static/assets/logo.png" alt="MyAnimeList logo" width="120" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>A local-first anime catalog, watchlist, recommendation, and playback app.</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img alt="Go" src="https://img.shields.io/badge/go-1.25-00ADD8?style=flat-square&logo=go" />
|
||||
<img alt="SQLite" src="https://img.shields.io/badge/database-sqlite-003B57?style=flat-square&logo=sqlite" />
|
||||
<img alt="Bun" src="https://img.shields.io/badge/runtime-bun-000000?style=flat-square&logo=bun" />
|
||||
<img alt="Tailwind" src="https://img.shields.io/badge/tailwind-4-06D6D4?style=flat-square&logo=tailwindcss" />
|
||||
<img alt="HTMX" src="https://img.shields.io/badge/htmx-partial--updates-3366CC?style=flat-square" />
|
||||
<img alt="License" src="https://img.shields.io/badge/license-MIT-green?style=flat-square" />
|
||||
</p>
|
||||
|
||||
MyAnimeList is a small self-hosted anime tracker and playback app. It keeps the catalog, watchlist, progress tracking, and player in one place, backed by a single SQLite database and a single Go server.
|
||||
MyAnimeList is a self-hosted media app for browsing anime, managing a watchlist, resuming episodes,
|
||||
and playing streams through a browser-based player. It collects the parts of an anime workflow that
|
||||
usually live across several products and keeps them in one small Go application backed by SQLite.
|
||||
|
||||
Most of the UI is rendered on the server. HTMX handles lightweight updates like search, pagination, and watchlist changes, while TypeScript is kept for the parts that need real browser state: the video player, command palette, theme handling, and skip segment editor. The app also includes local users, API tokens, subtitle support, playlist rewriting, provider integrations, migrations, and startup data fixes.
|
||||
I built it as a portfolio project, but the goal was never to make a disposable demo. The interesting
|
||||
part of the project is the product shape: server-rendered pages, a local database, provider
|
||||
integrations, playback proxying, recommendations, migrations, tests, and a TypeScript player that
|
||||
only appears where browser state actually earns its place.
|
||||
|
||||
## Running
|
||||
> [!NOTE]
|
||||
> This is a personal, local-first project. It is written to demonstrate product engineering choices,
|
||||
> not to present itself as an official MyAnimeList client or a hosted streaming platform.
|
||||
|
||||
Requires Go `1.25+`, Bun, [`just`](https://github.com/casey/just), and a C compiler for SQLite.
|
||||
### Contents
|
||||
|
||||
- [What This Project Is](#what-this-project-is)
|
||||
- [What It Includes](#what-it-includes)
|
||||
- [How It Is Built](#how-it-is-built)
|
||||
- [Working Locally](#working-locally)
|
||||
- [Repository Map](#repository-map)
|
||||
|
||||
### What This Project Is
|
||||
|
||||
This project started from a simple idea: anime tracking becomes more interesting when catalog data,
|
||||
personal progress, and playback live in the same interface. A user should be able to discover a
|
||||
title, inspect its metadata, add it to a watchlist, watch an episode, come back later, and continue
|
||||
from the right place without stitching that flow together manually.
|
||||
|
||||
That makes the app a useful playground for real application concerns. It has authentication,
|
||||
long-lived user state, external APIs, background refresh behavior, migrations, data fixes, cache
|
||||
boundaries, provider-specific code, and enough frontend complexity to justify TypeScript without
|
||||
turning the whole product into a single-page app.
|
||||
|
||||
The project is also intentionally modest. It uses a single Go server and a SQLite database because
|
||||
those choices make the system easy to run, inspect, and reason about. The architecture is more about
|
||||
clear ownership than novelty: feature packages own their handlers and services, integrations stay at
|
||||
the edges, and the UI is mostly rendered by the server.
|
||||
|
||||
### What It Includes
|
||||
|
||||
| Area | What it does |
|
||||
| --------------- | ------------------------------------------------------------------------------------------------------------ |
|
||||
| Catalog | Browse, search, and inspect anime metadata from external catalog sources. |
|
||||
| Details | Render synopsis, reviews, characters, statistics, relations, themes, and watch-order data. |
|
||||
| Watchlist | Store local user state for saved titles, statuses, and progress-driven flows. |
|
||||
| Playback | Serve watch pages, proxy streams/subtitles, rewrite playlists, and track progress. |
|
||||
| Player | Handle HLS playback, quality selection, subtitles, keyboard controls, episode navigation, and skip segments. |
|
||||
| Recommendations | Generate personal top picks from watchlist signals and recommendation data. |
|
||||
| Maintenance | Run migrations, startup fixes, local user commands, and data repair scripts. |
|
||||
|
||||
<details>
|
||||
<summary><strong>Implementation notes</strong></summary>
|
||||
|
||||
The backend is written in Go with Gin for HTTP routing and Fx for module wiring. SQLite is used for
|
||||
local persistence, with migrations and data fixes committed alongside the application. Templates are
|
||||
rendered on the server, HTMX handles small partial updates, and TypeScript powers the interactive
|
||||
parts of the browser experience.
|
||||
|
||||
The most stateful frontend code lives under `static/player`, where the app handles playback mode,
|
||||
source loading, progress storage, subtitles, timelines, quality changes, keyboard shortcuts, skip
|
||||
segments, episode completion, and thumbnail navigation.
|
||||
|
||||
</details>
|
||||
|
||||
### How It Is Built
|
||||
|
||||
The application is organized around product boundaries rather than framework layers.
|
||||
`internal/anime` owns catalog-facing behavior, `internal/watchlist` owns saved user state,
|
||||
`internal/playback` owns watch data and proxy behavior, and `integrations` contains provider
|
||||
clients. This keeps the core app from depending directly on the details of a specific metadata or
|
||||
playback source.
|
||||
|
||||
Server-rendered templates are the default because most pages are content-heavy and benefit from
|
||||
simple request-response rendering. TypeScript is used where the browser has real ongoing state:
|
||||
search interactions, theme handling, carousels, watchlist actions, toast messages, and especially
|
||||
the video player.
|
||||
|
||||
The result is a codebase that behaves like a small product rather than a tutorial project: it has a
|
||||
repeatable toolchain, database evolution, local maintenance commands, focused tests, and a clear
|
||||
split between app code and external integrations.
|
||||
|
||||
### Working Locally
|
||||
|
||||
The local workflow assumes [`mise`](https://mise.jdx.dev/) for tool versions and `just` for common
|
||||
commands.
|
||||
|
||||
```bash
|
||||
mise install
|
||||
bun install
|
||||
just build
|
||||
go run ./cmd/user <username> <password>
|
||||
just dev
|
||||
```
|
||||
|
||||
The app starts on `http://localhost:3000` by default. Configuration comes from environment variables, and a local `.env` file is loaded automatically. The most useful options are `PORT`, `DATABASE_FILE`, `PLAYBACK_PROXY_SECRET`, `EPISODE_AVAILABILITY_MODE`, and `ANIMESCHEDULE_API_TOKEN`.
|
||||
The development server runs on `http://localhost:3000` by default. `just dev` uses Air to rebuild
|
||||
the Go server and frontend assets when relevant files change.
|
||||
|
||||
## Development
|
||||
|
||||
The codebase is split between Go feature packages, external integrations, server-rendered templates, and a small frontend asset pipeline. `cmd/server` starts the web app, `cmd/user` contains local admin tools, `internal` holds the application modules, `integrations` holds provider clients, and `templates`, `static`, and `dist` contain the UI.
|
||||
|
||||
The common development commands are in the `justfile`.
|
||||
Playback proxying requires a local `PLAYBACK_PROXY_SECRET` so the server can mint stream and
|
||||
subtitle proxy tokens. Generate a strong value and add it to `.env` before using playback:
|
||||
|
||||
```bash
|
||||
just fmt
|
||||
just test
|
||||
just lint-go
|
||||
just lint-ts
|
||||
just typecheck
|
||||
just build
|
||||
echo "PLAYBACK_PROXY_SECRET=$(openssl rand -base64 32)" >> .env
|
||||
```
|
||||
|
||||
Run the full local check with:
|
||||
Create a local user with:
|
||||
|
||||
```bash
|
||||
just check
|
||||
go run ./cmd/user <username> <password>
|
||||
```
|
||||
|
||||
## License
|
||||
#### Commands
|
||||
|
||||
MIT. See [`LICENSE`](LICENSE).
|
||||
| Command | Use it for |
|
||||
| ------------------------------- | --------------------------------------------------- |
|
||||
| `just setup` | Install pinned tools and Bun dependencies. |
|
||||
| `just dev` | Run the app locally with live rebuilds. |
|
||||
| `just build` | Build the Go binary, CSS, and TypeScript assets. |
|
||||
| `just test` | Run the Go test suite. |
|
||||
| `just check` | Run linting, tests, typechecking, and a full build. |
|
||||
| `just lint-go` / `just lint-ts` | Run backend or frontend linting separately. |
|
||||
| `just typecheck` | Run TypeScript without emitting files. |
|
||||
| `just run` | Build and run the compiled server. |
|
||||
| `just clean` | Remove generated build output. |
|
||||
|
||||
<details>
|
||||
<summary><strong>Configuration</strong></summary>
|
||||
|
||||
Configuration is loaded from environment variables, and a local `.env` file is read automatically.
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
| --------------------------- | --------------- | -------------------------------------------------------------------------- |
|
||||
| `PORT` | `3000` | HTTP port for the server. |
|
||||
| `DATABASE_FILE` | `mal.db` | SQLite database path. |
|
||||
| `GIN_MODE` | release default | Gin runtime mode. |
|
||||
| `MAL_CORS_ALLOW_ALL` | disabled | Allows any origin when set to `1`; intended for local/proxy setups. |
|
||||
| `PLAYBACK_PROXY_SECRET` | empty | Secret used to mint playback proxy tokens; required for playback proxying. |
|
||||
| `EPISODE_AVAILABILITY_MODE` | `auto` | Episode availability strategy: `auto`, `legacy`, or `jikan`. |
|
||||
| `MAL_JIKAN_TRACE` | disabled | Enables optional Jikan client tracing when truthy. |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Maintenance commands</strong></summary>
|
||||
|
||||
| Command | Use it for |
|
||||
| ------------------------ | ---------------------------------------------------------- |
|
||||
| `just new-data-fix name` | Scaffold a new data-fix file. |
|
||||
| `just run-fixes` | Run registered data fixes through `cmd/user`. |
|
||||
| `just fix-all` | Run the Bun maintenance script for data fixes. |
|
||||
| `bun run format` | Format TypeScript and related frontend files with `oxfmt`. |
|
||||
|
||||
</details>
|
||||
|
||||
### Repository Map
|
||||
|
||||
| Path | Responsibility |
|
||||
| -------------------------------- | --------------------------------------------------------------- |
|
||||
| `cmd/server` | Web server entry point. |
|
||||
| `cmd/user` | Local user and maintenance commands. |
|
||||
| `internal/anime` | Catalog, details, browse, search, reviews, and recommendations. |
|
||||
| `internal/auth` | Authentication, middleware, and local user handling. |
|
||||
| `internal/watchlist` | Watchlist handlers, service logic, and persistence. |
|
||||
| `internal/playback` | Watch data, progress, proxy tokens, and skip segments. |
|
||||
| `internal/episodes` | Episode refresh and provider mapping. |
|
||||
| `internal/database` | SQLite setup, migrations, and startup data fixes. |
|
||||
| `integrations/jikan` | Jikan API client and catalog types. |
|
||||
| `integrations/playback/allanime` | Playback provider client and extraction logic. |
|
||||
| `templates` | Server-rendered pages and reusable components. |
|
||||
| `static` | TypeScript source for client-side behavior. |
|
||||
| `scripts` | Bun-powered development and maintenance scripts. |
|
||||
|
||||
Released under the [MIT License](LICENSE).
|
||||
|
||||
67
SECURITY.md
Normal file
67
SECURITY.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
This is a personal portfolio project, so there is no formal long-term support schedule. Security
|
||||
fixes are applied to the current main branch when issues are confirmed and within the practical
|
||||
maintenance capacity of the project.
|
||||
|
||||
## Reporting A Vulnerability
|
||||
|
||||
Please do not open a public issue for a security vulnerability.
|
||||
|
||||
Report security concerns privately to the repository maintainer. Include as much detail as you can:
|
||||
|
||||
- a description of the vulnerability;
|
||||
- steps to reproduce the issue;
|
||||
- affected routes, commands, files, or configuration;
|
||||
- the potential impact;
|
||||
- any suggested fix or mitigation, if you have one.
|
||||
|
||||
You can expect a best-effort response acknowledging the report, followed by validation and a fix
|
||||
when the issue is reproducible and in scope.
|
||||
|
||||
## Security Scope
|
||||
|
||||
The most important security areas for this project are:
|
||||
|
||||
- local authentication and session handling;
|
||||
- watchlist and playback progress data;
|
||||
- playback proxy tokens and signed stream access;
|
||||
- subtitle and playlist proxying;
|
||||
- external provider integration boundaries;
|
||||
- SQLite database access and migrations;
|
||||
- configuration loaded from environment variables or `.env` files.
|
||||
|
||||
Reports involving these areas are especially useful.
|
||||
|
||||
## Out Of Scope
|
||||
|
||||
The following are generally out of scope unless they expose a direct application vulnerability:
|
||||
|
||||
- issues that require full local machine access;
|
||||
- denial-of-service reports against a local development server;
|
||||
- vulnerabilities in third-party services outside this repository;
|
||||
- missing production hardening for deployments that are not documented or supported by the project;
|
||||
- social engineering or physical attacks.
|
||||
|
||||
## Operational Notes
|
||||
|
||||
This application is designed to be self-hosted and local-first. If you deploy it beyond a private
|
||||
local environment, you are responsible for the surrounding production controls, including TLS,
|
||||
network access, backups, secrets management, reverse proxy configuration, logging retention, and
|
||||
dependency monitoring.
|
||||
|
||||
Use a strong `PLAYBACK_PROXY_SECRET` if playback proxy token signing is enabled. Do not commit real
|
||||
secrets, provider tokens, session data, or production databases to the repository.
|
||||
|
||||
## Dependency Security
|
||||
|
||||
Dependencies are managed through Go modules and Bun. When updating dependencies, run the normal
|
||||
local checks before merging:
|
||||
|
||||
```bash
|
||||
just check
|
||||
```
|
||||
|
||||
Security-related dependency updates should be kept small and reviewed separately when possible.
|
||||
12
bun.lock
12
bun.lock
@@ -3,15 +3,15 @@
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "myanimelist-ui",
|
||||
"name": "mal",
|
||||
"dependencies": {
|
||||
"hls.js": "^1.6.16",
|
||||
"htmx.org": "1.9.12",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@tailwindcss/cli": "^4.3.0",
|
||||
"@types/node": "^24.0.0",
|
||||
"jiti": "^2.7.0",
|
||||
"lefthook": "^2.1.6",
|
||||
"oxfmt": "^0.52.0",
|
||||
"oxlint": "^1.67.0",
|
||||
@@ -148,6 +148,8 @@
|
||||
|
||||
"@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="],
|
||||
|
||||
"@playwright/test": ["@playwright/test@1.61.1", "", { "dependencies": { "playwright": "1.61.1" }, "bin": { "playwright": "cli.js" } }, "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig=="],
|
||||
|
||||
"@tailwindcss/cli": ["@tailwindcss/cli@4.3.0", "", { "dependencies": { "@parcel/watcher": "^2.5.1", "@tailwindcss/node": "4.3.0", "@tailwindcss/oxide": "4.3.0", "enhanced-resolve": "^5.21.0", "mri": "^1.2.0", "picocolors": "^1.1.1", "tailwindcss": "4.3.0" }, "bin": { "tailwindcss": "dist/index.mjs" } }, "sha512-X9kdlqyMopO9fewbgHsEeuy31YzMHbdZ9VsKt004tB+mxSg1CNbyhZYCzvhciN0AM4R4b5lvIprPjtNq7iQxpQ=="],
|
||||
|
||||
"@tailwindcss/node": ["@tailwindcss/node@4.3.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.21.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.0" } }, "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g=="],
|
||||
@@ -184,6 +186,8 @@
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.23.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"hls.js": ["hls.js@1.6.16", "", {}, "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA=="],
|
||||
@@ -258,6 +262,10 @@
|
||||
|
||||
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||
|
||||
"playwright": ["playwright@1.61.1", "", { "dependencies": { "playwright-core": "1.61.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ=="],
|
||||
|
||||
"playwright-core": ["playwright-core@1.61.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.3.0", "", {}, "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q=="],
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
# cmd
|
||||
|
||||
Application entrypoints.
|
||||
|
||||
| binary | purpose |
|
||||
| ------------ | -------------------------------- |
|
||||
| `cmd/server` | HTTP server and worker processes |
|
||||
| `cmd/user` | User management CLI |
|
||||
|
||||
## Conventions
|
||||
|
||||
- Each subdirectory is a `package main` that compiles to a standalone binary.
|
||||
- Shared logic lives in `internal/` or `pkg/`, not in `cmd/`.
|
||||
- Configuration is read from environment variables — see each binary's `main.go` for the full list.
|
||||
@@ -2,14 +2,17 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"mal/internal/app"
|
||||
"mal/internal"
|
||||
"mal/internal/observability"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
func main() {
|
||||
_ = godotenv.Load()
|
||||
if err := godotenv.Load(); err != nil {
|
||||
observability.Warn("env_file_load_failed", "server", "", nil, err)
|
||||
}
|
||||
|
||||
application := app.NewApp()
|
||||
application := internal.NewApp()
|
||||
application.Run()
|
||||
}
|
||||
|
||||
325
cmd/user/main.go
325
cmd/user/main.go
@@ -1,4 +1,4 @@
|
||||
// Package main provides small CLI utilities for local admin tasks.
|
||||
// Package main provides local user administration commands.
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -7,248 +7,189 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"io"
|
||||
"mal/internal"
|
||||
"mal/internal/config"
|
||||
"mal/internal/database"
|
||||
"mal/internal/db"
|
||||
"mal/internal/observability"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/joho/godotenv"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
observability.Error("cli_config_load_failed", "cmd/user", "", nil, err)
|
||||
if err := godotenv.Load(); err != nil {
|
||||
observability.Warn("env_file_load_failed", "user", "", nil, err)
|
||||
}
|
||||
|
||||
if err := run(os.Args[1:]); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "ERROR: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
dbConn, err := db.Open(cfg.DatabaseFile)
|
||||
if err != nil {
|
||||
observability.Error("cli_db_open_failed", "cmd/user", "", map[string]any{"db_file": cfg.DatabaseFile}, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer func() { _ = dbConn.Close() }()
|
||||
|
||||
os.Exit(run(dbConn, os.Args))
|
||||
}
|
||||
|
||||
func run(dbConn *sql.DB, args []string) int {
|
||||
ctx := context.Background()
|
||||
|
||||
cmd, err := parseArgs(args)
|
||||
if err != nil {
|
||||
observability.Warn("cli_usage", "cmd/user", "invalid arguments", map[string]any{"argc": len(args)}, err)
|
||||
_, _ = fmt.Fprintln(os.Stderr, usage())
|
||||
return 2
|
||||
func run(args []string) error {
|
||||
if len(args) == 1 && args[0] == "run-fixes" {
|
||||
return runFixes()
|
||||
}
|
||||
|
||||
switch cmd.kind {
|
||||
case commandUpdateAvatar:
|
||||
updateAvatars(ctx, dbConn)
|
||||
return 0
|
||||
case commandRunFixes:
|
||||
runFixes(ctx, dbConn)
|
||||
return 0
|
||||
case commandCreateOrUpdateUser:
|
||||
if err := createOrUpdateUser(ctx, dbConn, cmd.username, cmd.password); err != nil {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
default:
|
||||
observability.Error("cli_command_unreachable", "cmd/user", "", map[string]any{"kind": cmd.kind}, errors.New("unhandled command"))
|
||||
return 1
|
||||
if len(args) != 1 && len(args) != 2 {
|
||||
return errors.New("usage: create-user <username> [password]")
|
||||
}
|
||||
}
|
||||
|
||||
type commandKind string
|
||||
|
||||
const (
|
||||
commandUpdateAvatar commandKind = "update-avatar"
|
||||
commandRunFixes commandKind = "run-fixes"
|
||||
commandCreateOrUpdateUser commandKind = "create-or-update-user"
|
||||
)
|
||||
|
||||
type command struct {
|
||||
kind commandKind
|
||||
username string
|
||||
password string
|
||||
}
|
||||
|
||||
func parseArgs(args []string) (command, error) {
|
||||
username := strings.TrimSpace(args[0])
|
||||
password := ""
|
||||
if len(args) == 2 {
|
||||
switch args[1] {
|
||||
case string(commandUpdateAvatar):
|
||||
return command{kind: commandUpdateAvatar}, nil
|
||||
case string(commandRunFixes):
|
||||
return command{kind: commandRunFixes}, nil
|
||||
}
|
||||
password = args[1]
|
||||
}
|
||||
if username == "" {
|
||||
return errors.New("username must not be empty")
|
||||
}
|
||||
|
||||
if len(args) == 3 {
|
||||
return command{
|
||||
kind: commandCreateOrUpdateUser,
|
||||
username: args[1],
|
||||
password: args[2],
|
||||
}, nil
|
||||
}
|
||||
|
||||
return command{}, errors.New("invalid arguments")
|
||||
}
|
||||
|
||||
func usage() string {
|
||||
return "Usage: go run cmd/user/main.go <username> <password>\n go run cmd/user/main.go update-avatar\n go run cmd/user/main.go run-fixes"
|
||||
}
|
||||
|
||||
func createOrUpdateUser(ctx context.Context, dbConn *sql.DB, username string, password string) error {
|
||||
existingID, err := lookupUserID(ctx, dbConn, username)
|
||||
sqlDB, err := openDatabase()
|
||||
if err != nil {
|
||||
observability.Error("cli_user_lookup_failed", "cmd/user", "", map[string]any{"username": username}, err)
|
||||
return err
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
|
||||
if existingID != "" {
|
||||
if !promptConfirmOverwrite(username) {
|
||||
fmt.Println("Operation cancelled.")
|
||||
return nil
|
||||
}
|
||||
if err := updateUserPassword(ctx, dbConn, existingID, username, password); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Password for '%s' updated successfully!\n", username)
|
||||
if err := internal.RunMigrationsAndFixes(sqlDB); err != nil {
|
||||
return fmt.Errorf("prepare database: %w", err)
|
||||
}
|
||||
|
||||
return createOrUpdateUser(sqlDB, username, password)
|
||||
}
|
||||
|
||||
func runFixes() error {
|
||||
sqlDB, err := openDatabase()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
|
||||
if err := internal.RunMigrationsAndFixes(sqlDB); err != nil {
|
||||
return fmt.Errorf("run migrations and fixes: %w", err)
|
||||
}
|
||||
fmt.Println("Database migrations and fixes complete")
|
||||
return nil
|
||||
}
|
||||
|
||||
func openDatabase() (*sql.DB, error) {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load config: %w", err)
|
||||
}
|
||||
|
||||
sqlDB, err := db.Open(cfg.DatabaseFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open database: %w", err)
|
||||
}
|
||||
return sqlDB, nil
|
||||
}
|
||||
|
||||
func createOrUpdateUser(sqlDB *sql.DB, username, password string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var userID string
|
||||
err := sqlDB.QueryRowContext(ctx, `SELECT id FROM user WHERE username = ? LIMIT 1`, username).Scan(&userID)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return fmt.Errorf("check user: %w", err)
|
||||
}
|
||||
userExists := err == nil
|
||||
|
||||
if !userExists {
|
||||
return createUser(ctx, sqlDB, username, password)
|
||||
}
|
||||
|
||||
update, err := confirmPasswordUpdate(username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !update {
|
||||
fmt.Println("No changes made")
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := createUser(ctx, dbConn, username, password); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("User '%s' was created successfully!\n", username)
|
||||
return nil
|
||||
return updateUserPassword(ctx, sqlDB, userID, username, password)
|
||||
}
|
||||
|
||||
func lookupUserID(ctx context.Context, dbConn *sql.DB, username string) (string, error) {
|
||||
var id string
|
||||
err := dbConn.QueryRowContext(ctx, "SELECT id FROM user WHERE username = ?", username).Scan(&id)
|
||||
if err == nil {
|
||||
return id, nil
|
||||
}
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
func promptConfirmOverwrite(username string) bool {
|
||||
fmt.Printf("User '%s' already exists. Do you want to overwrite their password? [y/N]: ", username)
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
response, _ := reader.ReadString('\n')
|
||||
response = strings.TrimSpace(strings.ToLower(response))
|
||||
return response == "y" || response == "yes"
|
||||
}
|
||||
|
||||
func updateUserPassword(ctx context.Context, dbConn *sql.DB, userID string, username string, password string) error {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), 12)
|
||||
func createUser(ctx context.Context, sqlDB *sql.DB, username, password string) error {
|
||||
password, err := resolvePassword(password)
|
||||
if err != nil {
|
||||
observability.Error("cli_password_hash_failed", "cmd/user", "", nil, err)
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = dbConn.ExecContext(ctx, "UPDATE user SET password_hash = ? WHERE id = ?", string(hash), userID)
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
observability.Error("cli_user_password_update_failed", "cmd/user", "", map[string]any{"username": username}, err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func createUser(ctx context.Context, dbConn *sql.DB, username string, password string) error {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), 12)
|
||||
if err != nil {
|
||||
observability.Error("cli_password_hash_failed", "cmd/user", "", nil, err)
|
||||
return err
|
||||
return fmt.Errorf("hash password: %w", err)
|
||||
}
|
||||
|
||||
id := uuid.New().String()
|
||||
avatarURL := internal.DefaultAvatarURL(username)
|
||||
_, err = dbConn.ExecContext(
|
||||
_, err = sqlDB.ExecContext(
|
||||
ctx,
|
||||
"INSERT INTO user (id, username, password_hash, avatar_url) VALUES (?, ?, ?, ?)",
|
||||
id,
|
||||
username,
|
||||
string(hash),
|
||||
avatarURL,
|
||||
`INSERT INTO user (id, username, password_hash, avatar_url) VALUES (?, ?, ?, ?)`,
|
||||
uuid.NewString(), username, string(passwordHash), internal.DefaultAvatarURL(username),
|
||||
)
|
||||
if err != nil {
|
||||
observability.Error("cli_user_create_failed", "cmd/user", "", map[string]any{"username": username}, err)
|
||||
return err
|
||||
return fmt.Errorf("create user: %w", err)
|
||||
}
|
||||
fmt.Printf("Created user %q\n", username)
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateAvatars(ctx context.Context, dbConn *sql.DB) {
|
||||
rows, err := dbConn.QueryContext(ctx, "SELECT id, username FROM user")
|
||||
func updateUserPassword(ctx context.Context, sqlDB *sql.DB, userID, username, password string) error {
|
||||
password, err := resolvePassword(password)
|
||||
if err != nil {
|
||||
observability.Error("cli_users_list_failed", "cmd/user", "", nil, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
count := 0
|
||||
for rows.Next() {
|
||||
var id, username string
|
||||
if err := rows.Scan(&id, &username); err != nil {
|
||||
observability.Error("cli_user_scan_failed", "cmd/user", "", nil, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
avatarURL := internal.DefaultAvatarURL(username)
|
||||
_, err := dbConn.ExecContext(ctx, "UPDATE user SET avatar_url = ? WHERE id = ?", avatarURL, id)
|
||||
if err != nil {
|
||||
observability.Error("cli_user_avatar_update_failed", "cmd/user", "", map[string]any{"username": username}, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
count++
|
||||
return err
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
observability.Error("cli_users_iter_failed", "cmd/user", "", nil, err)
|
||||
os.Exit(1)
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("hash password: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Updated avatars for %d user(s)\n", count)
|
||||
if _, err := sqlDB.ExecContext(ctx, `UPDATE user SET password_hash = ? WHERE id = ?`, string(passwordHash), userID); err != nil {
|
||||
return fmt.Errorf("update password: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Updated password for user %q\n", username)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runFixes(ctx context.Context, dbConn *sql.DB) {
|
||||
if err := database.RunMigrationsAndFixes(dbConn); err != nil {
|
||||
observability.Error("cli_run_migrations_and_fixes_failed", "cmd/user", "", nil, err)
|
||||
os.Exit(1)
|
||||
func resolvePassword(password string) (string, error) {
|
||||
if password != "" {
|
||||
return password, nil
|
||||
}
|
||||
|
||||
rows, err := dbConn.QueryContext(ctx, "SELECT id, applied_at FROM data_fixes ORDER BY id ASC")
|
||||
fmt.Print("Password: ")
|
||||
passwordBytes, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||
fmt.Println()
|
||||
if err != nil {
|
||||
observability.Error("cli_data_fixes_list_failed", "cmd/user", "", nil, err)
|
||||
os.Exit(1)
|
||||
return "", fmt.Errorf("read password: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
count := 0
|
||||
for rows.Next() {
|
||||
var id string
|
||||
var appliedAt string
|
||||
if err := rows.Scan(&id, &appliedAt); err != nil {
|
||||
observability.Error("cli_data_fix_scan_failed", "cmd/user", "", nil, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("%s applied_at=%s\n", id, appliedAt)
|
||||
count++
|
||||
if len(passwordBytes) == 0 {
|
||||
return "", errors.New("password must not be empty")
|
||||
}
|
||||
return string(passwordBytes), nil
|
||||
}
|
||||
|
||||
func confirmPasswordUpdate(username string) (bool, error) {
|
||||
fmt.Printf("User %q already exists. Change password? [Y/n] ", username)
|
||||
answer, err := bufio.NewReader(os.Stdin).ReadString('\n')
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
return false, fmt.Errorf("read confirmation: %w", err)
|
||||
}
|
||||
|
||||
switch strings.ToLower(strings.TrimSpace(answer)) {
|
||||
case "", "y", "yes":
|
||||
return true, nil
|
||||
case "n", "no":
|
||||
return false, nil
|
||||
default:
|
||||
return false, errors.New("invalid response; enter y or n")
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
observability.Error("cli_data_fixes_iter_failed", "cmd/user", "", nil, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("Applied fixes: %d\n", count)
|
||||
}
|
||||
|
||||
4
create-user
Executable file
4
create-user
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
exec go run ./cmd/user "$@"
|
||||
@@ -17,4 +17,4 @@ namespace: mal
|
||||
images:
|
||||
- name: main
|
||||
newName: reg.milasholsting.dk/apps/mal
|
||||
newTag: sha-8fd7c11
|
||||
newTag: sha-7701ec5
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
: "${DATABASE_FILE:=/app/data/mal.db}"
|
||||
|
||||
if [ ! -x /app/main_server ]; then
|
||||
echo "ERROR: /app/main_server not found or not executable" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec /app/main_server
|
||||
|
||||
3
go.mod
3
go.mod
@@ -16,6 +16,7 @@ require (
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/pressly/goose/v3 v3.27.1
|
||||
go.uber.org/fx v1.24.0
|
||||
golang.org/x/term v0.43.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -56,6 +57,6 @@ require (
|
||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||
github.com/klauspost/compress v1.18.5 // indirect
|
||||
golang.org/x/sync v0.20.0 // direct
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/sys v0.44.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
)
|
||||
|
||||
6
go.sum
6
go.sum
@@ -158,8 +158,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
@@ -169,6 +169,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
|
||||
@@ -3,6 +3,9 @@ package jikan
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"mal/internal/observability"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -38,10 +41,48 @@ func (c *Client) WarmAnimeRecommendations(id int) {
|
||||
|
||||
c.runAsyncRefresh(func(ctx context.Context) {
|
||||
var resp RecommendationsResponse
|
||||
_ = c.getWithCache(ctx, cacheKey, 24*time.Hour, url, &resp)
|
||||
if err := c.getWithCache(ctx, cacheKey, 24*time.Hour, url, &resp); err != nil {
|
||||
c.EnqueueAnimeFetchRetry(ctx, id, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// GetTopAnime returns the top-rated anime list for a given page.
|
||||
func (c *Client) GetTopAnime(ctx context.Context, page int) (TopAnimeResult, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
cacheKey := fmt.Sprintf("top:%d", page)
|
||||
|
||||
var result TopAnimeResponse
|
||||
params := url.Values{}
|
||||
params.Set("page", strconv.Itoa(page))
|
||||
reqURL := buildRequestURL(c.baseURL, "/top/anime", params)
|
||||
|
||||
if err := c.getWithCache(ctx, cacheKey, shortCacheTTL, reqURL, &result); err != nil {
|
||||
return TopAnimeResult{}, err
|
||||
}
|
||||
|
||||
return TopAnimeResult{
|
||||
Animes: result.Data,
|
||||
HasNextPage: result.Pagination.HasNextPage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetAnimeGenres returns list of all anime genres, cached long-term.
|
||||
func (c *Client) GetAnimeGenres(ctx context.Context) ([]Genre, error) {
|
||||
const cacheKey = "anime_genres"
|
||||
|
||||
var result GenresResponse
|
||||
reqURL := fmt.Sprintf("%s/genres/anime", c.baseURL)
|
||||
|
||||
if err := c.getWithCache(ctx, cacheKey, longCacheTTL, reqURL, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result.Data, nil
|
||||
}
|
||||
|
||||
// GetAnimeByID returns full anime details; finished series cached 30 days, airing cached 1 day.
|
||||
func (c *Client) GetAnimeByID(ctx context.Context, id int) (Anime, error) {
|
||||
cacheKey := fmt.Sprintf("anime:%d", id)
|
||||
@@ -71,7 +112,7 @@ func (c *Client) GetAnimeByID(ctx context.Context, id int) (Anime, error) {
|
||||
func (c *Client) refreshAnimeByID(ctx context.Context, id int) (Anime, error) {
|
||||
cacheKey := fmt.Sprintf("anime:%d", id)
|
||||
|
||||
value, err, _ := c.sf.Do("refresh:"+cacheKey, func() (any, error) {
|
||||
value, err, shared := c.sf.Do("refresh:"+cacheKey, func() (any, error) {
|
||||
var cached Anime
|
||||
if c.getCache(ctx, cacheKey, &cached) && cached.MalID != 0 {
|
||||
return cached, nil
|
||||
@@ -95,6 +136,14 @@ func (c *Client) refreshAnimeByID(ctx context.Context, id int) (Anime, error) {
|
||||
if err != nil {
|
||||
return Anime{}, err
|
||||
}
|
||||
if shared {
|
||||
observability.Info(
|
||||
"jikan_anime_refresh_shared",
|
||||
"jikan",
|
||||
"",
|
||||
map[string]any{"anime_id": id, "cache_key": cacheKey},
|
||||
)
|
||||
}
|
||||
|
||||
if anime, ok := value.(Anime); ok && anime.MalID != 0 {
|
||||
return anime, nil
|
||||
@@ -105,6 +154,8 @@ func (c *Client) refreshAnimeByID(ctx context.Context, id int) (Anime, error) {
|
||||
|
||||
func (c *Client) refreshAnimeByIDAsync(id int) {
|
||||
c.runAsyncRefresh(func(ctx context.Context) {
|
||||
_, _ = c.refreshAnimeByID(ctx, id)
|
||||
if _, err := c.refreshAnimeByID(ctx, id); err != nil {
|
||||
c.EnqueueAnimeFetchRetry(ctx, id, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
79
integrations/jikan/cache/store.go
vendored
Normal file
79
integrations/jikan/cache/store.go
vendored
Normal file
@@ -0,0 +1,79 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"mal/internal/db"
|
||||
"mal/internal/observability"
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
db db.Querier
|
||||
}
|
||||
|
||||
func NewStore(queries db.Querier) *Store {
|
||||
return &Store{db: queries}
|
||||
}
|
||||
|
||||
// Get retrieves a fresh cached value by key.
|
||||
func (s *Store) Get(parentCtx context.Context, key string, out any) bool {
|
||||
ctx, cancel := context.WithTimeout(parentCtx, 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
data, err := s.db.GetJikanCache(ctx, key)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(data), out); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// GetStale retrieves an expired-but-available cached value by key.
|
||||
func (s *Store) GetStale(parentCtx context.Context, key string, out any) bool {
|
||||
ctx, cancel := context.WithTimeout(parentCtx, 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
data, err := s.db.GetJikanCacheStale(ctx, key)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(data), out); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Set stores data in cache with the specified TTL.
|
||||
func (s *Store) Set(parentCtx context.Context, key string, data any, ttl time.Duration) {
|
||||
ctx, cancel := context.WithTimeout(parentCtx, 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
bytes, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = s.db.SetJikanCache(ctx, db.SetJikanCacheParams{
|
||||
Key: key,
|
||||
Data: string(bytes),
|
||||
ExpiresAt: time.Now().Add(ttl),
|
||||
})
|
||||
if err != nil {
|
||||
observability.LogJSON(
|
||||
observability.LogLevelError,
|
||||
"jikan_cache_set",
|
||||
"jikan",
|
||||
"",
|
||||
map[string]any{"cache_key": key},
|
||||
err,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
// Package jikan provides a client for the Jikan v4 API.
|
||||
package jikan
|
||||
|
||||
import "time"
|
||||
|
||||
// Cache TTLs used by the Jikan client for endpoint responses.
|
||||
const shortCacheTTL = time.Hour // 1 hour - for frequently changing data
|
||||
const longCacheTTL = time.Hour * 24 // 24 hours - for stable data like genres
|
||||
const producerCacheTTL = time.Hour * 24 * 30
|
||||
@@ -5,34 +5,29 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
jcache "mal/integrations/jikan/cache"
|
||||
"mal/integrations/jikan/rate"
|
||||
jtransport "mal/integrations/jikan/transport"
|
||||
"mal/internal/config"
|
||||
"mal/internal/db"
|
||||
"mal/internal/observability"
|
||||
netutil "mal/pkg/net"
|
||||
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
var traceEnabled bool
|
||||
|
||||
type Client struct {
|
||||
httpClient *http.Client
|
||||
baseURL string
|
||||
db db.Querier
|
||||
retrySignal chan struct{} // signals retry worker to process queued retries
|
||||
mu sync.Mutex
|
||||
lastReqTime time.Time // rate limiting: last request timestamp
|
||||
sf singleflight.Group
|
||||
refreshSem chan struct{}
|
||||
metrics *observability.Metrics
|
||||
baseURL string
|
||||
db db.Querier
|
||||
retrySignal chan struct{} // signals retry worker to process queued retries
|
||||
sf singleflight.Group
|
||||
refreshSem chan struct{}
|
||||
cache *jcache.Store
|
||||
fetcher *jtransport.Client
|
||||
traceEnabled bool
|
||||
|
||||
// Random anime pool for DDoS-proof truly random "Surprise Me"
|
||||
randomPool []Anime
|
||||
@@ -42,115 +37,39 @@ type Client struct {
|
||||
|
||||
const jikanSlowLogThreshold = 750 * time.Millisecond
|
||||
|
||||
func NewClient(cfg config.Config, queries *db.Queries, metrics *observability.Metrics) *Client {
|
||||
traceEnabled = cfg.JikanTrace
|
||||
return &Client{
|
||||
httpClient: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: 10,
|
||||
IdleConnTimeout: 30 * time.Second,
|
||||
DisableKeepAlives: false,
|
||||
TLSHandshakeTimeout: 5 * time.Second,
|
||||
},
|
||||
},
|
||||
baseURL: "https://api.jikan.moe/v4",
|
||||
db: queries,
|
||||
metrics: metrics,
|
||||
retrySignal: make(chan struct{}, 1),
|
||||
refreshSem: make(chan struct{}, 4),
|
||||
randomPool: make([]Anime, 0),
|
||||
type APIError = jtransport.APIError
|
||||
|
||||
func NewClient(cfg config.Config, queries *db.Queries) *Client {
|
||||
limiter := rate.NewLimiter(400 * time.Millisecond)
|
||||
client := &Client{
|
||||
baseURL: "https://api.jikan.moe/v4",
|
||||
db: queries,
|
||||
retrySignal: make(chan struct{}, 1),
|
||||
refreshSem: make(chan struct{}, 4),
|
||||
cache: jcache.NewStore(queries),
|
||||
traceEnabled: cfg.JikanTrace,
|
||||
randomPool: make([]Anime, 0),
|
||||
}
|
||||
}
|
||||
client.fetcher = jtransport.NewClient(jtransport.Config{
|
||||
HTTPClient: jtransport.NewHTTPClient(),
|
||||
Limiter: limiter,
|
||||
TraceEnabled: client.jikanTraceEnabled,
|
||||
})
|
||||
|
||||
type APIError struct {
|
||||
StatusCode int
|
||||
URL string
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
return fmt.Sprintf("jikan api returned status %d", e.StatusCode)
|
||||
return client
|
||||
}
|
||||
|
||||
// IsRetryableError returns true if the error should trigger a retry.
|
||||
func IsRetryableError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
var apiErr *APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
return isRetryableStatus(apiErr.StatusCode)
|
||||
}
|
||||
|
||||
var netErr net.Error
|
||||
if errors.As(err, &netErr) {
|
||||
return true
|
||||
}
|
||||
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
return jtransport.IsRetryableError(err)
|
||||
}
|
||||
|
||||
func isRetryableStatus(statusCode int) bool {
|
||||
if statusCode == http.StatusTooManyRequests {
|
||||
return true
|
||||
}
|
||||
|
||||
return statusCode >= 500 && statusCode <= 504
|
||||
func (c *Client) jikanTraceEnabled() bool {
|
||||
return c.traceEnabled
|
||||
}
|
||||
|
||||
// retryDelay returns exponential backoff delay: 500ms, 1s, 2s, 4s, 8s (capped).
|
||||
func retryDelay(attempt int) time.Duration {
|
||||
base := 500 * time.Millisecond
|
||||
delay := base * time.Duration(1<<attempt)
|
||||
if delay > 8*time.Second {
|
||||
return 8 * time.Second
|
||||
}
|
||||
|
||||
return delay
|
||||
}
|
||||
|
||||
// parseRetryAfter parses Retry-After header value (seconds) into duration.
|
||||
func parseRetryAfter(value string) (time.Duration, bool) {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
seconds, err := strconv.Atoi(trimmed)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
if seconds <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return time.Duration(seconds) * time.Second, true
|
||||
}
|
||||
|
||||
func waitForRetry(ctx context.Context, delay time.Duration) error {
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case <-timer.C:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("request canceled while retrying jikan request: %w", ctx.Err())
|
||||
}
|
||||
}
|
||||
|
||||
func jikanTraceEnabled() bool {
|
||||
return traceEnabled
|
||||
}
|
||||
|
||||
func shouldSkipJikanCacheLog(source string, duration time.Duration, err error) bool {
|
||||
if jikanTraceEnabled() || err != nil {
|
||||
func (c *Client) shouldSkipJikanCacheLog(source string, duration time.Duration, err error) bool {
|
||||
if c.jikanTraceEnabled() || err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -178,9 +97,13 @@ func jikanCacheLogLevel(source string, err error) observability.LogLevel {
|
||||
return observability.LogLevelInfo
|
||||
}
|
||||
|
||||
func logJikanCache(cacheKey string, source string, startedAt time.Time, err error) {
|
||||
func (c *Client) logJikanCache(cacheKey string, source string, startedAt time.Time, err error) {
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return
|
||||
}
|
||||
|
||||
duration := time.Since(startedAt)
|
||||
if shouldSkipJikanCacheLog(source, duration, err) {
|
||||
if c.shouldSkipJikanCacheLog(source, duration, err) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -198,43 +121,6 @@ func logJikanCache(cacheKey string, source string, startedAt time.Time, err erro
|
||||
)
|
||||
}
|
||||
|
||||
func logJikanUpstream(urlStr string, statusCode int, attempts int, startedAt time.Time, err error) {
|
||||
duration := time.Since(startedAt)
|
||||
if !jikanTraceEnabled() && err == nil && statusCode < http.StatusBadRequest && duration < jikanSlowLogThreshold {
|
||||
return
|
||||
}
|
||||
|
||||
level := observability.LogLevelInfo
|
||||
if err != nil || statusCode >= http.StatusInternalServerError {
|
||||
level = observability.LogLevelError
|
||||
} else if statusCode == http.StatusTooManyRequests || statusCode >= http.StatusBadRequest {
|
||||
level = observability.LogLevelWarn
|
||||
}
|
||||
|
||||
observability.LogJSON(
|
||||
level,
|
||||
"jikan_upstream",
|
||||
"jikan",
|
||||
"",
|
||||
map[string]any{
|
||||
"url": urlStr,
|
||||
"endpoint": metricsEndpoint(urlStr),
|
||||
"status": statusCode,
|
||||
"attempts": attempts,
|
||||
"duration_ms": float64(duration.Microseconds()) / 1000,
|
||||
},
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
func truncateErrorMessage(message string) string {
|
||||
if len(message) <= 400 {
|
||||
return message
|
||||
}
|
||||
|
||||
return message[:400]
|
||||
}
|
||||
|
||||
// notifyRetryWorker signals the retry worker, non-blocking.
|
||||
func (c *Client) notifyRetryWorker() {
|
||||
select {
|
||||
@@ -257,121 +143,76 @@ func (c *Client) EnqueueAnimeFetchRetry(parentCtx context.Context, animeID int,
|
||||
ctx, cancel := context.WithTimeout(parentCtx, 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
message := cause.Error()
|
||||
if len(message) > 400 {
|
||||
message = message[:400]
|
||||
}
|
||||
|
||||
err := c.db.EnqueueAnimeFetchRetry(ctx, db.EnqueueAnimeFetchRetryParams{
|
||||
AnimeID: int64(animeID),
|
||||
LastError: truncateErrorMessage(cause.Error()),
|
||||
LastError: message,
|
||||
})
|
||||
if err != nil {
|
||||
observability.Warn(
|
||||
"jikan_retry_enqueue_failed",
|
||||
"jikan",
|
||||
"",
|
||||
map[string]any{"anime_id": animeID},
|
||||
err,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
c.notifyRetryWorker()
|
||||
}
|
||||
|
||||
// waitRateLimit enforces Jikan's 3 req/sec rate limit with 400ms spacing.
|
||||
func (c *Client) waitRateLimit(ctx context.Context) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
// Jikan has a 3 req/sec limit AND a 60 req/min limit.
|
||||
// 400ms base delay keeps us safely under the 3/sec limit.
|
||||
nextAllowed := c.lastReqTime.Add(400 * time.Millisecond)
|
||||
if now.Before(nextAllowed) {
|
||||
timer := time.NewTimer(nextAllowed.Sub(now))
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case <-timer.C:
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("request canceled while waiting for rate limit: %w", ctx.Err())
|
||||
}
|
||||
c.lastReqTime = time.Now()
|
||||
} else {
|
||||
c.lastReqTime = now
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getCache retrieves cached data by key, returns true on cache hit.
|
||||
func (c *Client) getCache(parentCtx context.Context, key string, out any) bool {
|
||||
ctx, cancel := context.WithTimeout(parentCtx, 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
data, err := c.db.GetJikanCache(ctx, key)
|
||||
if err != nil {
|
||||
c.metrics.ObserveCache("jikan", "miss")
|
||||
return false
|
||||
}
|
||||
|
||||
err = json.Unmarshal([]byte(data), out)
|
||||
if err != nil {
|
||||
c.metrics.ObserveCache("jikan", "miss")
|
||||
return false
|
||||
}
|
||||
|
||||
c.metrics.ObserveCache("jikan", "hit")
|
||||
return true
|
||||
return c.cache.Get(parentCtx, key, out)
|
||||
}
|
||||
|
||||
// getStaleCache retrieves expired-but-available cache by key.
|
||||
func (c *Client) getStaleCache(parentCtx context.Context, key string, out any) bool {
|
||||
ctx, cancel := context.WithTimeout(parentCtx, 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
data, err := c.db.GetJikanCacheStale(ctx, key)
|
||||
if err != nil {
|
||||
c.metrics.ObserveCache("jikan_stale", "miss")
|
||||
return false
|
||||
}
|
||||
|
||||
err = json.Unmarshal([]byte(data), out)
|
||||
if err != nil {
|
||||
c.metrics.ObserveCache("jikan_stale", "miss")
|
||||
return false
|
||||
}
|
||||
|
||||
c.metrics.ObserveCache("jikan_stale", "hit")
|
||||
return true
|
||||
return c.cache.GetStale(parentCtx, key, out)
|
||||
}
|
||||
|
||||
// setCache stores data in cache with specified TTL.
|
||||
func (c *Client) setCache(parentCtx context.Context, key string, data any, ttl time.Duration) {
|
||||
ctx, cancel := context.WithTimeout(parentCtx, 2*time.Second)
|
||||
defer cancel()
|
||||
c.cache.Set(parentCtx, key, data, ttl)
|
||||
}
|
||||
|
||||
bytes, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
func (c *Client) fetchWithRetry(ctx context.Context, urlStr string, out any) error {
|
||||
return c.fetcher.FetchWithRetry(ctx, urlStr, out)
|
||||
}
|
||||
|
||||
_ = c.db.SetJikanCache(ctx, db.SetJikanCacheParams{
|
||||
Key: key,
|
||||
Data: string(bytes),
|
||||
ExpiresAt: time.Now().Add(ttl),
|
||||
})
|
||||
var emptyResultChecks = map[reflect.Type]func(any) bool{
|
||||
reflect.TypeFor[*TopAnimeResponse](): func(out any) bool {
|
||||
return len(out.(*TopAnimeResponse).Data) == 0
|
||||
},
|
||||
reflect.TypeFor[*AnimeResponse](): func(out any) bool {
|
||||
return out.(*AnimeResponse).Data.MalID == 0
|
||||
},
|
||||
reflect.TypeFor[*EpisodesResponse](): func(out any) bool {
|
||||
return len(out.(*EpisodesResponse).Data) == 0
|
||||
},
|
||||
reflect.TypeFor[*StaffResponse](): func(out any) bool {
|
||||
return len(out.(*StaffResponse).Data) == 0
|
||||
},
|
||||
reflect.TypeFor[*StatisticsResponse](): func(out any) bool {
|
||||
return out.(*StatisticsResponse).Data.Total == 0
|
||||
},
|
||||
reflect.TypeFor[*ThemesResponse](): func(out any) bool {
|
||||
themes := out.(*ThemesResponse).Data
|
||||
return len(themes.Openings) == 0 && len(themes.Endings) == 0
|
||||
},
|
||||
}
|
||||
|
||||
// isEmptyResult detects if response contains no meaningful data.
|
||||
func isEmptyResult(out any) bool {
|
||||
switch v := out.(type) {
|
||||
case *TopAnimeResponse:
|
||||
return len(v.Data) == 0
|
||||
case *SearchResponse:
|
||||
return len(v.Data) == 0
|
||||
case *AnimeResponse:
|
||||
return v.Data.MalID == 0
|
||||
case *EpisodesResponse:
|
||||
return len(v.Data) == 0
|
||||
case *StaffResponse:
|
||||
return len(v.Data) == 0
|
||||
case *StatisticsResponse:
|
||||
return v.Data.Total == 0
|
||||
case *ThemesResponse:
|
||||
return len(v.Data.Openings) == 0 && len(v.Data.Endings) == 0
|
||||
case *ReviewsResponse:
|
||||
return false // empty reviews is a valid state
|
||||
if out == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
outType := reflect.TypeOf(out)
|
||||
if check, ok := emptyResultChecks[outType]; ok {
|
||||
return check(out)
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -390,7 +231,7 @@ func cloneResponseTarget(out any) (any, bool) {
|
||||
}
|
||||
|
||||
func (c *Client) refreshWithCache(ctx context.Context, cacheKey string, ttl time.Duration, url string, out any) error {
|
||||
value, err, _ := c.sf.Do("refresh:"+cacheKey, func() (any, error) {
|
||||
value, err, shared := c.sf.Do("refresh:"+cacheKey, func() (any, error) {
|
||||
if c.getCache(ctx, cacheKey, out) {
|
||||
if !isEmptyResult(out) {
|
||||
return json.Marshal(out)
|
||||
@@ -401,7 +242,7 @@ func (c *Client) refreshWithCache(ctx context.Context, cacheKey string, ttl time
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Don't cache empty results to avoid caching failures
|
||||
// Don't cache empty results to avoid caching failures.
|
||||
if isEmptyResult(out) {
|
||||
return nil, fmt.Errorf("jikan: empty response for %s", cacheKey)
|
||||
}
|
||||
@@ -412,6 +253,14 @@ func (c *Client) refreshWithCache(ctx context.Context, cacheKey string, ttl time
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if shared {
|
||||
observability.Info(
|
||||
"jikan_cache_refresh_shared",
|
||||
"jikan",
|
||||
"",
|
||||
map[string]any{"cache_key": cacheKey, "url": url},
|
||||
)
|
||||
}
|
||||
|
||||
if bytes, ok := value.([]byte); ok {
|
||||
if err := json.Unmarshal(bytes, out); err == nil && !isEmptyResult(out) {
|
||||
@@ -429,7 +278,15 @@ func (c *Client) refreshWithCacheAsync(cacheKey string, ttl time.Duration, url s
|
||||
}
|
||||
|
||||
c.runAsyncRefresh(func(ctx context.Context) {
|
||||
_ = c.refreshWithCache(ctx, cacheKey, ttl, url, target)
|
||||
if err := c.refreshWithCache(ctx, cacheKey, ttl, url, target); err != nil {
|
||||
observability.Warn(
|
||||
"jikan_async_cache_refresh_failed",
|
||||
"jikan",
|
||||
"",
|
||||
map[string]any{"cache_key": cacheKey, "url": url},
|
||||
err,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -455,183 +312,26 @@ func (c *Client) getWithCache(ctx context.Context, cacheKey string, ttl time.Dur
|
||||
startedAt := time.Now()
|
||||
if c.getCache(ctx, cacheKey, out) {
|
||||
if !isEmptyResult(out) {
|
||||
logJikanCache(cacheKey, "fresh", startedAt, nil)
|
||||
c.logJikanCache(cacheKey, "fresh", startedAt, nil)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if c.getStaleCache(ctx, cacheKey, out) && !isEmptyResult(out) {
|
||||
logJikanCache(cacheKey, "stale", startedAt, nil)
|
||||
c.logJikanCache(cacheKey, "stale", startedAt, nil)
|
||||
c.refreshWithCacheAsync(cacheKey, ttl, url, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := c.refreshWithCache(ctx, cacheKey, ttl, url, out); err != nil {
|
||||
if c.getStaleCache(ctx, cacheKey, out) && !isEmptyResult(out) {
|
||||
logJikanCache(cacheKey, "stale_after_error", startedAt, err)
|
||||
c.logJikanCache(cacheKey, "stale_after_error", startedAt, err)
|
||||
return nil
|
||||
}
|
||||
logJikanCache(cacheKey, "miss", startedAt, err)
|
||||
c.logJikanCache(cacheKey, "miss", startedAt, err)
|
||||
return err
|
||||
}
|
||||
|
||||
logJikanCache(cacheKey, "refresh", startedAt, nil)
|
||||
c.logJikanCache(cacheKey, "refresh", startedAt, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
// fetchWithRetry makes HTTP request with exponential backoff retry on transient failures.
|
||||
func (c *Client) fetchWithRetry(ctx context.Context, urlStr string, out any) error {
|
||||
maxRetries := 5
|
||||
startedAt := time.Now()
|
||||
attempts := 0
|
||||
endpoint := metricsEndpoint(urlStr)
|
||||
logAndReturn := func(statusCode int, err error) error {
|
||||
c.metrics.ObserveJikanRequest(endpoint, statusCode, time.Since(startedAt), err)
|
||||
logJikanUpstream(urlStr, statusCode, attempts, startedAt, err)
|
||||
return err
|
||||
}
|
||||
|
||||
for attempt := range maxRetries {
|
||||
attempts = attempt + 1
|
||||
if err := c.prepareRetryAttempt(ctx); err != nil {
|
||||
return logAndReturn(0, err)
|
||||
}
|
||||
|
||||
resp, err := c.doRequest(ctx, urlStr)
|
||||
if err != nil {
|
||||
retry, requestErr := handleRequestRetry(ctx, err, attempt, maxRetries)
|
||||
if retry {
|
||||
continue
|
||||
}
|
||||
|
||||
return logAndReturn(0, requestErr)
|
||||
}
|
||||
|
||||
statusCode, retry, err := handleResponseRetry(ctx, resp, urlStr, out, attempt, maxRetries)
|
||||
if retry {
|
||||
continue
|
||||
}
|
||||
|
||||
return logAndReturn(statusCode, err)
|
||||
}
|
||||
|
||||
return logAndReturn(0, fmt.Errorf("max retries exceeded for %s", urlStr))
|
||||
}
|
||||
|
||||
func (c *Client) prepareRetryAttempt(ctx context.Context) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("request canceled while retrying jikan request: %w", ctx.Err())
|
||||
default:
|
||||
}
|
||||
|
||||
return c.waitRateLimit(ctx)
|
||||
}
|
||||
|
||||
func (c *Client) doRequest(ctx context.Context, urlStr string) (*http.Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlStr, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create jikan request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", netutil.Generic)
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func handleRequestRetry(ctx context.Context, err error, attempt int, maxRetries int) (bool, error) {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return false, fmt.Errorf("request canceled while retrying jikan request: %w", err)
|
||||
}
|
||||
|
||||
if attempt >= maxRetries-1 || !IsRetryableError(err) {
|
||||
return false, fmt.Errorf("jikan api error: %w", err)
|
||||
}
|
||||
|
||||
if retryErr := waitForRetry(ctx, retryDelay(attempt)); retryErr != nil {
|
||||
return false, retryErr
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func handleResponseRetry(ctx context.Context, resp *http.Response, urlStr string, out any, attempt int, maxRetries int) (int, bool, error) {
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return handleStatusRetry(ctx, resp, urlStr, out, attempt, maxRetries)
|
||||
}
|
||||
|
||||
err := json.NewDecoder(resp.Body).Decode(out)
|
||||
_ = resp.Body.Close()
|
||||
if err == nil {
|
||||
return resp.StatusCode, false, nil
|
||||
}
|
||||
|
||||
if attempt < maxRetries-1 {
|
||||
if retryErr := waitForRetry(ctx, retryDelay(attempt)); retryErr != nil {
|
||||
return resp.StatusCode, false, retryErr
|
||||
}
|
||||
return resp.StatusCode, true, nil
|
||||
}
|
||||
|
||||
return resp.StatusCode, false, fmt.Errorf("failed to decode jikan response: %w", err)
|
||||
}
|
||||
|
||||
func handleStatusRetry(ctx context.Context, resp *http.Response, urlStr string, out any, attempt int, maxRetries int) (int, bool, error) {
|
||||
statusCode := resp.StatusCode
|
||||
apiErr := &APIError{StatusCode: statusCode, URL: urlStr}
|
||||
|
||||
retryAfter := time.Duration(0)
|
||||
if parsed, ok := parseRetryAfter(resp.Header.Get("Retry-After")); ok {
|
||||
retryAfter = parsed
|
||||
}
|
||||
|
||||
if isRetryableStatus(statusCode) && attempt < maxRetries-1 {
|
||||
_ = resp.Body.Close()
|
||||
if retryErr := waitForRetry(ctx, max(retryAfter, retryDelay(attempt))); retryErr != nil {
|
||||
return statusCode, false, retryErr
|
||||
}
|
||||
return statusCode, true, nil
|
||||
}
|
||||
|
||||
// Best-effort decode (often useful for debugging), but still treat non-200 as error.
|
||||
_ = json.NewDecoder(resp.Body).Decode(out)
|
||||
_ = resp.Body.Close()
|
||||
return statusCode, false, apiErr
|
||||
}
|
||||
|
||||
func metricsEndpoint(urlStr string) string {
|
||||
trimmed := strings.TrimSpace(urlStr)
|
||||
if trimmed == "" {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
prefix := "https://api.jikan.moe/v4"
|
||||
trimmed = strings.TrimPrefix(trimmed, prefix)
|
||||
|
||||
if idx := strings.Index(trimmed, "?"); idx >= 0 {
|
||||
trimmed = trimmed[:idx]
|
||||
}
|
||||
|
||||
parts := strings.Split(trimmed, "/")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := strconv.Atoi(part); err == nil {
|
||||
out = append(out, "{id}")
|
||||
continue
|
||||
}
|
||||
out = append(out, part)
|
||||
}
|
||||
|
||||
if len(out) == 0 {
|
||||
return "/"
|
||||
}
|
||||
|
||||
return "/" + strings.Join(out, "/")
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"io"
|
||||
"mal/internal/config"
|
||||
"mal/internal/db"
|
||||
"mal/internal/observability"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -24,14 +23,18 @@ func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
|
||||
func TestGetWithCacheReturnsStaleAndRefreshesAsync(t *testing.T) {
|
||||
sqlDB := newTestCacheDB(t)
|
||||
defer sqlDB.Close()
|
||||
defer func() {
|
||||
if err := sqlDB.Close(); err != nil {
|
||||
t.Errorf("close sqlite: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
queries := db.New(sqlDB)
|
||||
client := NewClient(config.Config{}, queries, observability.NewMetrics())
|
||||
client := NewClient(config.Config{}, queries)
|
||||
stale := TopAnimeResponse{Data: []Anime{{MalID: 1, Title: "stale"}}}
|
||||
insertCachedResponse(t, sqlDB, "top:1", stale, time.Now().Add(-time.Hour))
|
||||
|
||||
client.httpClient = &http.Client{
|
||||
client.fetcher.HTTPClient = &http.Client{
|
||||
Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
body := `{"data":[{"mal_id":2,"title":"fresh"}]}`
|
||||
return &http.Response{
|
||||
@@ -52,6 +55,62 @@ func TestGetWithCacheReturnsStaleAndRefreshesAsync(t *testing.T) {
|
||||
waitForFreshCache(t, sqlDB, client, "top:1")
|
||||
}
|
||||
|
||||
func TestGetWithCacheAllowsEmptySearchResults(t *testing.T) {
|
||||
sqlDB := newTestCacheDB(t)
|
||||
defer func() {
|
||||
if err := sqlDB.Close(); err != nil {
|
||||
t.Errorf("close sqlite: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
queries := db.New(sqlDB)
|
||||
client := NewClient(config.Config{}, queries)
|
||||
client.fetcher.HTTPClient = &http.Client{
|
||||
Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
body := `{"pagination":{"has_next_page":false},"data":[]}`
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
}),
|
||||
}
|
||||
|
||||
var got SearchResponse
|
||||
if err := client.getWithCache(context.Background(), "search::::::12:0:true:1:24", time.Hour, "https://example.test/anime?genres=12", &got); err != nil {
|
||||
t.Fatalf("getWithCache() returned error for empty search response: %v", err)
|
||||
}
|
||||
if len(got.Data) != 0 {
|
||||
t.Fatalf("getWithCache() data length = %d, want 0", len(got.Data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCachedRandomPoolIgnoresExpiredAnimeCache(t *testing.T) {
|
||||
sqlDB := newTestCacheDB(t)
|
||||
defer func() {
|
||||
if err := sqlDB.Close(); err != nil {
|
||||
t.Errorf("close sqlite: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
queries := db.New(sqlDB)
|
||||
client := NewClient(config.Config{}, queries)
|
||||
insertCachedAnime(t, sqlDB, "anime:1", Anime{MalID: 1, Title: "fresh"}, time.Now().Add(time.Hour))
|
||||
insertCachedAnime(t, sqlDB, "anime:2", Anime{MalID: 2, Title: "expired"}, time.Now().Add(-time.Hour))
|
||||
|
||||
client.loadCachedRandomPool(context.Background())
|
||||
|
||||
client.poolMu.RLock()
|
||||
defer client.poolMu.RUnlock()
|
||||
|
||||
if len(client.randomPool) != 1 {
|
||||
t.Fatalf("randomPool length = %d, want 1", len(client.randomPool))
|
||||
}
|
||||
if client.randomPool[0].MalID != 1 || client.randomPool[0].Title != "fresh" {
|
||||
t.Fatalf("randomPool[0] = %+v, want fresh anime", client.randomPool[0])
|
||||
}
|
||||
}
|
||||
|
||||
func newTestCacheDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
@@ -71,7 +130,9 @@ func newTestCacheDB(t *testing.T) *sql.DB {
|
||||
);
|
||||
`)
|
||||
if err != nil {
|
||||
sqlDB.Close()
|
||||
if closeErr := sqlDB.Close(); closeErr != nil {
|
||||
t.Fatalf("create cache table: %v; close sqlite: %v", err, closeErr)
|
||||
}
|
||||
t.Fatalf("create cache table: %v", err)
|
||||
}
|
||||
|
||||
@@ -99,6 +160,27 @@ func insertCachedResponse(t *testing.T, sqlDB *sql.DB, key string, value TopAnim
|
||||
}
|
||||
}
|
||||
|
||||
func insertCachedAnime(t *testing.T, sqlDB *sql.DB, key string, value Anime, expiresAt time.Time) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal cached anime: %v", err)
|
||||
}
|
||||
|
||||
_, err = sqlDB.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO jikan_cache (key, data, expires_at) VALUES (?, ?, ?)`,
|
||||
key,
|
||||
string(encoded),
|
||||
expiresAt,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert cached anime: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func waitForFreshCache(t *testing.T, sqlDB *sql.DB, client *Client, key string) {
|
||||
t.Helper()
|
||||
|
||||
@@ -113,6 +195,8 @@ func waitForFreshCache(t *testing.T, sqlDB *sql.DB, client *Client, key string)
|
||||
|
||||
var rawData string
|
||||
var rawExpires string
|
||||
_ = sqlDB.QueryRowContext(context.Background(), `SELECT data, expires_at FROM jikan_cache WHERE key = ?`, key).Scan(&rawData, &rawExpires)
|
||||
if err := sqlDB.QueryRowContext(context.Background(), `SELECT data, expires_at FROM jikan_cache WHERE key = ?`, key).Scan(&rawData, &rawExpires); err != nil {
|
||||
t.Fatalf("query cached refresh result: %v", err)
|
||||
}
|
||||
t.Fatalf("cache was not refreshed asynchronously; data=%s expires_at=%s", rawData, rawExpires)
|
||||
}
|
||||
|
||||
@@ -27,6 +27,21 @@ type ProducerListResult struct {
|
||||
HasNextPage bool
|
||||
}
|
||||
|
||||
func (c *Client) GetProducerByID(ctx context.Context, id int) (ProducerResponse, error) {
|
||||
if id <= 0 {
|
||||
return ProducerResponse{}, fmt.Errorf("invalid producer id")
|
||||
}
|
||||
|
||||
cacheKey := fmt.Sprintf("producer:%d", id)
|
||||
reqURL := fmt.Sprintf("%s/producers/%d", c.baseURL, id)
|
||||
|
||||
var result ProducerResponse
|
||||
if err := c.getWithCache(ctx, cacheKey, producerCacheTTL, reqURL, &result); err != nil {
|
||||
return ProducerResponse{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetProducers(ctx context.Context, query string, page int, limit int) (ProducerListResult, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
|
||||
@@ -24,7 +24,7 @@ func setQueryValue(values url.Values, key, value string) {
|
||||
values.Set(key, value)
|
||||
}
|
||||
|
||||
func setPositiveIntQueryValue(values url.Values, key string, value int) {
|
||||
func setPositiveInt(values url.Values, key string, value int) {
|
||||
if value <= 0 {
|
||||
values.Del(key)
|
||||
return
|
||||
|
||||
66
integrations/jikan/rate/limiter.go
Normal file
66
integrations/jikan/rate/limiter.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package rate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Limiter struct {
|
||||
mu sync.Mutex
|
||||
nextReqTime time.Time
|
||||
interval time.Duration
|
||||
}
|
||||
|
||||
func NewLimiter(interval time.Duration) *Limiter {
|
||||
return &Limiter{interval: interval}
|
||||
}
|
||||
|
||||
// Wait enforces minimum spacing between upstream Jikan requests.
|
||||
func (l *Limiter) Wait(ctx context.Context) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
waitUntil := l.reserve(time.Now())
|
||||
if waitUntil.IsZero() {
|
||||
return nil
|
||||
}
|
||||
|
||||
timer := time.NewTimer(time.Until(waitUntil))
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case <-timer.C:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
l.release(waitUntil)
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Limiter) reserve(now time.Time) time.Time {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
if l.nextReqTime.IsZero() || now.After(l.nextReqTime) {
|
||||
l.nextReqTime = now.Add(l.interval)
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
waitUntil := l.nextReqTime
|
||||
l.nextReqTime = l.nextReqTime.Add(l.interval)
|
||||
return waitUntil
|
||||
}
|
||||
|
||||
func (l *Limiter) release(waitUntil time.Time) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
reservationEnd := waitUntil.Add(l.interval)
|
||||
if l.nextReqTime.Equal(reservationEnd) {
|
||||
l.nextReqTime = waitUntil
|
||||
}
|
||||
}
|
||||
40
integrations/jikan/rate/limiter_test.go
Normal file
40
integrations/jikan/rate/limiter_test.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package rate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLimiterDoesNotHoldLockWhileWaiting(t *testing.T) {
|
||||
limiter := NewLimiter(250 * time.Millisecond)
|
||||
if err := limiter.Wait(context.Background()); err != nil {
|
||||
t.Fatalf("initial wait: %v", err)
|
||||
}
|
||||
|
||||
firstCtx, cancelFirst := context.WithCancel(context.Background())
|
||||
defer cancelFirst()
|
||||
|
||||
firstDone := make(chan error, 1)
|
||||
go func() {
|
||||
firstDone <- limiter.Wait(firstCtx)
|
||||
}()
|
||||
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
|
||||
secondCtx, cancelSecond := context.WithTimeout(context.Background(), 30*time.Millisecond)
|
||||
defer cancelSecond()
|
||||
|
||||
startedAt := time.Now()
|
||||
err := limiter.Wait(secondCtx)
|
||||
elapsed := time.Since(startedAt)
|
||||
if err == nil {
|
||||
t.Fatal("second wait succeeded, want context timeout")
|
||||
}
|
||||
if elapsed > 150*time.Millisecond {
|
||||
t.Fatalf("second wait took %s, want it to observe context timeout without waiting behind first caller", elapsed)
|
||||
}
|
||||
|
||||
cancelFirst()
|
||||
<-firstDone
|
||||
}
|
||||
@@ -54,37 +54,13 @@ func watchOrderTypeLabel(value string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func isTVWatchOrderType(value string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(value), "tv")
|
||||
}
|
||||
|
||||
// isAllowedWatchOrderType returns true for the default uncluttered watch order types.
|
||||
func isAllowedWatchOrderType(value string) bool {
|
||||
normalized := strings.ToLower(strings.TrimSpace(value))
|
||||
return normalized == "tv" || normalized == "movie"
|
||||
}
|
||||
|
||||
func hasTVWatchOrderEntry(entries []watchorder.WatchOrderEntry) bool {
|
||||
for _, entry := range entries {
|
||||
if isTVWatchOrderType(entry.Type) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func relationCacheKey(id int) string {
|
||||
return fmt.Sprintf("relations:watch-order:%d", id)
|
||||
}
|
||||
|
||||
func (c *Client) refreshWatchOrder(ctx context.Context, id int) (watchorder.WatchOrderResult, error) {
|
||||
cacheKey := relationCacheKey(id)
|
||||
cacheKey := fmt.Sprintf("relations:watch-order:%d", id)
|
||||
watchOrderURL := fmt.Sprintf(chiakiWatchOrderURL, id)
|
||||
requestCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
result, err := watchorder.FetchWatchOrder(requestCtx, c.httpClient, watchOrderURL)
|
||||
result, err := watchorder.FetchWatchOrder(requestCtx, c.fetcher.HTTPClient, watchOrderURL)
|
||||
if err != nil {
|
||||
var statusError *watchorder.HTTPStatusError
|
||||
if errors.As(err, &statusError) && statusError.StatusCode == http.StatusNotFound {
|
||||
@@ -139,13 +115,21 @@ func (c *Client) refreshWatchOrder(ctx context.Context, id int) (watchorder.Watc
|
||||
|
||||
func (c *Client) refreshWatchOrderAsync(id int) {
|
||||
c.runAsyncRefresh(func(ctx context.Context) {
|
||||
_, _ = c.refreshWatchOrder(ctx, id)
|
||||
if _, err := c.refreshWatchOrder(ctx, id); err != nil {
|
||||
observability.Warn(
|
||||
"relations_watch_order_async_refresh_failed",
|
||||
"jikan",
|
||||
"",
|
||||
map[string]any{"anime_id": id},
|
||||
err,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// getWatchOrder fetches watch order from chiaki, caches result for 24h.
|
||||
func (c *Client) getWatchOrder(ctx context.Context, id int) (watchorder.WatchOrderResult, error) {
|
||||
cacheKey := relationCacheKey(id)
|
||||
cacheKey := fmt.Sprintf("relations:watch-order:%d", id)
|
||||
|
||||
var cached watchorder.WatchOrderResult
|
||||
if c.getCache(ctx, cacheKey, &cached) {
|
||||
@@ -201,10 +185,18 @@ func (c *Client) handleWatchOrderError(ctx context.Context, id int, err error) (
|
||||
return c.currentOnlyRelation(ctx, id)
|
||||
}
|
||||
|
||||
func buildAllowedWatchOrderEntries(result watchorder.WatchOrderResult, mode WatchOrderMode) ([]watchorder.WatchOrderEntry, map[int]bool) {
|
||||
// relation filter
|
||||
func allowedWatchOrder(result watchorder.WatchOrderResult, mode WatchOrderMode) ([]watchorder.WatchOrderEntry, map[int]bool) {
|
||||
allowedEntries := make([]watchorder.WatchOrderEntry, 0, len(result.WatchOrder))
|
||||
seen := make(map[int]bool)
|
||||
shouldIncludeAllTypes := mode == WatchOrderModeComplete || !hasTVWatchOrderEntry(result.WatchOrder)
|
||||
hasTVEntry := false
|
||||
for _, entry := range result.WatchOrder {
|
||||
if strings.EqualFold(strings.TrimSpace(entry.Type), "tv") {
|
||||
hasTVEntry = true
|
||||
break
|
||||
}
|
||||
}
|
||||
allTypes := mode == WatchOrderModeComplete || !hasTVEntry
|
||||
|
||||
for _, entry := range result.WatchOrder {
|
||||
if len(allowedEntries) >= maxWatchOrderEntries {
|
||||
@@ -213,7 +205,8 @@ func buildAllowedWatchOrderEntries(result watchorder.WatchOrderResult, mode Watc
|
||||
if seen[entry.ID] {
|
||||
continue
|
||||
}
|
||||
if !shouldIncludeAllTypes && !isAllowedWatchOrderType(entry.Type) {
|
||||
typ := strings.ToLower(strings.TrimSpace(entry.Type))
|
||||
if !allTypes && typ != "tv" && typ != "movie" {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -224,7 +217,7 @@ func buildAllowedWatchOrderEntries(result watchorder.WatchOrderResult, mode Watc
|
||||
return allowedEntries, seen
|
||||
}
|
||||
|
||||
func (c *Client) fetchRelationResults(ctx context.Context, entries []watchorder.WatchOrderEntry) []fetchResult {
|
||||
func (c *Client) fetchEntries(ctx context.Context, entries []watchorder.WatchOrderEntry) chan fetchResult {
|
||||
g, gCtx := errgroup.WithContext(ctx)
|
||||
g.SetLimit(3)
|
||||
|
||||
@@ -237,6 +230,16 @@ func (c *Client) fetchRelationResults(ctx context.Context, entries []watchorder.
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return nil
|
||||
}
|
||||
observability.Warn(
|
||||
"relations_fetch_entry_failed",
|
||||
"jikan",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": entry.ID,
|
||||
"index": i,
|
||||
},
|
||||
err,
|
||||
)
|
||||
c.EnqueueAnimeFetchRetry(gCtx, entry.ID, err)
|
||||
return nil
|
||||
}
|
||||
@@ -251,15 +254,37 @@ func (c *Client) fetchRelationResults(ctx context.Context, entries []watchorder.
|
||||
}
|
||||
|
||||
go func() {
|
||||
_ = g.Wait()
|
||||
if err := g.Wait(); err != nil {
|
||||
observability.Warn("relations_fetch_group_failed", "jikan", "", nil, err)
|
||||
}
|
||||
close(results)
|
||||
}()
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func (c *Client) fetchResults(ctx context.Context, entries []watchorder.WatchOrderEntry) []fetchResult {
|
||||
results := c.fetchEntries(ctx, entries)
|
||||
|
||||
fetched := make([]fetchResult, 0, len(entries))
|
||||
for res := range results {
|
||||
fetched = append(fetched, res)
|
||||
}
|
||||
|
||||
if len(fetched) < len(entries) {
|
||||
observability.Warn(
|
||||
"relations_fetch_incomplete",
|
||||
"jikan",
|
||||
"",
|
||||
map[string]any{
|
||||
"expected": len(entries),
|
||||
"fetched": len(fetched),
|
||||
"missing": len(entries) - len(fetched),
|
||||
},
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
sort.Slice(fetched, func(i, j int) bool {
|
||||
return fetched[i].index < fetched[j].index
|
||||
})
|
||||
@@ -267,7 +292,7 @@ func (c *Client) fetchRelationResults(ctx context.Context, entries []watchorder.
|
||||
return fetched
|
||||
}
|
||||
|
||||
func buildRelationsFromResults(results []fetchResult, id int) []RelationEntry {
|
||||
func buildRelations(results []fetchResult, id int) []RelationEntry {
|
||||
relations := make([]RelationEntry, 0, len(results)+1)
|
||||
for _, res := range results {
|
||||
relations = append(relations, RelationEntry{
|
||||
@@ -281,7 +306,7 @@ func buildRelationsFromResults(results []fetchResult, id int) []RelationEntry {
|
||||
return relations
|
||||
}
|
||||
|
||||
func (c *Client) ensureCurrentRelation(ctx context.Context, id int, seen map[int]bool, relations []RelationEntry) ([]RelationEntry, error) {
|
||||
func (c *Client) ensureCurrent(ctx context.Context, id int, seen map[int]bool, relations []RelationEntry) ([]RelationEntry, error) {
|
||||
if seen[id] {
|
||||
return relations, nil
|
||||
}
|
||||
@@ -312,10 +337,10 @@ func (c *Client) GetFullRelations(ctx context.Context, id int, mode WatchOrderMo
|
||||
return c.handleWatchOrderError(ctx, id, err)
|
||||
}
|
||||
|
||||
allowedEntries, seen := buildAllowedWatchOrderEntries(result, mode)
|
||||
fetched := c.fetchRelationResults(ctx, allowedEntries)
|
||||
relations := buildRelationsFromResults(fetched, id)
|
||||
relations, err = c.ensureCurrentRelation(ctx, id, seen, relations)
|
||||
allowedEntries, seen := allowedWatchOrder(result, mode)
|
||||
fetched := c.fetchResults(ctx, allowedEntries)
|
||||
relations := buildRelations(fetched, id)
|
||||
relations, err = c.ensureCurrent(ctx, id, seen, relations)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -329,6 +354,14 @@ func (c *Client) GetFullRelations(ctx context.Context, id int, mode WatchOrderMo
|
||||
|
||||
func (c *Client) WarmFullRelations(id int) {
|
||||
c.runAsyncRefresh(func(ctx context.Context) {
|
||||
_, _ = c.GetFullRelations(ctx, id, WatchOrderModeMain)
|
||||
if _, err := c.GetFullRelations(ctx, id, WatchOrderModeMain); err != nil {
|
||||
observability.Warn(
|
||||
"relations_warm_full_failed",
|
||||
"jikan",
|
||||
"",
|
||||
map[string]any{"anime_id": id},
|
||||
err,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,40 +5,6 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func runBoolCases(t *testing.T, tests []struct {
|
||||
name string
|
||||
input string
|
||||
want bool
|
||||
}, fn func(string) bool) {
|
||||
t.Helper()
|
||||
|
||||
for _, testCase := range tests {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
got := fn(testCase.input)
|
||||
if got != testCase.want {
|
||||
t.Fatalf("expected %v, got %v", testCase.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAllowedWatchOrderType(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want bool
|
||||
}{
|
||||
{name: "tv", input: "tv", want: true},
|
||||
{name: "movie", input: "movie", want: true},
|
||||
{name: "case and whitespace", input: " TV ", want: true},
|
||||
{name: "tv special", input: "tv special", want: false},
|
||||
{name: "ova", input: "ova", want: false},
|
||||
{name: "empty", input: "", want: false},
|
||||
}
|
||||
|
||||
runBoolCases(t, tests, isAllowedWatchOrderType)
|
||||
}
|
||||
|
||||
func TestNormalizeWatchOrderMode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -62,51 +28,17 @@ func TestNormalizeWatchOrderMode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasTVWatchOrderEntry(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
entries []watchorder.WatchOrderEntry
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "contains tv",
|
||||
entries: []watchorder.WatchOrderEntry{
|
||||
{ID: 1, Type: "Movie"},
|
||||
{ID: 2, Type: " TV "},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "ona only",
|
||||
entries: []watchorder.WatchOrderEntry{
|
||||
{ID: 1, Type: "ONA"},
|
||||
{ID: 2, Type: "Special"},
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range tests {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
got := hasTVWatchOrderEntry(testCase.entries)
|
||||
if got != testCase.want {
|
||||
t.Fatalf("expected %v, got %v", testCase.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAllowedWatchOrderEntriesKeepsDefaultTypesWhenTVExists(t *testing.T) {
|
||||
result := watchorder.WatchOrderResult{
|
||||
WatchOrder: []watchorder.WatchOrderEntry{
|
||||
{ID: 1, Type: "TV"},
|
||||
{ID: 2, Type: "Special"},
|
||||
{ID: 3, Type: "Movie"},
|
||||
{ID: 3, Type: " Movie "},
|
||||
{ID: 4, Type: "ONA"},
|
||||
},
|
||||
}
|
||||
|
||||
entries, seen := buildAllowedWatchOrderEntries(result, WatchOrderModeMain)
|
||||
entries, seen := allowedWatchOrder(result, WatchOrderModeMain)
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("expected 2 entries, got %d", len(entries))
|
||||
}
|
||||
@@ -130,7 +62,7 @@ func TestBuildAllowedWatchOrderEntriesIncludesAllTypesWhenNoTVExists(t *testing.
|
||||
},
|
||||
}
|
||||
|
||||
entries, seen := buildAllowedWatchOrderEntries(result, WatchOrderModeMain)
|
||||
entries, seen := allowedWatchOrder(result, WatchOrderModeMain)
|
||||
if len(entries) != 3 {
|
||||
t.Fatalf("expected 3 entries, got %d", len(entries))
|
||||
}
|
||||
@@ -154,7 +86,7 @@ func TestBuildAllowedWatchOrderEntriesIncludesAllTypesInCompleteMode(t *testing.
|
||||
},
|
||||
}
|
||||
|
||||
entries, seen := buildAllowedWatchOrderEntries(result, WatchOrderModeComplete)
|
||||
entries, seen := allowedWatchOrder(result, WatchOrderModeComplete)
|
||||
if len(entries) != 4 {
|
||||
t.Fatalf("expected 4 entries, got %d", len(entries))
|
||||
}
|
||||
@@ -193,17 +125,3 @@ func TestWatchOrderTypeLabel(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowedWatchOrderTypeFromDataset(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want bool
|
||||
}{
|
||||
{name: "label tv", input: "TV", want: true},
|
||||
{name: "label movie", input: "Movie", want: true},
|
||||
{name: "label special", input: "Special", want: false},
|
||||
}
|
||||
|
||||
runBoolCases(t, tests, isAllowedWatchOrderType)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
func normalizeSearchPagination(page, limit int) (int, int) {
|
||||
func normalizePage(page, limit int) (int, int) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
@@ -32,31 +32,31 @@ func joinGenreIDs(genres []int) string {
|
||||
return strings.Join(ids, ",")
|
||||
}
|
||||
|
||||
func buildAdvancedSearchURL(baseURL, query, animeType, status, orderBy, sort, genres string, studioID int, sfw bool, page, limit int) string {
|
||||
func advancedURL(baseURL, query, animeType, status, orderBy, sort, genres string, studioID int, sfw bool, page, limit int) string {
|
||||
params := url.Values{}
|
||||
params.Set("page", strconv.Itoa(page))
|
||||
setTrueQueryValue(params, "sfw", sfw)
|
||||
setQueryValue(params, "q", query)
|
||||
setQueryValue(params, "type", animeType)
|
||||
setQueryValue(params, "status", status)
|
||||
setPositiveIntQueryValue(params, "producers", studioID)
|
||||
setPositiveInt(params, "producers", studioID)
|
||||
setQueryValue(params, "order_by", orderBy)
|
||||
setQueryValue(params, "sort", sort)
|
||||
setQueryValue(params, "genres", genres)
|
||||
setPositiveIntQueryValue(params, "limit", limit)
|
||||
setPositiveInt(params, "limit", limit)
|
||||
|
||||
return buildRequestURL(baseURL, "/anime", params)
|
||||
}
|
||||
|
||||
// SearchAdvanced performs a filtered anime search with type, status, ordering, genre filters, and studio (producer) filters.
|
||||
func (c *Client) SearchAdvanced(ctx context.Context, query, animeType, status, orderBy, sort string, genres []int, studioID int, sfw bool, page, limit int) (SearchResult, error) {
|
||||
page, limit = normalizeSearchPagination(page, limit)
|
||||
page, limit = normalizePage(page, limit)
|
||||
genresParam := joinGenreIDs(genres)
|
||||
|
||||
cacheKey := fmt.Sprintf("search:%s:%s:%s:%s:%s:%s:%d:%v:%d:%d", query, animeType, status, orderBy, sort, genresParam, studioID, sfw, page, limit)
|
||||
|
||||
var result SearchResponse
|
||||
reqURL := buildAdvancedSearchURL(c.baseURL, query, animeType, status, orderBy, sort, genresParam, studioID, sfw, page, limit)
|
||||
reqURL := advancedURL(c.baseURL, query, animeType, status, orderBy, sort, genresParam, studioID, sfw, page, limit)
|
||||
|
||||
if err := c.getWithCache(ctx, cacheKey, shortCacheTTL, reqURL, &result); err != nil {
|
||||
return SearchResult{}, err
|
||||
@@ -67,39 +67,3 @@ func (c *Client) SearchAdvanced(ctx context.Context, query, animeType, status, o
|
||||
HasNextPage: result.Pagination.HasNextPage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetTopAnime returns the top-rated anime list for a given page.
|
||||
func (c *Client) GetTopAnime(ctx context.Context, page int) (TopAnimeResult, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
cacheKey := fmt.Sprintf("top:%d", page)
|
||||
|
||||
var result TopAnimeResponse
|
||||
params := url.Values{}
|
||||
params.Set("page", strconv.Itoa(page))
|
||||
reqURL := buildRequestURL(c.baseURL, "/top/anime", params)
|
||||
|
||||
if err := c.getWithCache(ctx, cacheKey, shortCacheTTL, reqURL, &result); err != nil {
|
||||
return TopAnimeResult{}, err
|
||||
}
|
||||
|
||||
return TopAnimeResult{
|
||||
Animes: result.Data,
|
||||
HasNextPage: result.Pagination.HasNextPage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetAnimeGenres returns list of all anime genres, cached long-term.
|
||||
func (c *Client) GetAnimeGenres(ctx context.Context) ([]Genre, error) {
|
||||
const cacheKey = "anime_genres"
|
||||
|
||||
var result GenresResponse
|
||||
reqURL := fmt.Sprintf("%s/genres/anime", c.baseURL)
|
||||
|
||||
if err := c.getWithCache(ctx, cacheKey, longCacheTTL, reqURL, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result.Data, nil
|
||||
}
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
package jikan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type ProducerResponse struct {
|
||||
Data struct {
|
||||
MalID int `json:"mal_id"`
|
||||
Titles []struct {
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
} `json:"titles"`
|
||||
Images struct {
|
||||
Jpg struct {
|
||||
ImageURL string `json:"image_url"`
|
||||
} `json:"jpg"`
|
||||
} `json:"images"`
|
||||
Favorites int `json:"favorites"`
|
||||
Established string `json:"established"`
|
||||
About string `json:"about"`
|
||||
Count int `json:"count"`
|
||||
External []struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
} `json:"external"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func (c *Client) GetProducerByID(ctx context.Context, id int) (ProducerResponse, error) {
|
||||
if id <= 0 {
|
||||
return ProducerResponse{}, fmt.Errorf("invalid producer id")
|
||||
}
|
||||
|
||||
cacheKey := fmt.Sprintf("producer:%d", id)
|
||||
reqURL := fmt.Sprintf("%s/producers/%d", c.baseURL, id)
|
||||
|
||||
var result ProducerResponse
|
||||
if err := c.getWithCache(ctx, cacheKey, producerCacheTTL, reqURL, &result); err != nil {
|
||||
return ProducerResponse{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
352
integrations/jikan/transport/client.go
Normal file
352
integrations/jikan/transport/client.go
Normal file
@@ -0,0 +1,352 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"mal/integrations/jikan/rate"
|
||||
"mal/internal/observability"
|
||||
errlog "mal/pkg"
|
||||
netutil "mal/pkg/net"
|
||||
)
|
||||
|
||||
const slowLogThreshold = 750 * time.Millisecond
|
||||
|
||||
type Client struct {
|
||||
HTTPClient *http.Client
|
||||
Limiter *rate.Limiter
|
||||
TraceEnabled func() bool
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
HTTPClient *http.Client
|
||||
Limiter *rate.Limiter
|
||||
TraceEnabled func() bool
|
||||
}
|
||||
|
||||
type APIError struct {
|
||||
StatusCode int
|
||||
URL string
|
||||
Body json.RawMessage
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
return fmt.Sprintf("jikan api returned status %d", e.StatusCode)
|
||||
}
|
||||
|
||||
func NewHTTPClient() *http.Client {
|
||||
return &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: 10,
|
||||
IdleConnTimeout: 30 * time.Second,
|
||||
DisableKeepAlives: false,
|
||||
TLSHandshakeTimeout: 5 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func NewClient(cfg Config) *Client {
|
||||
return &Client{
|
||||
HTTPClient: cfg.HTTPClient,
|
||||
Limiter: cfg.Limiter,
|
||||
TraceEnabled: cfg.TraceEnabled,
|
||||
}
|
||||
}
|
||||
|
||||
// IsRetryableError returns true if the error should trigger a retry.
|
||||
func IsRetryableError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return false
|
||||
}
|
||||
|
||||
var apiErr *APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
return isRetryableStatus(apiErr.StatusCode)
|
||||
}
|
||||
|
||||
var netErr net.Error
|
||||
if errors.As(err, &netErr) {
|
||||
return true
|
||||
}
|
||||
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// FetchWithRetry makes an HTTP request with exponential backoff on transient failures.
|
||||
func (c *Client) FetchWithRetry(ctx context.Context, urlStr string, out any) error {
|
||||
maxRetries := 5
|
||||
startedAt := time.Now()
|
||||
attempts := 0
|
||||
logAndReturn := func(statusCode int, err error) error {
|
||||
if isDoneContextError(ctx, err) {
|
||||
return err
|
||||
}
|
||||
c.logUpstream(urlStr, statusCode, attempts, startedAt, err)
|
||||
return err
|
||||
}
|
||||
|
||||
for attempt := range maxRetries {
|
||||
attempts = attempt + 1
|
||||
if err := c.prepareRetryAttempt(ctx); err != nil {
|
||||
return logAndReturn(0, err)
|
||||
}
|
||||
|
||||
resp, err := c.doRequest(ctx, urlStr)
|
||||
if err != nil {
|
||||
retry, requestErr := handleRequestRetry(ctx, err, attempt, maxRetries)
|
||||
if retry {
|
||||
continue
|
||||
}
|
||||
|
||||
return logAndReturn(0, requestErr)
|
||||
}
|
||||
|
||||
statusCode, retry, err := func() (int, bool, error) {
|
||||
defer func() {
|
||||
errlog.Log("failed to close jikan response body", resp.Body.Close())
|
||||
}()
|
||||
return handleResponseRetry(ctx, resp, urlStr, out, attempt, maxRetries)
|
||||
}()
|
||||
if retry {
|
||||
continue
|
||||
}
|
||||
|
||||
return logAndReturn(statusCode, err)
|
||||
}
|
||||
|
||||
return logAndReturn(0, fmt.Errorf("max retries exceeded for %s", urlStr))
|
||||
}
|
||||
|
||||
func (c *Client) prepareRetryAttempt(ctx context.Context) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
return c.Limiter.Wait(ctx)
|
||||
}
|
||||
|
||||
func (c *Client) doRequest(ctx context.Context, urlStr string) (*http.Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlStr, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create jikan request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", netutil.Generic)
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func handleRequestRetry(ctx context.Context, err error, attempt int, maxRetries int) (bool, error) {
|
||||
if ctx.Err() != nil {
|
||||
return false, ctx.Err()
|
||||
}
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if attempt >= maxRetries-1 || !IsRetryableError(err) {
|
||||
return false, fmt.Errorf("jikan api error: %w", err)
|
||||
}
|
||||
|
||||
if retryErr := waitForRetry(ctx, retryDelay(attempt)); retryErr != nil {
|
||||
return false, retryErr
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func handleResponseRetry(ctx context.Context, resp *http.Response, urlStr string, out any, attempt int, maxRetries int) (int, bool, error) {
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return handleStatusRetry(ctx, resp, urlStr, attempt, maxRetries)
|
||||
}
|
||||
|
||||
err := json.NewDecoder(resp.Body).Decode(out)
|
||||
if err == nil {
|
||||
return resp.StatusCode, false, nil
|
||||
}
|
||||
|
||||
if attempt < maxRetries-1 {
|
||||
if retryErr := waitForRetry(ctx, retryDelay(attempt)); retryErr != nil {
|
||||
return resp.StatusCode, false, retryErr
|
||||
}
|
||||
return resp.StatusCode, true, nil
|
||||
}
|
||||
|
||||
return resp.StatusCode, false, fmt.Errorf("failed to decode jikan response: %w", err)
|
||||
}
|
||||
|
||||
func handleStatusRetry(ctx context.Context, resp *http.Response, urlStr string, attempt int, maxRetries int) (int, bool, error) {
|
||||
statusCode := resp.StatusCode
|
||||
apiErr := &APIError{StatusCode: statusCode, URL: urlStr}
|
||||
|
||||
retryAfter := time.Duration(0)
|
||||
if parsed, ok := parseRetryAfter(resp.Header.Get("Retry-After")); ok {
|
||||
retryAfter = parsed
|
||||
}
|
||||
|
||||
if isRetryableStatus(statusCode) && attempt < maxRetries-1 {
|
||||
if retryErr := waitForRetry(ctx, max(retryAfter, retryDelay(attempt))); retryErr != nil {
|
||||
return statusCode, false, retryErr
|
||||
}
|
||||
return statusCode, true, nil
|
||||
}
|
||||
|
||||
apiErr.Body = readErrorBody(resp)
|
||||
return statusCode, false, apiErr
|
||||
}
|
||||
|
||||
func readErrorBody(resp *http.Response) json.RawMessage {
|
||||
if resp.Body == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
body = []byte(strings.TrimSpace(string(body)))
|
||||
if len(body) == 0 || !json.Valid(body) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return json.RawMessage(body)
|
||||
}
|
||||
|
||||
func isRetryableStatus(statusCode int) bool {
|
||||
if statusCode == http.StatusTooManyRequests {
|
||||
return true
|
||||
}
|
||||
|
||||
return statusCode >= 500 && statusCode <= 504
|
||||
}
|
||||
|
||||
// retryDelay returns exponential backoff delay: 500ms, 1s, 2s, 4s, 8s (capped).
|
||||
func retryDelay(attempt int) time.Duration {
|
||||
base := 500 * time.Millisecond
|
||||
delay := base * time.Duration(1<<attempt)
|
||||
if delay > 8*time.Second {
|
||||
return 8 * time.Second
|
||||
}
|
||||
|
||||
return delay
|
||||
}
|
||||
|
||||
// parseRetryAfter parses Retry-After header value (seconds) into duration.
|
||||
func parseRetryAfter(value string) (time.Duration, bool) {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
seconds, err := strconv.Atoi(trimmed)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
if seconds <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return time.Duration(seconds) * time.Second, true
|
||||
}
|
||||
|
||||
func waitForRetry(ctx context.Context, delay time.Duration) error {
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case <-timer.C:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func isDoneContextError(ctx context.Context, err error) bool {
|
||||
return err != nil && ctx.Err() != nil && errors.Is(err, ctx.Err())
|
||||
}
|
||||
|
||||
func (c *Client) logUpstream(urlStr string, statusCode int, attempts int, startedAt time.Time, err error) {
|
||||
duration := time.Since(startedAt)
|
||||
traceEnabled := c.TraceEnabled != nil && c.TraceEnabled()
|
||||
if !traceEnabled && err == nil && statusCode < http.StatusBadRequest && duration < slowLogThreshold {
|
||||
return
|
||||
}
|
||||
|
||||
level := observability.LogLevelInfo
|
||||
if err != nil || statusCode >= http.StatusInternalServerError {
|
||||
level = observability.LogLevelError
|
||||
} else if statusCode == http.StatusTooManyRequests || statusCode >= http.StatusBadRequest {
|
||||
level = observability.LogLevelWarn
|
||||
}
|
||||
|
||||
observability.LogJSON(
|
||||
level,
|
||||
"jikan_upstream",
|
||||
"jikan",
|
||||
"",
|
||||
map[string]any{
|
||||
"url": urlStr,
|
||||
"endpoint": endpointLabel(urlStr),
|
||||
"status": statusCode,
|
||||
"attempts": attempts,
|
||||
"duration_ms": float64(duration.Microseconds()) / 1000,
|
||||
},
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
func endpointLabel(urlStr string) string {
|
||||
trimmed := strings.TrimSpace(urlStr)
|
||||
if trimmed == "" {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
prefix := "https://api.jikan.moe/v4"
|
||||
trimmed = strings.TrimPrefix(trimmed, prefix)
|
||||
|
||||
if idx := strings.Index(trimmed, "?"); idx >= 0 {
|
||||
trimmed = trimmed[:idx]
|
||||
}
|
||||
|
||||
parts := strings.Split(trimmed, "/")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := strconv.Atoi(part); err == nil {
|
||||
out = append(out, "{id}")
|
||||
continue
|
||||
}
|
||||
out = append(out, part)
|
||||
}
|
||||
|
||||
if len(out) == 0 {
|
||||
return "/"
|
||||
}
|
||||
|
||||
return "/" + strings.Join(out, "/")
|
||||
}
|
||||
55
integrations/jikan/transport/client_test.go
Normal file
55
integrations/jikan/transport/client_test.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHandleStatusRetryLeavesOutputUntouched(t *testing.T) {
|
||||
out := struct {
|
||||
Data []struct {
|
||||
MalID int `json:"mal_id"`
|
||||
} `json:"data"`
|
||||
}{
|
||||
Data: []struct {
|
||||
MalID int `json:"mal_id"`
|
||||
}{{MalID: 123}},
|
||||
}
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusNotFound,
|
||||
Body: io.NopCloser(strings.NewReader(`{"data":[{"mal_id":999}]}`)),
|
||||
Header: make(http.Header),
|
||||
}
|
||||
|
||||
statusCode, retry, err := handleResponseRetry(context.Background(), resp, "https://example.test/anime/1", &out, 0, 1)
|
||||
if statusCode != http.StatusNotFound {
|
||||
t.Fatalf("statusCode = %d, want %d", statusCode, http.StatusNotFound)
|
||||
}
|
||||
if retry {
|
||||
t.Fatal("retry = true, want false")
|
||||
}
|
||||
var apiErr *APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("err = %v, want APIError", err)
|
||||
}
|
||||
if len(out.Data) != 1 || out.Data[0].MalID != 123 {
|
||||
t.Fatalf("out = %+v, want original value", out)
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Data []struct {
|
||||
MalID int `json:"mal_id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(apiErr.Body, &body); err != nil {
|
||||
t.Fatalf("unmarshal APIError body: %v", err)
|
||||
}
|
||||
if len(body.Data) != 1 || body.Data[0].MalID != 999 {
|
||||
t.Fatalf("APIError body = %+v, want decoded error body", body)
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,29 @@ type StudioAnimeResult struct {
|
||||
StudioName string
|
||||
}
|
||||
|
||||
type ProducerResponse struct {
|
||||
Data struct {
|
||||
MalID int `json:"mal_id"`
|
||||
Titles []struct {
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
} `json:"titles"`
|
||||
Images struct {
|
||||
Jpg struct {
|
||||
ImageURL string `json:"image_url"`
|
||||
} `json:"jpg"`
|
||||
} `json:"images"`
|
||||
Favorites int `json:"favorites"`
|
||||
Established string `json:"established"`
|
||||
About string `json:"about"`
|
||||
Count int `json:"count"`
|
||||
External []struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
} `json:"external"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type NamedEntity struct {
|
||||
MalID int `json:"mal_id"`
|
||||
Name string `json:"name"`
|
||||
@@ -162,40 +185,6 @@ type RecommendationsResponse struct {
|
||||
Data []RecommendationEntry `json:"data"`
|
||||
}
|
||||
|
||||
// ScoredByFormatted returns formatted count (e.g. "1 234 567").
|
||||
func (a Anime) ScoredByFormatted() string {
|
||||
return formatNumber(a.ScoredBy)
|
||||
}
|
||||
|
||||
// MembersFormatted returns formatted count (e.g. "1 234 567").
|
||||
func (a Anime) MembersFormatted() string {
|
||||
return formatNumber(a.Members)
|
||||
}
|
||||
|
||||
// FavoritesFormatted returns formatted count (e.g. "1 234 567").
|
||||
func (a Anime) FavoritesFormatted() string {
|
||||
return formatNumber(a.Favorites)
|
||||
}
|
||||
|
||||
// formatNumber adds space separators to a number (1234567 -> "1 234 567").
|
||||
func formatNumber(n int) string {
|
||||
if n == 0 {
|
||||
return ""
|
||||
}
|
||||
s := fmt.Sprintf("%d", n)
|
||||
var res []string
|
||||
for i := len(s); i > 0; i -= 3 {
|
||||
start := max(i-3, 0)
|
||||
res = append([]string{s[start:i]}, res...)
|
||||
}
|
||||
return strings.Join(res, " ")
|
||||
}
|
||||
|
||||
// ImageURL returns the webp large image URL for the anime.
|
||||
func (a Anime) ImageURL() string {
|
||||
return a.Images.Webp.LargeImageURL
|
||||
}
|
||||
|
||||
// ShortRating extracts just the rating code (e.g. "PG-13") from full rating string.
|
||||
func (a Anime) ShortRating() string {
|
||||
if a.Rating == "" {
|
||||
@@ -239,7 +228,7 @@ func (a Anime) DurationSeconds() float64 {
|
||||
var currentValue int
|
||||
hasValue := false
|
||||
|
||||
for _, token := range strings.Fields(strings.ToLower(a.Duration)) {
|
||||
for token := range strings.FieldsSeq(strings.ToLower(a.Duration)) {
|
||||
value, err := strconv.Atoi(token)
|
||||
if err == nil {
|
||||
currentValue = value
|
||||
|
||||
@@ -2,7 +2,7 @@ package allanime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"errors"
|
||||
"mal/internal/domain"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -28,8 +28,8 @@ func (c *AllAnimeProvider) GetEpisodeAvailabilityByProviderID(ctx context.Contex
|
||||
return domain.EpisodeAvailability{}, err
|
||||
}
|
||||
|
||||
sub := parseEpisodeNumbers(append(available.Sub, available.Raw...))
|
||||
dub := parseEpisodeNumbers(available.Dub)
|
||||
sub := episodeNums(append(available.Sub, available.Raw...))
|
||||
dub := episodeNums(available.Dub)
|
||||
return domain.EpisodeAvailability{Sub: sub, Dub: dub}, nil
|
||||
}
|
||||
|
||||
@@ -48,27 +48,28 @@ func (c *AllAnimeProvider) GetAvailableEpisodes(ctx context.Context, showID stri
|
||||
|
||||
data, ok := result["data"].(map[string]any)
|
||||
if !ok {
|
||||
return AvailableEpisodes{}, fmt.Errorf("invalid response")
|
||||
return AvailableEpisodes{}, errors.New("invalid response")
|
||||
}
|
||||
|
||||
show, ok := data["show"].(map[string]any)
|
||||
if !ok || show == nil {
|
||||
return AvailableEpisodes{}, fmt.Errorf("show not found")
|
||||
return AvailableEpisodes{}, errors.New("show not found")
|
||||
}
|
||||
|
||||
detail, ok := show["availableEpisodesDetail"].(map[string]any)
|
||||
if !ok {
|
||||
return AvailableEpisodes{}, fmt.Errorf("invalid detail")
|
||||
return AvailableEpisodes{}, errors.New("invalid detail")
|
||||
}
|
||||
|
||||
return AvailableEpisodes{
|
||||
Sub: stringSliceFromAny(detail["sub"]),
|
||||
Dub: stringSliceFromAny(detail["dub"]),
|
||||
Raw: stringSliceFromAny(detail["raw"]),
|
||||
Sub: stringsFrom(detail["sub"]),
|
||||
Dub: stringsFrom(detail["dub"]),
|
||||
Raw: stringsFrom(detail["raw"]),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseEpisodeNumbers(raw []string) []int {
|
||||
// episode ids
|
||||
func episodeNums(raw []string) []int {
|
||||
seen := make(map[int]bool, len(raw))
|
||||
out := make([]int, 0, len(raw))
|
||||
for _, value := range raw {
|
||||
@@ -82,7 +83,8 @@ func parseEpisodeNumbers(raw []string) []int {
|
||||
return out
|
||||
}
|
||||
|
||||
func stringSliceFromAny(value any) []string {
|
||||
// graphql list
|
||||
func stringsFrom(value any) []string {
|
||||
items, ok := value.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
|
||||
@@ -6,10 +6,10 @@ import (
|
||||
)
|
||||
|
||||
func TestParseEpisodeNumbersKeepsOnlyPositiveIntegers(t *testing.T) {
|
||||
got := parseEpisodeNumbers([]string{"1", " 2 ", "2", "0", "-1", "12.5", "SP1", "6"})
|
||||
got := episodeNums([]string{"1", " 2 ", "2", "0", "-1", "12.5", "SP1", "6"})
|
||||
want := []int{1, 2, 6}
|
||||
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("parseEpisodeNumbers() = %v, want %v", got, want)
|
||||
t.Fatalf("episodeNums() = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"mal/internal/domain"
|
||||
errlog "mal/pkg"
|
||||
netutil "mal/pkg/net"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -45,7 +46,7 @@ func (c *AllAnimeProvider) Name() string {
|
||||
}
|
||||
|
||||
func (c *AllAnimeProvider) GetStreams(ctx context.Context, animeID int, titleCandidates []string, episode string, mode string) (*domain.StreamResult, error) {
|
||||
showID := c.resolveShowIDWithFallback(ctx, animeID, titleCandidates, mode)
|
||||
showID := c.showID(ctx, animeID, titleCandidates, mode)
|
||||
if showID == "" {
|
||||
return nil, fmt.Errorf("allanime: show not found for malID %d", animeID)
|
||||
}
|
||||
@@ -123,7 +124,9 @@ func executeAndReadResponse(client *http.Client, req *http.Request, executeErrPr
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("%s: %w", executeErrPrefix, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
defer func() {
|
||||
errlog.Log("failed to close allanime response body", resp.Body.Close())
|
||||
}()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, netutil.MiB2))
|
||||
if err != nil {
|
||||
|
||||
@@ -4,7 +4,9 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"encoding/json"
|
||||
"crypto/cipher"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"mal/internal/domain"
|
||||
"testing"
|
||||
)
|
||||
@@ -32,6 +34,10 @@ type sourceReferencesTestCase struct {
|
||||
wantRefs []sourceReference
|
||||
}
|
||||
|
||||
var _ interface {
|
||||
GetStreams(context.Context, int, []string, string, string) (*domain.StreamResult, error)
|
||||
} = (*AllAnimeProvider)(nil)
|
||||
|
||||
func runStringTransformTests(t *testing.T, tests []stringTransformTestCase, fn func(string) string) {
|
||||
t.Helper()
|
||||
|
||||
@@ -53,7 +59,7 @@ func runSourceReferenceTests(t *testing.T, tests []sourceReferencesTestCase) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := buildSourceReferences(tt.rawURLs)
|
||||
got := sourceRefs(tt.rawURLs)
|
||||
if len(got) != len(tt.wantRefs) {
|
||||
t.Errorf("got %d refs, want %d", len(got), len(tt.wantRefs))
|
||||
return
|
||||
@@ -71,6 +77,29 @@ func runSourceReferenceTests(t *testing.T, tests []sourceReferencesTestCase) {
|
||||
}
|
||||
}
|
||||
|
||||
func buildEncryptedTobeparsedPayload(t *testing.T, plaintext []byte) string {
|
||||
t.Helper()
|
||||
|
||||
key := sha256.Sum256([]byte(aesKeys[0]))
|
||||
block, err := aes.NewCipher(key[:])
|
||||
if err != nil {
|
||||
t.Fatalf("create cipher: %v", err)
|
||||
}
|
||||
|
||||
iv := []byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b}
|
||||
ctrIV := append([]byte{}, iv...)
|
||||
ctrIV = append(ctrIV, 0x00, 0x00, 0x00, 0x02)
|
||||
|
||||
cipherText := make([]byte, len(plaintext))
|
||||
cipher.NewCTR(block, ctrIV).XORKeyStream(cipherText, plaintext)
|
||||
|
||||
raw := append([]byte{1}, iv...)
|
||||
raw = append(raw, cipherText...)
|
||||
raw = append(raw, make([]byte, 16)...)
|
||||
|
||||
return base64.StdEncoding.EncodeToString(raw)
|
||||
}
|
||||
|
||||
func TestDecodeSourceURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -222,7 +251,7 @@ func TestBuildStreamSource(t *testing.T) {
|
||||
func TestResolveDirectSourceSkipsEmbeds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if _, ok := resolveDirectSource(sourceReference{
|
||||
if _, ok := directSource(sourceReference{
|
||||
URL: "https://ok.ru/videoembed/123",
|
||||
Name: "ok",
|
||||
}); ok {
|
||||
@@ -298,7 +327,7 @@ func TestBuildSourceReferencesOrder(t *testing.T) {
|
||||
map[string]any{"sourceUrl": "https://yt.com/v.mp4", "sourceName": "yt-mp4"},
|
||||
}
|
||||
|
||||
got := buildSourceReferences(rawURLs)
|
||||
got := sourceRefs(rawURLs)
|
||||
|
||||
wantOrder := []string{"default", "yt-mp4", "s-mp4", "luf-mp4"}
|
||||
if len(got) != len(wantOrder) {
|
||||
@@ -419,18 +448,16 @@ func TestDecryptTobeparsed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("valid encrypted payload with first key", func(t *testing.T) {
|
||||
payload := "AQAAAAABc2S7yj94zW6j4A8d9D6C3qFvYjR1hI4L6z1J3qKj5pXhKj"
|
||||
plaintext := []byte(`{"ok":true,"items":[1,2,3]}`)
|
||||
payload := buildEncryptedTobeparsedPayload(t, plaintext)
|
||||
|
||||
decrypted, err := decryptTobeparsed(payload)
|
||||
if err == nil {
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal(decrypted, &result); err != nil {
|
||||
t.Logf("decrypted (not valid json): %s", string(decrypted))
|
||||
} else {
|
||||
t.Logf("decrypted: %+v", result)
|
||||
}
|
||||
} else {
|
||||
t.Logf("expected decryption to succeed or fail gracefully: %v", err)
|
||||
if err != nil {
|
||||
t.Fatalf("decryptTobeparsed: %v", err)
|
||||
}
|
||||
|
||||
if string(decrypted) != string(plaintext) {
|
||||
t.Fatalf("decrypted = %q, want %q", decrypted, plaintext)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -465,21 +492,16 @@ func TestTryDecryptCTR(t *testing.T) {
|
||||
}
|
||||
|
||||
iv := []byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b}
|
||||
cipherText := []byte("test plaintext ")
|
||||
plaintext := []byte("test plaintext ")
|
||||
|
||||
plainText := tryDecryptCTR(block, iv, cipherText)
|
||||
_ = plainText
|
||||
ctrIV := append([]byte{}, iv...)
|
||||
ctrIV = append(ctrIV, 0x00, 0x00, 0x00, 0x02)
|
||||
cipherText := make([]byte, len(plaintext))
|
||||
cipher.NewCTR(block, ctrIV).XORKeyStream(cipherText, plaintext)
|
||||
|
||||
got := tryDecryptCTR(block, iv, cipherText)
|
||||
if !bytes.Equal(got, plaintext) {
|
||||
t.Fatalf("tryDecryptCTR() = %q, want %q", got, plaintext)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAllAnimeClientImplementsInterfaces(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
_ interface {
|
||||
GetStreams(context.Context, int, []string, string, string) (*domain.StreamResult, error)
|
||||
} = &AllAnimeProvider{}
|
||||
)
|
||||
|
||||
t.Log("allAnimeClient implements required interfaces")
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
@@ -21,7 +22,7 @@ func decryptTobeparsed(encoded string) ([]byte, error) {
|
||||
}
|
||||
|
||||
if len(raw) < 29 {
|
||||
return nil, fmt.Errorf("encrypted payload too short")
|
||||
return nil, errors.New("encrypted payload too short")
|
||||
}
|
||||
|
||||
version := raw[0]
|
||||
@@ -54,7 +55,7 @@ func decryptTobeparsed(encoded string) ([]byte, error) {
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("decryption failed")
|
||||
return nil, errors.New("decryption failed")
|
||||
}
|
||||
|
||||
func tryDecryptCTR(block cipher.Block, iv []byte, cipherText []byte) []byte {
|
||||
@@ -119,7 +120,7 @@ func decodeSourceURL(encoded string) string {
|
||||
}
|
||||
|
||||
func responseFromTobeparsed(data map[string]any) (map[string]any, error) {
|
||||
toBeParsed := firstNonEmptyString(
|
||||
toBeParsed := firstString(
|
||||
nestedString(data, "tobeparsed"),
|
||||
nestedString(data, "episode", "tobeparsed"),
|
||||
)
|
||||
@@ -137,7 +138,7 @@ func responseFromTobeparsed(data map[string]any) (map[string]any, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sourceURLs := firstNonEmptySlice(
|
||||
sourceURLs := firstSlice(
|
||||
nestedSlice(parsed, "sourceUrls"),
|
||||
nestedSlice(parsed, "episode", "sourceUrls"),
|
||||
)
|
||||
@@ -152,10 +153,6 @@ func responseFromTobeparsed(data map[string]any) (map[string]any, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func hasEpisodeSourceURLs(data map[string]any) bool {
|
||||
return len(nestedSlice(data, "episode", "sourceUrls")) > 0
|
||||
}
|
||||
|
||||
func parseGraphQLResponse(respBody []byte, decodeErrPrefix string) (map[string]any, error) {
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal(respBody, &parsed); err != nil {
|
||||
@@ -169,7 +166,8 @@ func parseGraphQLResponse(respBody []byte, decodeErrPrefix string) (map[string]a
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func firstNonEmptyString(values ...string) string {
|
||||
// first non-empty
|
||||
func firstString(values ...string) string {
|
||||
for _, value := range values {
|
||||
if value != "" {
|
||||
return value
|
||||
@@ -179,7 +177,8 @@ func firstNonEmptyString(values ...string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstNonEmptySlice(values ...[]any) []any {
|
||||
// first non-empty
|
||||
func firstSlice(values ...[]any) []any {
|
||||
for _, value := range values {
|
||||
if len(value) > 0 {
|
||||
return value
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
errlog "mal/pkg"
|
||||
netutil "mal/pkg/net"
|
||||
"net/http"
|
||||
"regexp"
|
||||
@@ -71,14 +72,16 @@ func (e *providerExtractor) ExtractVideoLinks(ctx context.Context, providerPath
|
||||
}
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
defer func() {
|
||||
errlog.Log("failed to close provider response body", resp.Body.Close())
|
||||
}()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, netutil.MiB2)) // 2MB limit
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read provider response: %w", err)
|
||||
}
|
||||
|
||||
return e.parseProviderResponse(ctx, string(body)), nil
|
||||
return e.parseResponse(ctx, string(body)), nil
|
||||
}
|
||||
|
||||
func (e *providerExtractor) ExtractEmbedVideoLinks(ctx context.Context, rawURL string) ([]StreamSource, error) {
|
||||
@@ -86,40 +89,43 @@ func (e *providerExtractor) ExtractEmbedVideoLinks(ctx context.Context, rawURL s
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch embed response: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
defer func() {
|
||||
errlog.Log("failed to close embed response body", resp.Body.Close())
|
||||
}()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, netutil.MiB2))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read embed response: %w", err)
|
||||
}
|
||||
|
||||
return parseExternalEmbedResponse(rawURL, string(body), e.referer), nil
|
||||
return parseEmbed(rawURL, string(body), e.referer), nil
|
||||
}
|
||||
|
||||
// parseProviderResponse extracts stream sources from provider JSON response.
|
||||
func (e *providerExtractor) parseProviderResponse(ctx context.Context, response string) []StreamSource {
|
||||
// provider response
|
||||
func (e *providerExtractor) parseResponse(ctx context.Context, response string) []StreamSource {
|
||||
var root any
|
||||
if err := json.Unmarshal([]byte(response), &root); err != nil {
|
||||
return []StreamSource{}
|
||||
}
|
||||
|
||||
data := collectProviderResponseData(root, e.referer)
|
||||
sources := buildProviderLinkSources(data.links, data.referer)
|
||||
sources = append(sources, e.buildProviderHLSSources(ctx, data.hls, data.referer)...)
|
||||
data := collectData(root, e.referer)
|
||||
sources := linkSources(data.links, data.referer)
|
||||
sources = append(sources, e.hlsSources(ctx, data.hls, data.referer)...)
|
||||
|
||||
attachSubtitles(sources, data.subtitles)
|
||||
|
||||
return sources
|
||||
}
|
||||
|
||||
func collectProviderResponseData(root any, fallbackReferer string) providerResponseData {
|
||||
// provider payload
|
||||
func collectData(root any, fallbackReferer string) providerResponseData {
|
||||
data := providerResponseData{referer: fallbackReferer}
|
||||
|
||||
var walk func(v any)
|
||||
walk = func(v any) {
|
||||
switch x := v.(type) {
|
||||
case map[string]any:
|
||||
collectProviderMapData(x, &data)
|
||||
collectMapData(x, &data)
|
||||
for _, child := range x {
|
||||
walk(child)
|
||||
}
|
||||
@@ -138,7 +144,7 @@ func collectProviderResponseData(root any, fallbackReferer string) providerRespo
|
||||
return data
|
||||
}
|
||||
|
||||
func collectProviderMapData(node map[string]any, data *providerResponseData) {
|
||||
func collectMapData(node map[string]any, data *providerResponseData) {
|
||||
if ref, ok := node["Referer"].(string); ok {
|
||||
if trimmedRef := strings.TrimSpace(ref); trimmedRef != "" {
|
||||
data.referer = trimmedRef
|
||||
@@ -158,11 +164,11 @@ func collectProviderMapData(node map[string]any, data *providerResponseData) {
|
||||
}
|
||||
|
||||
if subs, ok := node["subtitles"].([]any); ok {
|
||||
data.subtitles = append(data.subtitles, parseProviderSubtitles(subs)...)
|
||||
data.subtitles = append(data.subtitles, parseSubtitles(subs)...)
|
||||
}
|
||||
}
|
||||
|
||||
func parseProviderSubtitles(items []any) []Subtitle {
|
||||
func parseSubtitles(items []any) []Subtitle {
|
||||
subtitles := make([]Subtitle, 0, len(items))
|
||||
for _, item := range items {
|
||||
node, ok := item.(map[string]any)
|
||||
@@ -170,8 +176,14 @@ func parseProviderSubtitles(items []any) []Subtitle {
|
||||
continue
|
||||
}
|
||||
|
||||
lang, _ := node["lang"].(string)
|
||||
src, _ := node["src"].(string)
|
||||
lang, ok := node["lang"].(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
src, ok := node["src"].(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
lang = strings.TrimSpace(lang)
|
||||
src = strings.TrimSpace(src)
|
||||
if lang == "" || src == "" {
|
||||
@@ -184,7 +196,7 @@ func parseProviderSubtitles(items []any) []Subtitle {
|
||||
return subtitles
|
||||
}
|
||||
|
||||
func buildProviderLinkSources(items []providerLinkItem, referer string) []StreamSource {
|
||||
func linkSources(items []providerLinkItem, referer string) []StreamSource {
|
||||
sources := make([]StreamSource, 0, len(items))
|
||||
for _, item := range items {
|
||||
link := strings.TrimSpace(item.link)
|
||||
@@ -196,7 +208,7 @@ func buildProviderLinkSources(items []providerLinkItem, referer string) []Stream
|
||||
URL: link,
|
||||
Quality: strings.TrimSpace(item.resolutionStr),
|
||||
Provider: "wixmp",
|
||||
Type: detectProviderSourceType(link),
|
||||
Type: sourceType(link),
|
||||
Referer: referer,
|
||||
})
|
||||
}
|
||||
@@ -204,19 +216,19 @@ func buildProviderLinkSources(items []providerLinkItem, referer string) []Stream
|
||||
return sources
|
||||
}
|
||||
|
||||
func detectProviderSourceType(link string) string {
|
||||
sourceType := detectStreamType(link)
|
||||
if sourceType != "unknown" {
|
||||
return sourceType
|
||||
func sourceType(link string) string {
|
||||
typ := detectStreamType(link)
|
||||
if typ != "unknown" {
|
||||
return typ
|
||||
}
|
||||
|
||||
return detectEmbedType(link)
|
||||
}
|
||||
|
||||
func (e *providerExtractor) buildProviderHLSSources(ctx context.Context, items []providerHLSItem, referer string) []StreamSource {
|
||||
func (e *providerExtractor) hlsSources(ctx context.Context, items []providerHLSItem, referer string) []StreamSource {
|
||||
sources := make([]StreamSource, 0, len(items))
|
||||
for _, item := range items {
|
||||
playlistURL, ok := providerPlaylistURL(item)
|
||||
playlistURL, ok := playlistURL(item)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
@@ -241,7 +253,7 @@ func (e *providerExtractor) buildProviderHLSSources(ctx context.Context, items [
|
||||
return sources
|
||||
}
|
||||
|
||||
func providerPlaylistURL(item providerHLSItem) (string, bool) {
|
||||
func playlistURL(item providerHLSItem) (string, bool) {
|
||||
playlistURL := strings.TrimSpace(item.url)
|
||||
if playlistURL == "" || item.hardsubLang != "en-US" {
|
||||
return "", false
|
||||
@@ -266,7 +278,9 @@ func (e *providerExtractor) parseM3U8(ctx context.Context, masterURL string, ref
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
defer func() {
|
||||
errlog.Log("failed to close m3u8 response body", resp.Body.Close())
|
||||
}()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, netutil.KiB512)) // 512KB limit
|
||||
if err != nil {
|
||||
@@ -280,22 +294,27 @@ func parseM3U8Sources(body string, masterURL string, referer string) []StreamSou
|
||||
lines := strings.Split(body, "\n")
|
||||
baseURL := playlistBaseURL(masterURL)
|
||||
bwPattern := regexp.MustCompile(`BANDWIDTH=(\d+)`)
|
||||
currentBandwidth := 0
|
||||
bw := 0
|
||||
sources := make([]StreamSource, 0)
|
||||
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if bandwidth, ok := parseStreamBandwidth(trimmed, bwPattern); ok {
|
||||
currentBandwidth = bandwidth
|
||||
if bandwidth, ok := streamBandwidth(trimmed, bwPattern); ok {
|
||||
bw = bandwidth
|
||||
continue
|
||||
}
|
||||
if shouldSkipM3U8Line(trimmed) {
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
streamURL := trimmed
|
||||
if !strings.HasPrefix(streamURL, "http://") && !strings.HasPrefix(streamURL, "https://") {
|
||||
streamURL = baseURL + streamURL
|
||||
}
|
||||
|
||||
sources = append(sources, StreamSource{
|
||||
URL: resolvePlaylistURL(trimmed, baseURL),
|
||||
Quality: qualityFromBandwidth(currentBandwidth),
|
||||
URL: streamURL,
|
||||
Quality: quality(bw),
|
||||
Provider: "hls",
|
||||
Type: "m3u8",
|
||||
Referer: referer,
|
||||
@@ -313,7 +332,7 @@ func playlistBaseURL(masterURL string) string {
|
||||
return masterURL
|
||||
}
|
||||
|
||||
func parseStreamBandwidth(line string, bwPattern *regexp.Regexp) (int, bool) {
|
||||
func streamBandwidth(line string, bwPattern *regexp.Regexp) (int, bool) {
|
||||
if !strings.HasPrefix(line, "#EXT-X-STREAM-INF") {
|
||||
return 0, false
|
||||
}
|
||||
@@ -331,19 +350,7 @@ func parseStreamBandwidth(line string, bwPattern *regexp.Regexp) (int, bool) {
|
||||
return value, true
|
||||
}
|
||||
|
||||
func shouldSkipM3U8Line(line string) bool {
|
||||
return line == "" || strings.HasPrefix(line, "#")
|
||||
}
|
||||
|
||||
func resolvePlaylistURL(streamURL string, baseURL string) string {
|
||||
if strings.HasPrefix(streamURL, "http://") || strings.HasPrefix(streamURL, "https://") {
|
||||
return streamURL
|
||||
}
|
||||
|
||||
return baseURL + streamURL
|
||||
}
|
||||
|
||||
func qualityFromBandwidth(bandwidth int) string {
|
||||
func quality(bandwidth int) string {
|
||||
kbps := bandwidth / 1000
|
||||
|
||||
switch {
|
||||
@@ -360,12 +367,13 @@ func qualityFromBandwidth(bandwidth int) string {
|
||||
}
|
||||
}
|
||||
|
||||
func parseExternalEmbedResponse(rawURL string, body string, fallbackReferer string) []StreamSource {
|
||||
// embed page
|
||||
func parseEmbed(rawURL string, body string, fallbackReferer string) []StreamSource {
|
||||
switch {
|
||||
case strings.Contains(strings.ToLower(rawURL), "ok.ru/"):
|
||||
return parseOKRUSources(body, fallbackReferer)
|
||||
case strings.Contains(strings.ToLower(rawURL), "mp4upload.com/"):
|
||||
return parseMP4UploadSources(body, fallbackReferer)
|
||||
return parseMP4Upload(body, fallbackReferer)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
@@ -379,7 +387,7 @@ func parseOKRUSources(body string, referer string) []StreamSource {
|
||||
return nil
|
||||
}
|
||||
|
||||
playlistURL := decodeEscapedMediaURL(firstNonEmptyString(match[1], match[2]))
|
||||
playlistURL := mediaURL(firstString(match[1], match[2]))
|
||||
if playlistURL == "" {
|
||||
return nil
|
||||
}
|
||||
@@ -393,27 +401,27 @@ func parseOKRUSources(body string, referer string) []StreamSource {
|
||||
}}
|
||||
}
|
||||
|
||||
func parseMP4UploadSources(body string, referer string) []StreamSource {
|
||||
func parseMP4Upload(body string, referer string) []StreamSource {
|
||||
srcPattern := regexp.MustCompile(`(?m)src:\s*"([^"]+)"`)
|
||||
match := srcPattern.FindStringSubmatch(body)
|
||||
if len(match) < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
mediaURL := decodeEscapedMediaURL(match[1])
|
||||
if mediaURL == "" {
|
||||
url := mediaURL(match[1])
|
||||
if url == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return []StreamSource{{
|
||||
URL: mediaURL,
|
||||
URL: url,
|
||||
Provider: "mp4upload",
|
||||
Type: detectProviderSourceType(mediaURL),
|
||||
Type: sourceType(url),
|
||||
Referer: referer,
|
||||
}}
|
||||
}
|
||||
|
||||
func decodeEscapedMediaURL(raw string) string {
|
||||
func mediaURL(raw string) string {
|
||||
if unquoted, err := strconv.Unquote(`"` + raw + `"`); err == nil {
|
||||
raw = unquoted
|
||||
}
|
||||
|
||||
799
integrations/playback/allanime/http_test.go
Normal file
799
integrations/playback/allanime/http_test.go
Normal file
@@ -0,0 +1,799 @@
|
||||
package allanime
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGraphqlRequest_SuccessAndHeaders(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var method, url, ct, referer, ua string
|
||||
var bodyBuf bytes.Buffer
|
||||
|
||||
provider := &AllAnimeProvider{
|
||||
httpClient: &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
method = req.Method
|
||||
url = req.URL.String()
|
||||
ct = req.Header.Get("Content-Type")
|
||||
referer = req.Header.Get("Referer")
|
||||
ua = req.Header.Get("User-Agent")
|
||||
if _, err := io.Copy(&bodyBuf, req.Body); err != nil {
|
||||
t.Fatalf("copy request body: %v", err)
|
||||
}
|
||||
return mockStringResponse(http.StatusOK, `{"data":{"key":"val"}}`), nil
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
_, err := provider.graphqlRequest(
|
||||
context.Background(),
|
||||
"query($id:String!){show(_id:$id){name}}",
|
||||
map[string]any{"id": "abc"},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("graphqlRequest() error = %v", err)
|
||||
}
|
||||
|
||||
verifyGraphqlRequest(t, method, url, ct, referer, ua, bodyBuf.Bytes())
|
||||
}
|
||||
|
||||
func TestGraphqlRequest_Errors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
status int
|
||||
body string
|
||||
}{
|
||||
{
|
||||
name: "graphql error in response",
|
||||
status: http.StatusOK,
|
||||
body: `{"errors":[{"message":"not found"}]}`,
|
||||
},
|
||||
{
|
||||
name: "non-200 status",
|
||||
status: http.StatusInternalServerError,
|
||||
body: `{"data":{}}`,
|
||||
},
|
||||
{
|
||||
name: "invalid json body",
|
||||
status: http.StatusOK,
|
||||
body: `not json`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
provider := &AllAnimeProvider{
|
||||
httpClient: &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return mockStringResponse(tt.status, tt.body), nil
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
_, err := provider.graphqlRequest(
|
||||
context.Background(),
|
||||
"query($id:String!){show(_id:$id){name}}",
|
||||
map[string]any{"id": "abc"},
|
||||
)
|
||||
if err == nil {
|
||||
t.Error("expected error, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func verifyGraphqlRequest(t *testing.T, method, url, ct, referer, ua string, body []byte) {
|
||||
t.Helper()
|
||||
|
||||
if method != http.MethodPost {
|
||||
t.Errorf("method = %q, want POST", method)
|
||||
}
|
||||
if url != allAnimeBaseURL+"/api" {
|
||||
t.Errorf("url = %q, want %q", url, allAnimeBaseURL+"/api")
|
||||
}
|
||||
if ct != "application/json" {
|
||||
t.Errorf("Content-Type = %q", ct)
|
||||
}
|
||||
if referer != allAnimeReferer {
|
||||
t.Errorf("Referer = %q", referer)
|
||||
}
|
||||
if ua != defaultUserAgent {
|
||||
t.Errorf("User-Agent = %q", ua)
|
||||
}
|
||||
|
||||
var sent map[string]any
|
||||
if err := json.Unmarshal(body, &sent); err != nil {
|
||||
t.Fatalf("unmarshal sent body: %v", err)
|
||||
}
|
||||
if sent["query"] != "query($id:String!){show(_id:$id){name}}" {
|
||||
t.Errorf("unexpected query in body")
|
||||
}
|
||||
vars, ok := sent["variables"].(map[string]any)
|
||||
if !ok || vars["id"] != "abc" {
|
||||
t.Errorf("unexpected variables in body")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphqlRequest_SetsTranslationTypeLower(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
provider := &AllAnimeProvider{
|
||||
httpClient: &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return mockStringResponse(http.StatusOK, `{"data":{}}`), nil
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
_, err := provider.graphqlRequest(
|
||||
context.Background(),
|
||||
"query($t:VaildTranslationTypeEnumType!){x(translationType:$t){id}}",
|
||||
map[string]any{"translationType": "SUB"},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("graphqlRequest: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphqlRequestWithHash_Plain(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
provider := &AllAnimeProvider{
|
||||
utlsClient: &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if req.Method != http.MethodGet {
|
||||
t.Errorf("method = %q, want GET", req.Method)
|
||||
}
|
||||
if !strings.Contains(req.URL.String(), episodeQueryHash) {
|
||||
t.Errorf("url should contain hash, got %q", req.URL.String())
|
||||
}
|
||||
if req.Header.Get("Referer") != allAnimeReferer {
|
||||
t.Errorf("Referer = %q", req.Header.Get("Referer"))
|
||||
}
|
||||
return mockStringResponse(http.StatusOK, `{"data":{"episode":{"sourceUrls":[{"sourceUrl":"https://example.test/v.mp4","sourceName":"default"}]}}}`), nil
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
result, err := provider.graphqlRequestWithHash(
|
||||
context.Background(),
|
||||
"show123", "1", "sub",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("graphqlRequestWithHash: %v", err)
|
||||
}
|
||||
|
||||
data, ok := result["data"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("result missing data key")
|
||||
}
|
||||
sources := nestedSlice(data, "episode", "sourceUrls")
|
||||
if len(sources) != 1 {
|
||||
t.Fatalf("got %d sources, want 1", len(sources))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphqlRequestWithHash_Encrypted(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
encryptedPayload := buildEncryptedTobeparsedPayload(t, []byte(`{"sourceUrls":[{"sourceUrl":"https://e.test/v.mp4","sourceName":"default"}]}`))
|
||||
|
||||
provider := &AllAnimeProvider{
|
||||
utlsClient: &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return mockStringResponse(http.StatusOK, `{"data":{"tobeparsed":"`+encryptedPayload+`"}}`), nil
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
result, err := provider.graphqlRequestWithHash(
|
||||
context.Background(),
|
||||
"show456", "2", "dub",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("graphqlRequestWithHash: %v", err)
|
||||
}
|
||||
|
||||
sources := nestedSlice(result, "episode", "sourceUrls")
|
||||
if len(sources) != 1 {
|
||||
t.Fatalf("got %d sources, want 1", len(sources))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphqlRequestWithHash_Non200(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
provider := &AllAnimeProvider{
|
||||
utlsClient: &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return mockStringResponse(http.StatusNotFound, `not found`), nil
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
_, err := provider.graphqlRequestWithHash(
|
||||
context.Background(),
|
||||
"x", "1", "sub",
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-200")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphqlRequestWithHash_EmptyData(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
provider := &AllAnimeProvider{
|
||||
utlsClient: &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return mockStringResponse(http.StatusOK, `{"data":{}}`), nil
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
_, err := provider.graphqlRequestWithHash(
|
||||
context.Background(),
|
||||
"x", "1", "sub",
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEpisodeSources_EncryptedHash(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
encrypted := buildEncryptedTobeparsedPayload(t, []byte(`{"sourceUrls":[{"sourceUrl":"https://direct.test/v.mp4","sourceName":"default"}]}`))
|
||||
|
||||
provider := &AllAnimeProvider{
|
||||
httpClient: &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
t.Error("fallback POST should not be called")
|
||||
return nil, nil
|
||||
}),
|
||||
},
|
||||
utlsClient: &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return mockStringResponse(http.StatusOK, `{"data":{"tobeparsed":"`+encrypted+`"}}`), nil
|
||||
}),
|
||||
},
|
||||
extractor: newProviderExtractor(),
|
||||
}
|
||||
|
||||
sources, err := provider.GetEpisodeSources(context.Background(), "show1", "1", "sub")
|
||||
if err != nil {
|
||||
t.Fatalf("GetEpisodeSources: %v", err)
|
||||
}
|
||||
if len(sources) == 0 {
|
||||
t.Fatal("expected at least one source")
|
||||
}
|
||||
if sources[0].URL != "https://direct.test/v.mp4" {
|
||||
t.Errorf("URL = %q", sources[0].URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEpisodeSources_FallbackPost(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sourceResponse := `{"data":{"episode":{"sourceUrls":[{"sourceUrl":"https://direct.test/v.mp4","sourceName":"default"}]}}}`
|
||||
fallbackCalled := false
|
||||
|
||||
provider := &AllAnimeProvider{
|
||||
httpClient: &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
fallbackCalled = true
|
||||
return mockStringResponse(http.StatusOK, sourceResponse), nil
|
||||
}),
|
||||
},
|
||||
utlsClient: &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return mockStringResponse(http.StatusNotFound, `not found`), nil
|
||||
}),
|
||||
},
|
||||
extractor: newProviderExtractor(),
|
||||
}
|
||||
|
||||
sources, err := provider.GetEpisodeSources(context.Background(), "show3", "3", "sub")
|
||||
if err != nil {
|
||||
t.Fatalf("GetEpisodeSources: %v", err)
|
||||
}
|
||||
if !fallbackCalled {
|
||||
t.Fatal("fallback POST was not called")
|
||||
}
|
||||
if len(sources) == 0 {
|
||||
t.Fatal("expected at least one source")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEpisodeSources_BothFail(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
provider := &AllAnimeProvider{
|
||||
httpClient: &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return mockStringResponse(http.StatusNotFound, `not found`), nil
|
||||
}),
|
||||
},
|
||||
utlsClient: &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return mockStringResponse(http.StatusNotFound, `not found`), nil
|
||||
}),
|
||||
},
|
||||
extractor: newProviderExtractor(),
|
||||
}
|
||||
|
||||
_, err := provider.GetEpisodeSources(context.Background(), "show4", "4", "sub")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when both requests fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAvailableEpisodes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
wantSub int
|
||||
wantDub int
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "sub and dub available",
|
||||
body: `{"data":{"show":{"availableEpisodesDetail":{"sub":["1","2","3"],"dub":["1"]},"lastEpisodeInfo":{}}}}`,
|
||||
wantSub: 3,
|
||||
wantDub: 1,
|
||||
},
|
||||
{
|
||||
name: "sub only",
|
||||
body: `{"data":{"show":{"availableEpisodesDetail":{"sub":["1","2"],"dub":null},"lastEpisodeInfo":{}}}}`,
|
||||
wantSub: 2,
|
||||
wantDub: 0,
|
||||
},
|
||||
{
|
||||
name: "show not found",
|
||||
body: `{"data":{"show":null}}`,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
provider := &AllAnimeProvider{
|
||||
httpClient: &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return mockStringResponse(http.StatusOK, tt.body), nil
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
available, err := provider.GetAvailableEpisodes(context.Background(), "showX")
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("GetAvailableEpisodes() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
if tt.wantErr {
|
||||
return
|
||||
}
|
||||
|
||||
if len(available.Sub) != tt.wantSub {
|
||||
t.Errorf("Sub count = %d, want %d", len(available.Sub), tt.wantSub)
|
||||
}
|
||||
if len(available.Dub) != tt.wantDub {
|
||||
t.Errorf("Dub count = %d, want %d", len(available.Dub), tt.wantDub)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("returns results", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
provider := &AllAnimeProvider{
|
||||
httpClient: &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return mockStringResponse(http.StatusOK, `{"data":{"shows":{"edges":[{"_id":"id1","malId":"1","name":"Title One"},{"_id":"id2","malId":"2","name":"Title Two"}]}}}`), nil
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
results, err := provider.Search(context.Background(), "test", "sub")
|
||||
if err != nil {
|
||||
t.Fatalf("Search: %v", err)
|
||||
}
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("len = %d, want 2", len(results))
|
||||
}
|
||||
if results[0].ID != "id1" || results[0].MalID != "1" || results[0].Name != "Title One" {
|
||||
t.Errorf("result[0] = %+v", results[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty results", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
provider := &AllAnimeProvider{
|
||||
httpClient: &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return mockStringResponse(http.StatusOK, `{"data":{"shows":{"edges":[]}}}`), nil
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
results, err := provider.Search(context.Background(), "nonexistent", "sub")
|
||||
if err != nil {
|
||||
t.Fatalf("Search: %v", err)
|
||||
}
|
||||
if len(results) != 0 {
|
||||
t.Errorf("len = %d, want 0", len(results))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetStreams_FullSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
searchBody := `{"data":{"shows":{"edges":[{"_id":"show123","malId":"1","name":"Test Anime"}]}}}`
|
||||
encrypted := buildEncryptedTobeparsedPayload(t, []byte(`{"sourceUrls":[{"sourceUrl":"https://stream.test/video.mp4","sourceName":"default"}]}`))
|
||||
|
||||
provider := &AllAnimeProvider{
|
||||
httpClient: &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return mockStringResponse(http.StatusOK, searchBody), nil
|
||||
}),
|
||||
},
|
||||
utlsClient: &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return mockStringResponse(http.StatusOK, `{"data":{"tobeparsed":"`+encrypted+`"}}`), nil
|
||||
}),
|
||||
},
|
||||
extractor: newProviderExtractor(),
|
||||
}
|
||||
|
||||
result, err := provider.GetStreams(context.Background(), 1, []string{"Test Anime"}, "1", "sub")
|
||||
if err != nil {
|
||||
t.Fatalf("GetStreams: %v", err)
|
||||
}
|
||||
if result.URL != "https://stream.test/video.mp4" {
|
||||
t.Errorf("URL = %q", result.URL)
|
||||
}
|
||||
if result.Referer != allAnimeReferer {
|
||||
t.Errorf("Referer = %q", result.Referer)
|
||||
}
|
||||
if result.Type != "mp4" {
|
||||
t.Errorf("Type = %q", result.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetStreams_ShowNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
provider := &AllAnimeProvider{
|
||||
httpClient: &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return mockStringResponse(http.StatusOK, `{"data":{"shows":{"edges":[]}}}`), nil
|
||||
}),
|
||||
},
|
||||
utlsClient: &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
t.Error("should not call episode sources when show not found")
|
||||
return nil, nil
|
||||
}),
|
||||
},
|
||||
extractor: newProviderExtractor(),
|
||||
}
|
||||
|
||||
_, err := provider.GetStreams(context.Background(), 999, []string{"Nothing"}, "1", "sub")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for show not found")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetStreams_NoSources(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
provider := &AllAnimeProvider{
|
||||
httpClient: &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return mockStringResponse(http.StatusOK, `{"data":{"shows":{"edges":[{"_id":"showX","malId":"1","name":"Anime"}]}}}`), nil
|
||||
}),
|
||||
},
|
||||
utlsClient: &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return mockStringResponse(http.StatusNotFound, `not found`), nil
|
||||
}),
|
||||
},
|
||||
extractor: newProviderExtractor(),
|
||||
}
|
||||
|
||||
_, err := provider.GetStreams(context.Background(), 1, []string{"Anime"}, "1", "sub")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when no sources")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseProviderResponse(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("extracts links and subtitles", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := `{"links":[{"link":"https://cdn.test/video.mp4","resolutionStr":"1080p"}],"subtitles":[{"lang":"en","src":"https://sub.test/en.vtt"}]}`
|
||||
extractor := &providerExtractor{
|
||||
baseURL: allAnimeSiteURL,
|
||||
referer: allAnimeReferer,
|
||||
}
|
||||
|
||||
sources := extractor.parseResponse(context.Background(), body)
|
||||
if len(sources) == 0 {
|
||||
t.Fatal("expected at least one source")
|
||||
}
|
||||
|
||||
if sources[0].URL != "https://cdn.test/video.mp4" {
|
||||
t.Errorf("URL = %q", sources[0].URL)
|
||||
}
|
||||
if sources[0].Quality != "1080p" {
|
||||
t.Errorf("Quality = %q", sources[0].Quality)
|
||||
}
|
||||
if len(sources[0].Subtitles) != 1 {
|
||||
t.Fatalf("subtitles count = %d, want 1", len(sources[0].Subtitles))
|
||||
}
|
||||
if sources[0].Subtitles[0].Lang != "en" {
|
||||
t.Errorf("sub lang = %q", sources[0].Subtitles[0].Lang)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid json returns empty", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
extractor := &providerExtractor{
|
||||
baseURL: allAnimeSiteURL,
|
||||
referer: allAnimeReferer,
|
||||
}
|
||||
|
||||
sources := extractor.parseResponse(context.Background(), "not json")
|
||||
if len(sources) != 0 {
|
||||
t.Errorf("expected empty, got %d sources", len(sources))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty response returns empty", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
extractor := &providerExtractor{
|
||||
baseURL: allAnimeSiteURL,
|
||||
referer: allAnimeReferer,
|
||||
}
|
||||
|
||||
sources := extractor.parseResponse(context.Background(), "{}")
|
||||
if len(sources) != 0 {
|
||||
t.Errorf("expected empty, got %d sources", len(sources))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseExternalEmbedResponse(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("ok.ru extracts hls manifest", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := `{"flashvars":{"metadata":"{\"hlsManifestUrl\":\"https://ok.example.test/playlist.m3u8\"}"}}`
|
||||
sources := parseEmbed("https://ok.ru/video/123", body, allAnimeReferer)
|
||||
if len(sources) != 1 {
|
||||
t.Fatalf("got %d sources, want 1", len(sources))
|
||||
}
|
||||
if sources[0].URL != "https://ok.example.test/playlist.m3u8" {
|
||||
t.Errorf("URL = %q", sources[0].URL)
|
||||
}
|
||||
if sources[0].Provider != "ok" {
|
||||
t.Errorf("Provider = %q", sources[0].Provider)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("mp4upload extracts src", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := `src: "https://mp4upload.example.test/video.mp4"`
|
||||
sources := parseEmbed("https://mp4upload.com/e/abc", body, allAnimeReferer)
|
||||
if len(sources) != 1 {
|
||||
t.Fatalf("got %d sources, want 1", len(sources))
|
||||
}
|
||||
if sources[0].URL != "https://mp4upload.example.test/video.mp4" {
|
||||
t.Errorf("URL = %q", sources[0].URL)
|
||||
}
|
||||
if sources[0].Provider != "mp4upload" {
|
||||
t.Errorf("Provider = %q", sources[0].Provider)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown embed returns empty", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sources := parseEmbed("https://unknown.example.com/video", "<html></html>", allAnimeReferer)
|
||||
if len(sources) != 0 {
|
||||
t.Errorf("expected empty, got %d sources", len(sources))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseM3U8Sources(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("parses bandwidth entries", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=8000000,RESOLUTION=1920x1080\n1080p.m3u8\n#EXT-X-STREAM-INF:BANDWIDTH=5000000\n720p.m3u8\n#EXT-X-STREAM-INF:BANDWIDTH=2500000\n480p.m3u8"
|
||||
masterURL := "https://cdn.test/master.m3u8"
|
||||
|
||||
sources := parseM3U8Sources(body, masterURL, allAnimeReferer)
|
||||
if len(sources) != 3 {
|
||||
t.Fatalf("got %d sources, want 3", len(sources))
|
||||
}
|
||||
|
||||
expected := []struct {
|
||||
url string
|
||||
quality string
|
||||
}{
|
||||
{"https://cdn.test/1080p.m3u8", "1080p"},
|
||||
{"https://cdn.test/720p.m3u8", "720p"},
|
||||
{"https://cdn.test/480p.m3u8", "480p"},
|
||||
}
|
||||
for i, exp := range expected {
|
||||
if sources[i].URL != exp.url {
|
||||
t.Errorf("sources[%d].URL = %q, want %q", i, sources[i].URL, exp.url)
|
||||
}
|
||||
if sources[i].Quality != exp.quality {
|
||||
t.Errorf("sources[%d].Quality = %q, want %q", i, sources[i].Quality, exp.quality)
|
||||
}
|
||||
if sources[i].Type != "m3u8" {
|
||||
t.Errorf("sources[%d].Type = %q", i, sources[i].Type)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty body returns nothing", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sources := parseM3U8Sources("", "https://cdn.test/master.m3u8", allAnimeReferer)
|
||||
if len(sources) != 0 {
|
||||
t.Errorf("expected empty, got %d", len(sources))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("absolute URLs not rebased", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := "#EXT-X-STREAM-INF:BANDWIDTH=8000000\nhttps://cdn2.test/video.m3u8"
|
||||
sources := parseM3U8Sources(body, "https://cdn.test/master.m3u8", allAnimeReferer)
|
||||
if len(sources) != 1 {
|
||||
t.Fatalf("got %d sources", len(sources))
|
||||
}
|
||||
if sources[0].URL != "https://cdn2.test/video.m3u8" {
|
||||
t.Errorf("URL = %q", sources[0].URL)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestExtractVideoLinks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("fetches and parses provider response", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
extractor := &providerExtractor{
|
||||
httpClient: &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if req.Method != http.MethodGet {
|
||||
t.Errorf("method = %q, want GET", req.Method)
|
||||
}
|
||||
if req.Header.Get("Referer") != allAnimeReferer {
|
||||
t.Errorf("Referer = %q", req.Header.Get("Referer"))
|
||||
}
|
||||
body := `{"links":[{"link":"https://cdn.test/video.mp4","resolutionStr":"720p"}]}`
|
||||
return mockStringResponse(http.StatusOK, body), nil
|
||||
}),
|
||||
},
|
||||
baseURL: allAnimeSiteURL,
|
||||
referer: allAnimeReferer,
|
||||
}
|
||||
|
||||
sources, err := extractor.ExtractVideoLinks(context.Background(), "/some-path")
|
||||
if err != nil {
|
||||
t.Fatalf("ExtractVideoLinks: %v", err)
|
||||
}
|
||||
if len(sources) != 1 {
|
||||
t.Fatalf("got %d sources, want 1", len(sources))
|
||||
}
|
||||
if sources[0].Provider != "wixmp" {
|
||||
t.Errorf("Provider = %q", sources[0].Provider)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("server error returns empty sources", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
extractor := &providerExtractor{
|
||||
httpClient: &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return mockStringResponse(http.StatusInternalServerError, ""), nil
|
||||
}),
|
||||
},
|
||||
baseURL: allAnimeSiteURL,
|
||||
referer: allAnimeReferer,
|
||||
}
|
||||
|
||||
sources, err := extractor.ExtractVideoLinks(context.Background(), "/error-path")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if len(sources) != 0 {
|
||||
t.Errorf("expected empty sources, got %d", len(sources))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestExtractEmbedVideoLinks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("ok.ru embed extracted", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
extractor := &providerExtractor{
|
||||
httpClient: &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
body := `{"flashvars":{"metadata":"{\"hlsManifestUrl\":\"https://ok.test/play.m3u8\"}"}}`
|
||||
return mockStringResponse(http.StatusOK, body), nil
|
||||
}),
|
||||
},
|
||||
referer: allAnimeReferer,
|
||||
}
|
||||
|
||||
sources, err := extractor.ExtractEmbedVideoLinks(context.Background(), "https://ok.ru/video/123")
|
||||
if err != nil {
|
||||
t.Fatalf("ExtractEmbedVideoLinks: %v", err)
|
||||
}
|
||||
if len(sources) != 1 {
|
||||
t.Fatalf("got %d sources, want 1", len(sources))
|
||||
}
|
||||
if sources[0].URL != "https://ok.test/play.m3u8" {
|
||||
t.Errorf("URL = %q", sources[0].URL)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown embed returns empty", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
extractor := &providerExtractor{
|
||||
httpClient: &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return mockStringResponse(http.StatusOK, "<html></html>"), nil
|
||||
}),
|
||||
},
|
||||
referer: allAnimeReferer,
|
||||
}
|
||||
|
||||
sources, err := extractor.ExtractEmbedVideoLinks(context.Background(), "https://unknown.com/video")
|
||||
if err != nil {
|
||||
t.Fatalf("ExtractEmbedVideoLinks: %v", err)
|
||||
}
|
||||
if len(sources) != 0 {
|
||||
t.Errorf("expected empty, got %d sources", len(sources))
|
||||
}
|
||||
})
|
||||
}
|
||||
23
integrations/playback/allanime/mock_test.go
Normal file
23
integrations/playback/allanime/mock_test.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package allanime
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
func mockStringResponse(status int, body string) *http.Response {
|
||||
hdr := make(http.Header)
|
||||
hdr.Set("Content-Type", "application/json")
|
||||
return &http.Response{
|
||||
StatusCode: status,
|
||||
Header: hdr,
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
}
|
||||
}
|
||||
@@ -99,9 +99,9 @@ func (c *AllAnimeProvider) Search(ctx context.Context, query string, mode string
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *AllAnimeProvider) resolveShowIDWithFallback(ctx context.Context, animeID int, titleCandidates []string, mode string) string {
|
||||
func (c *AllAnimeProvider) showID(ctx context.Context, animeID int, titleCandidates []string, mode string) string {
|
||||
targetMalIDStr := strconv.Itoa(animeID)
|
||||
firstAvailableShowID := ""
|
||||
fallbackID := ""
|
||||
|
||||
for _, title := range titleCandidates {
|
||||
searchResults, err := c.Search(ctx, title, mode)
|
||||
@@ -111,12 +111,12 @@ func (c *AllAnimeProvider) resolveShowIDWithFallback(ctx context.Context, animeI
|
||||
if showID := exactMatchShowID(searchResults, targetMalIDStr); showID != "" {
|
||||
return showID
|
||||
}
|
||||
if firstAvailableShowID == "" {
|
||||
firstAvailableShowID = searchResults[0].ID
|
||||
if fallbackID == "" {
|
||||
fallbackID = searchResults[0].ID
|
||||
}
|
||||
}
|
||||
|
||||
return firstAvailableShowID
|
||||
return fallbackID
|
||||
}
|
||||
|
||||
func exactMatchShowID(searchResults []searchResult, targetMalID string) string {
|
||||
@@ -131,7 +131,7 @@ func exactMatchShowID(searchResults []searchResult, targetMalID string) string {
|
||||
|
||||
func (c *AllAnimeProvider) ResolveEpisodeProviderID(ctx context.Context, animeID int, titleCandidates []string) (string, error) {
|
||||
for _, mode := range []string{"sub", "dub"} {
|
||||
showID, err := c.resolveShowIDStrict(ctx, animeID, titleCandidates, mode)
|
||||
showID, err := c.strictShowID(ctx, animeID, titleCandidates, mode)
|
||||
if err == nil {
|
||||
return showID, nil
|
||||
}
|
||||
@@ -139,7 +139,7 @@ func (c *AllAnimeProvider) ResolveEpisodeProviderID(ctx context.Context, animeID
|
||||
return "", fmt.Errorf("allanime: no exact mal id match for %d", animeID)
|
||||
}
|
||||
|
||||
func (c *AllAnimeProvider) resolveShowIDStrict(ctx context.Context, animeID int, titleCandidates []string, mode string) (string, error) {
|
||||
func (c *AllAnimeProvider) strictShowID(ctx context.Context, animeID int, titleCandidates []string, mode string) (string, error) {
|
||||
targetMalIDStr := strconv.Itoa(animeID)
|
||||
for _, title := range titleCandidates {
|
||||
searchResults, err := c.Search(ctx, title, mode)
|
||||
|
||||
@@ -2,6 +2,7 @@ package allanime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -24,7 +25,7 @@ func (c *AllAnimeProvider) GetEpisodeSources(ctx context.Context, showID string,
|
||||
|
||||
result, err := c.graphqlRequestWithHash(ctx, showID, episode, mode)
|
||||
if err == nil {
|
||||
sources := c.extractSourceURLsFromData(ctx, result)
|
||||
sources := c.sourcesFrom(ctx, result)
|
||||
if len(sources) > 0 {
|
||||
return sources, nil
|
||||
}
|
||||
@@ -41,34 +42,34 @@ func (c *AllAnimeProvider) GetEpisodeSources(ctx context.Context, showID string,
|
||||
|
||||
data, ok := result["data"].(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid source response")
|
||||
return nil, errors.New("invalid source response")
|
||||
}
|
||||
|
||||
rawSourceURLs, ok := data["episode"].(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid episode response")
|
||||
return nil, errors.New("invalid episode response")
|
||||
}
|
||||
|
||||
sourceURLs, ok := rawSourceURLs["sourceUrls"].([]any)
|
||||
if !ok || len(sourceURLs) == 0 {
|
||||
return nil, fmt.Errorf("no source urls")
|
||||
return nil, errors.New("no source urls")
|
||||
}
|
||||
|
||||
references := buildSourceReferences(sourceURLs)
|
||||
references := sourceRefs(sourceURLs)
|
||||
if len(references) == 0 {
|
||||
return nil, fmt.Errorf("no source references")
|
||||
return nil, errors.New("no source references")
|
||||
}
|
||||
|
||||
out := c.resolveSourceReferences(ctx, references)
|
||||
out := c.resolveRefs(ctx, references)
|
||||
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("no playable sources extracted")
|
||||
return nil, errors.New("no playable sources extracted")
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *AllAnimeProvider) extractSourceURLsFromData(ctx context.Context, data map[string]any) []StreamSource {
|
||||
func (c *AllAnimeProvider) sourcesFrom(ctx context.Context, data map[string]any) []StreamSource {
|
||||
episodeData, ok := data["episode"].(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
@@ -79,23 +80,23 @@ func (c *AllAnimeProvider) extractSourceURLsFromData(ctx context.Context, data m
|
||||
return nil
|
||||
}
|
||||
|
||||
references := buildSourceReferences(sourceURLs)
|
||||
references := sourceRefs(sourceURLs)
|
||||
if len(references) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return c.resolveSourceReferences(ctx, references)
|
||||
return c.resolveRefs(ctx, references)
|
||||
}
|
||||
|
||||
func (c *AllAnimeProvider) resolveSourceReferences(ctx context.Context, references []sourceReference) []StreamSource {
|
||||
func (c *AllAnimeProvider) resolveRefs(ctx context.Context, references []sourceReference) []StreamSource {
|
||||
out := make([]StreamSource, 0, len(references))
|
||||
for _, ref := range references {
|
||||
if source, ok := resolveDirectSource(ref); ok {
|
||||
if source, ok := directSource(ref); ok {
|
||||
out = append(out, source)
|
||||
return out
|
||||
}
|
||||
|
||||
extracted := c.resolveExtractedSources(ctx, ref)
|
||||
extracted := c.resolveExtracted(ctx, ref)
|
||||
if len(extracted) > 0 {
|
||||
out = append(out, extracted...)
|
||||
return out
|
||||
@@ -105,7 +106,7 @@ func (c *AllAnimeProvider) resolveSourceReferences(ctx context.Context, referenc
|
||||
return out
|
||||
}
|
||||
|
||||
func resolveDirectSource(ref sourceReference) (StreamSource, bool) {
|
||||
func directSource(ref sourceReference) (StreamSource, bool) {
|
||||
target := strings.TrimSpace(ref.URL)
|
||||
if target == "" {
|
||||
return StreamSource{}, false
|
||||
@@ -130,7 +131,7 @@ func resolveDirectSource(ref sourceReference) (StreamSource, bool) {
|
||||
return buildStreamSource(decoded, detectSourceType(decoded), ref.Name), true
|
||||
}
|
||||
|
||||
func (c *AllAnimeProvider) resolveExtractedSources(ctx context.Context, ref sourceReference) []StreamSource {
|
||||
func (c *AllAnimeProvider) resolveExtracted(ctx context.Context, ref sourceReference) []StreamSource {
|
||||
rawURL := strings.TrimSpace(ref.URL)
|
||||
decoded := decodeSourceURL(rawURL)
|
||||
if decoded == "" {
|
||||
@@ -179,7 +180,8 @@ func buildStreamSource(url, sourceType, provider string) StreamSource {
|
||||
}
|
||||
}
|
||||
|
||||
func buildSourceReferences(rawSourceURLs []any) []sourceReference {
|
||||
// source priority
|
||||
func sourceRefs(rawSourceURLs []any) []sourceReference {
|
||||
priorityOrder := []string{"default", "yt-mp4", "s-mp4", "luf-mp4"}
|
||||
prioritySet := map[string]struct{}{"default": {}, "yt-mp4": {}, "s-mp4": {}, "luf-mp4": {}}
|
||||
|
||||
@@ -193,8 +195,11 @@ func buildSourceReferences(rawSourceURLs []any) []sourceReference {
|
||||
continue
|
||||
}
|
||||
|
||||
sourceURL, _ := item["sourceUrl"].(string)
|
||||
sourceName, _ := item["sourceName"].(string)
|
||||
sourceURL, ok := stringMapValue(item, "sourceUrl")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
sourceName, _ := stringMapValue(item, "sourceName")
|
||||
sourceURL = strings.TrimSpace(sourceURL)
|
||||
sourceName = strings.TrimSpace(sourceName)
|
||||
if sourceURL == "" {
|
||||
@@ -208,7 +213,7 @@ func buildSourceReferences(rawSourceURLs []any) []sourceReference {
|
||||
|
||||
ref := sourceReference{URL: sourceURL, Name: sourceName}
|
||||
normalized := strings.ToLower(sourceName)
|
||||
if _, prioritizedProvider := prioritySet[normalized]; prioritizedProvider {
|
||||
if _, priority := prioritySet[normalized]; priority {
|
||||
if _, exists := prioritized[normalized]; !exists {
|
||||
prioritized[normalized] = ref
|
||||
}
|
||||
@@ -229,8 +234,13 @@ func buildSourceReferences(rawSourceURLs []any) []sourceReference {
|
||||
return ordered
|
||||
}
|
||||
|
||||
func stringMapValue(item map[string]any, key string) (string, bool) {
|
||||
value, ok := item[key].(string)
|
||||
return value, ok
|
||||
}
|
||||
|
||||
func (c *AllAnimeProvider) graphqlRequestWithHash(ctx context.Context, showID, episode, mode string) (map[string]any, error) {
|
||||
req, err := newEpisodeHashRequest(ctx, showID, episode, mode)
|
||||
req, err := newHashRequest(ctx, showID, episode, mode)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create GET request: %w", err)
|
||||
}
|
||||
@@ -261,7 +271,7 @@ func (c *AllAnimeProvider) graphqlRequestWithHash(ctx context.Context, showID, e
|
||||
|
||||
data, ok := parsed["data"].(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no data in response")
|
||||
return nil, errors.New("no data in response")
|
||||
}
|
||||
|
||||
decrypted, err := responseFromTobeparsed(data)
|
||||
@@ -272,14 +282,14 @@ func (c *AllAnimeProvider) graphqlRequestWithHash(ctx context.Context, showID, e
|
||||
return decrypted, nil
|
||||
}
|
||||
|
||||
if hasEpisodeSourceURLs(data) {
|
||||
if len(nestedSlice(data, "episode", "sourceUrls")) > 0 {
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no usable data in response")
|
||||
return nil, errors.New("no usable data in response")
|
||||
}
|
||||
|
||||
func newEpisodeHashRequest(ctx context.Context, showID, episode, mode string) (*http.Request, error) {
|
||||
func newHashRequest(ctx context.Context, showID, episode, mode string) (*http.Request, error) {
|
||||
varsJSON := fmt.Sprintf(`{"showId":"%s","translationType":"%s","episodeString":"%s"}`, showID, strings.ToLower(mode), episode)
|
||||
extJSON := fmt.Sprintf(`{"persistedQuery":{"version":1,"sha256Hash":"%s"}}`, episodeQueryHash)
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
errlog "mal/pkg"
|
||||
netutil "mal/pkg/net"
|
||||
"net/http"
|
||||
"regexp"
|
||||
@@ -155,23 +156,19 @@ func extractRows(doc *goquery.Document) []watchOrderRow {
|
||||
}
|
||||
|
||||
title := strings.TrimSpace(selection.Find(".wo_title").First().Text())
|
||||
alternativeTitle := strings.TrimSpace(selection.Find(".uk-text-small").First().Text())
|
||||
alt := strings.TrimSpace(selection.Find(".uk-text-small").First().Text())
|
||||
|
||||
rows = append(rows, watchOrderRow{
|
||||
id: id,
|
||||
typeID: typeID,
|
||||
title: title,
|
||||
alternativeTitle: alternativeTitle,
|
||||
alternativeTitle: alt,
|
||||
})
|
||||
})
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
func hasWatchOrderTable(doc *goquery.Document) bool {
|
||||
return doc.Find("#wo_list").Length() > 0
|
||||
}
|
||||
|
||||
// shouldTryProxy returns true for transient errors where the Jina proxy may help
|
||||
// (e.g. Cloudflare blocking, rate limits)
|
||||
func shouldTryProxy(err error) bool {
|
||||
@@ -205,7 +202,9 @@ func fetchProxyText(ctx context.Context, httpClient *http.Client, url string) (s
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("proxy request failed: %w", err)
|
||||
}
|
||||
defer func() { _ = response.Body.Close() }()
|
||||
defer func() {
|
||||
errlog.Log("failed to close watch order proxy response body", response.Body.Close())
|
||||
}()
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("proxy status %d", response.StatusCode)
|
||||
@@ -355,7 +354,7 @@ func FetchWatchOrder(ctx context.Context, httpClient *http.Client, url string) (
|
||||
}
|
||||
|
||||
// empty table indicates JS-rendered content; need proxy
|
||||
if !hasWatchOrderTable(doc) {
|
||||
if doc.Find("#wo_list").Length() == 0 {
|
||||
return fetchViaProxy(ctx, httpClient, url, rootID)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"mal/internal/observability"
|
||||
"mal/internal/server"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -35,17 +36,19 @@ type browseQuery struct {
|
||||
func producerQueryParams(c *gin.Context) (string, int, int, error) {
|
||||
q := strings.TrimSpace(c.Query("q"))
|
||||
|
||||
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
rawPage := c.DefaultQuery("page", "1")
|
||||
page, err := strconv.Atoi(rawPage)
|
||||
if err != nil {
|
||||
return "", 0, 0, fmt.Errorf("invalid page")
|
||||
return "", 0, 0, fmt.Errorf("invalid page %q: %w", rawPage, err)
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
limit, err := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||
rawLimit := c.DefaultQuery("limit", "50")
|
||||
limit, err := strconv.Atoi(rawLimit)
|
||||
if err != nil {
|
||||
return "", 0, 0, fmt.Errorf("invalid limit")
|
||||
return "", 0, 0, fmt.Errorf("invalid limit %q: %w", rawLimit, err)
|
||||
}
|
||||
if limit < 1 || limit > 12 {
|
||||
limit = 12
|
||||
@@ -137,8 +140,11 @@ func parseBrowseQuery(c *gin.Context) (browseQuery, error) {
|
||||
studioID := 0
|
||||
if raw := strings.TrimSpace(c.Query("studio")); raw != "" {
|
||||
id, err := strconv.Atoi(raw)
|
||||
if err != nil || id < 0 {
|
||||
return browseQuery{}, fmt.Errorf("invalid studio id")
|
||||
if err != nil {
|
||||
return browseQuery{}, fmt.Errorf("invalid studio id %q: %w", raw, err)
|
||||
}
|
||||
if id < 0 {
|
||||
return browseQuery{}, fmt.Errorf("invalid studio id %d", id)
|
||||
}
|
||||
studioID = id
|
||||
}
|
||||
@@ -147,16 +153,17 @@ func parseBrowseQuery(c *gin.Context) (browseQuery, error) {
|
||||
for _, g := range c.QueryArray("genres") {
|
||||
id, err := strconv.Atoi(g)
|
||||
if err != nil {
|
||||
return browseQuery{}, fmt.Errorf("invalid genre id")
|
||||
return browseQuery{}, fmt.Errorf("invalid genre id %q: %w", g, err)
|
||||
}
|
||||
if id > 0 {
|
||||
genres = append(genres, id)
|
||||
}
|
||||
}
|
||||
|
||||
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
rawPage := c.DefaultQuery("page", "1")
|
||||
page, err := strconv.Atoi(rawPage)
|
||||
if err != nil {
|
||||
return browseQuery{}, fmt.Errorf("invalid page")
|
||||
return browseQuery{}, fmt.Errorf("invalid page %q: %w", rawPage, err)
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
@@ -175,6 +182,25 @@ func parseBrowseQuery(c *gin.Context) (browseQuery, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func canonicalBrowseURL(rawURL *url.URL) (string, bool) {
|
||||
if rawURL == nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
query := rawURL.Query()
|
||||
if _, exists := query["sfw"]; exists {
|
||||
return "", false
|
||||
}
|
||||
|
||||
query.Set("sfw", "true")
|
||||
encoded := query.Encode()
|
||||
if encoded == "" {
|
||||
return rawURL.Path, true
|
||||
}
|
||||
|
||||
return rawURL.Path + "?" + encoded, true
|
||||
}
|
||||
|
||||
func browseStudioName(ctx context.Context, svc Service, studioID int) string {
|
||||
if studioID <= 0 {
|
||||
return ""
|
||||
@@ -280,6 +306,11 @@ func (h *AnimeHandler) respondBrowseSearchError(c *gin.Context, query browseQuer
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleBrowse(c *gin.Context) {
|
||||
if target, ok := canonicalBrowseURL(c.Request.URL); ok {
|
||||
c.Redirect(http.StatusSeeOther, target)
|
||||
return
|
||||
}
|
||||
|
||||
query, err := parseBrowseQuery(c)
|
||||
if err != nil {
|
||||
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, err.Error())
|
||||
@@ -303,7 +334,16 @@ func (h *AnimeHandler) HandleBrowse(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
genresList, _ := h.svc.GetGenres(c.Request.Context())
|
||||
genresList, err := h.svc.GetGenres(c.Request.Context())
|
||||
if err != nil {
|
||||
observability.WarnContext(c.Request.Context(),
|
||||
"genres_fetch_failed",
|
||||
"anime",
|
||||
"",
|
||||
map[string]any{"q": query.q, "type": query.animeType, "status": query.status},
|
||||
err,
|
||||
)
|
||||
}
|
||||
browseData := browseTemplateData(query, studioName, genresList, animes, user, watchlistMap, res.HasNextPage)
|
||||
|
||||
if c.GetHeader("HX-Request") == "true" {
|
||||
@@ -348,7 +388,7 @@ func (h *AnimeHandler) HandleQuickSearch(c *gin.Context) {
|
||||
Title: anime.DisplayTitle(),
|
||||
Type: anime.Type,
|
||||
Year: anime.Year,
|
||||
Image: anime.ImageURL(),
|
||||
Image: anime.Images.Webp.LargeImageURL,
|
||||
InWatchlist: watchlistMap[int64(anime.MalID)],
|
||||
}
|
||||
}
|
||||
|
||||
39
internal/anime/browse_handler_test.go
Normal file
39
internal/anime/browse_handler_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package anime
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCanonicalBrowseURLAddsSFWTrueWhenMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rawURL, err := url.Parse("/browse?status=airing&order_by=popularity&sort=asc")
|
||||
if err != nil {
|
||||
t.Fatalf("url.Parse() error = %v", err)
|
||||
}
|
||||
|
||||
got, ok := canonicalBrowseURL(rawURL)
|
||||
if !ok {
|
||||
t.Fatal("canonicalBrowseURL() should request redirect when sfw is missing")
|
||||
}
|
||||
|
||||
want := "/browse?order_by=popularity&sfw=true&sort=asc&status=airing"
|
||||
if got != want {
|
||||
t.Fatalf("canonicalBrowseURL() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalBrowseURLSkipsWhenSFWAlreadyPresent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rawURL, err := url.Parse("/browse?status=airing&sfw=false")
|
||||
if err != nil {
|
||||
t.Fatalf("url.Parse() error = %v", err)
|
||||
}
|
||||
|
||||
got, ok := canonicalBrowseURL(rawURL)
|
||||
if ok {
|
||||
t.Fatalf("canonicalBrowseURL() unexpectedly requested redirect to %q", got)
|
||||
}
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
package anime
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"mal/internal/db"
|
||||
"mal/internal/domain"
|
||||
"mal/internal/server"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const commandPaletteAnimeLimit = 24
|
||||
|
||||
type commandPaletteItem struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Label string `json:"label"`
|
||||
Subtitle string `json:"subtitle"`
|
||||
Href string `json:"href"`
|
||||
Image string `json:"image,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
}
|
||||
|
||||
type commandPaletteResponse struct {
|
||||
Items []commandPaletteItem `json:"items"`
|
||||
HasNextPage bool `json:"hasNextPage"`
|
||||
NextPage int `json:"nextPage,omitempty"`
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleCommandPalette(c *gin.Context) {
|
||||
user := server.CurrentUser(c)
|
||||
if user == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
query := strings.TrimSpace(c.Query("q"))
|
||||
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if err != nil || page < 1 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid page"})
|
||||
return
|
||||
}
|
||||
|
||||
items := make([]commandPaletteItem, 0, commandPaletteAnimeLimit)
|
||||
|
||||
if query != "" {
|
||||
hasNextPage := false
|
||||
if len(query) >= 2 {
|
||||
var animeItems []commandPaletteItem
|
||||
animeItems, hasNextPage = h.commandPaletteAnimeResults(c, query, page)
|
||||
items = append(items, animeItems...)
|
||||
}
|
||||
|
||||
if page == 1 {
|
||||
items = append(items, h.commandPaletteNavigationItems(query)...)
|
||||
items = append(items, h.commandPaletteContinueItems(c, user.ID, query)...)
|
||||
items = append(items, h.commandPalettePersonalItems(c, user.ID, query)...)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, commandPaletteResponse{
|
||||
Items: items,
|
||||
HasNextPage: hasNextPage,
|
||||
NextPage: page + 1,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
items = append(items, h.commandPaletteContinueItems(c, user.ID, query)...)
|
||||
items = append(items, h.commandPaletteNavigationItems(query)...)
|
||||
items = append(items, h.commandPalettePersonalItems(c, user.ID, query)...)
|
||||
c.JSON(http.StatusOK, commandPaletteResponse{Items: items})
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) commandPaletteNavigationItems(query string) []commandPaletteItem {
|
||||
all := []commandPaletteItem{
|
||||
{ID: "nav:home", Type: "navigation", Label: "Go to Home", Subtitle: "Navigation", Href: "/", Icon: "home"},
|
||||
{ID: "nav:watchlist", Type: "navigation", Label: "Go to Watchlist", Subtitle: "Navigation", Href: "/watchlist", Icon: "bookmark"},
|
||||
{ID: "nav:top-picks", Type: "navigation", Label: "Open Top Picks", Subtitle: "Navigation", Href: "/top-picks", Icon: "sparkles"},
|
||||
{ID: "nav:popular", Type: "navigation", Label: "Browse popular", Subtitle: "Browse", Href: "/browse?order_by=popularity&sort=asc", Icon: "trending"},
|
||||
{ID: "nav:airing", Type: "navigation", Label: "Currently airing", Subtitle: "Browse", Href: "/browse?status=airing&order_by=popularity&sort=asc", Icon: "play"},
|
||||
}
|
||||
if query == "" {
|
||||
return all
|
||||
}
|
||||
|
||||
filtered := make([]commandPaletteItem, 0, len(all))
|
||||
for _, item := range all {
|
||||
if commandPaletteMatches(query, item.Label, item.Subtitle) {
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) commandPaletteAnimeResults(c *gin.Context, query string, page int) ([]commandPaletteItem, bool) {
|
||||
res, err := h.svc.SearchAdvanced(c.Request.Context(), query, "", "", "", "", nil, 0, true, page, commandPaletteAnimeLimit)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
animes := wrapAnimes(res.Animes)
|
||||
items := make([]commandPaletteItem, 0, len(animes))
|
||||
for _, anime := range animes {
|
||||
items = append(items, commandPaletteItem{
|
||||
ID: fmt.Sprintf("anime:%d", anime.MalID),
|
||||
Type: "anime",
|
||||
Label: anime.DisplayTitle(),
|
||||
Subtitle: strings.TrimSpace("Anime " + anime.Type),
|
||||
Href: fmt.Sprintf("/anime/%d", anime.MalID),
|
||||
Image: anime.ImageURL(),
|
||||
})
|
||||
}
|
||||
return items, res.HasNextPage
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) commandPalettePersonalItems(c *gin.Context, userID string, query string) []commandPaletteItem {
|
||||
items := make([]commandPaletteItem, 0, 5)
|
||||
|
||||
watchlist, err := h.watchlistSvc.GetCommandPaletteWatchlist(c.Request.Context(), userID, query, 5)
|
||||
if err != nil {
|
||||
return items
|
||||
}
|
||||
|
||||
for _, entry := range watchlist {
|
||||
title := watchlistTitle(entry)
|
||||
items = append(items, commandPaletteItem{
|
||||
ID: fmt.Sprintf("watchlist:%d", entry.AnimeID),
|
||||
Type: "watchlist",
|
||||
Label: title,
|
||||
Subtitle: watchlistStatusLabel(entry.Status),
|
||||
Href: fmt.Sprintf("/anime/%d", entry.AnimeID),
|
||||
Image: entry.ImageUrl,
|
||||
})
|
||||
if len(items) >= 5 {
|
||||
return items
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) commandPaletteContinueItems(c *gin.Context, userID string, query string) []commandPaletteItem {
|
||||
items := make([]commandPaletteItem, 0, 5)
|
||||
|
||||
rows, err := h.watchlistSvc.GetCommandPaletteContinueWatching(c.Request.Context(), userID, query, 5)
|
||||
if err != nil {
|
||||
return items
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
title := continueWatchingTitle(row)
|
||||
episode := ""
|
||||
href := fmt.Sprintf("/anime/%d/watch", row.AnimeID)
|
||||
if row.CurrentEpisode.Valid {
|
||||
episode = fmt.Sprintf(" episode %d", row.CurrentEpisode.Int64)
|
||||
href = fmt.Sprintf("%s?ep=%d", href, row.CurrentEpisode.Int64)
|
||||
}
|
||||
items = append(items, commandPaletteItem{
|
||||
ID: fmt.Sprintf("continue:%d", row.AnimeID),
|
||||
Type: "continue",
|
||||
Label: "Continue watching " + title,
|
||||
Subtitle: "Resume" + episode,
|
||||
Href: href,
|
||||
Image: row.ImageUrl,
|
||||
})
|
||||
if len(items) >= 5 {
|
||||
return items
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
func commandPaletteMatches(query string, values ...string) bool {
|
||||
needle := strings.ToLower(strings.TrimSpace(query))
|
||||
for _, value := range values {
|
||||
if strings.Contains(strings.ToLower(value), needle) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func continueWatchingTitle(row db.GetContinueWatchingEntriesRow) string {
|
||||
return row.DisplayTitle()
|
||||
}
|
||||
|
||||
func watchlistTitle(row domain.UserWatchListRow) string {
|
||||
return row.DisplayTitle()
|
||||
}
|
||||
|
||||
func watchlistStatusLabel(status string) string {
|
||||
switch status {
|
||||
case "watching":
|
||||
return "Watching"
|
||||
case "plan_to_watch":
|
||||
return "Plan to Watch"
|
||||
default:
|
||||
return "Watchlist"
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package anime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"mal/integrations/jikan"
|
||||
"mal/internal/domain"
|
||||
@@ -18,8 +19,106 @@ const (
|
||||
animeSectionTimeout = 12 * time.Second
|
||||
watchOrderTimeout = 15 * time.Second
|
||||
audioLookupTimeout = 8 * time.Second
|
||||
episodeCountTimeout = 4 * time.Second
|
||||
)
|
||||
|
||||
type animeEpisodeCountDisplay struct {
|
||||
Count int
|
||||
Label string
|
||||
}
|
||||
|
||||
func listedEpisodeCount(episodes []domain.EpisodeData) int {
|
||||
count := 0
|
||||
for _, episode := range episodes {
|
||||
if episode.MalID <= 0 || episode.IsRecap {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func releasedEpisodeCount(anime domain.Anime, now time.Time) int {
|
||||
if !anime.Airing || anime.Aired.From == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
firstAired, err := time.Parse(time.RFC3339, anime.Aired.From)
|
||||
if err != nil || now.Before(firstAired) {
|
||||
return 0
|
||||
}
|
||||
|
||||
count := int(now.Sub(firstAired)/(7*24*time.Hour)) + 1
|
||||
if anime.Episodes > 0 && count > anime.Episodes {
|
||||
return anime.Episodes
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) animeEpisodeCount(ctx context.Context, anime domain.Anime, now time.Time) animeEpisodeCountDisplay {
|
||||
if h.episodeSvc != nil {
|
||||
episodeCtx, cancel := context.WithTimeout(ctx, episodeCountTimeout)
|
||||
defer cancel()
|
||||
|
||||
episodeList, err := h.episodeSvc.GetCanonicalEpisodes(episodeCtx, anime, false)
|
||||
if err == nil {
|
||||
if count := len(episodeList.Episodes); count > 0 {
|
||||
return animeEpisodeCountDisplay{Count: count, Label: "Available episodes"}
|
||||
}
|
||||
} else {
|
||||
observability.Warn(
|
||||
"anime_episode_availability_count_fetch_failed",
|
||||
"anime",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": anime.MalID,
|
||||
},
|
||||
err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if h.svc != nil && anime.Airing {
|
||||
episodeCtx, cancel := context.WithTimeout(ctx, episodeCountTimeout)
|
||||
defer cancel()
|
||||
|
||||
episodes, err := h.svc.GetAllEpisodes(episodeCtx, anime.MalID)
|
||||
if err == nil {
|
||||
if count := listedEpisodeCount(episodes); count > 0 {
|
||||
return animeEpisodeCountDisplay{Count: count, Label: "Listed episodes"}
|
||||
}
|
||||
} else {
|
||||
observability.Warn(
|
||||
"anime_episode_count_fetch_failed",
|
||||
"anime",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": anime.MalID,
|
||||
},
|
||||
err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if anime.Episodes > 0 {
|
||||
return animeEpisodeCountDisplay{Count: anime.Episodes, Label: "Total episodes"}
|
||||
}
|
||||
if count := releasedEpisodeCount(anime, now); count > 0 {
|
||||
return animeEpisodeCountDisplay{Count: count, Label: "Estimated aired episodes"}
|
||||
}
|
||||
return animeEpisodeCountDisplay{}
|
||||
}
|
||||
|
||||
func animeInitialEpisodeCount(anime domain.Anime, now time.Time) animeEpisodeCountDisplay {
|
||||
if anime.Episodes > 0 {
|
||||
return animeEpisodeCountDisplay{Count: anime.Episodes, Label: "Total episodes"}
|
||||
}
|
||||
if count := releasedEpisodeCount(anime, now); count > 0 {
|
||||
return animeEpisodeCountDisplay{Count: count, Label: "Estimated aired episodes"}
|
||||
}
|
||||
return animeEpisodeCountDisplay{}
|
||||
}
|
||||
|
||||
func animeAudioAvailabilityLabel(episodes []domain.CanonicalEpisode) string {
|
||||
hasKnownSub := false
|
||||
for _, episode := range episodes {
|
||||
@@ -104,17 +203,18 @@ func (h *AnimeHandler) HandleAnimeDetails(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
audioAvailability := h.animeAudioAvailability(c.Request.Context(), anime)
|
||||
episodesCount := animeInitialEpisodeCount(anime, time.Now())
|
||||
|
||||
c.HTML(http.StatusOK, "anime.gohtml", gin.H{
|
||||
"Anime": anime,
|
||||
"AudioAvailability": audioAvailability,
|
||||
"CurrentPath": fmt.Sprintf("/anime/%d", id),
|
||||
"User": user,
|
||||
"Status": status,
|
||||
"WatchlistIDs": watchlistIDs,
|
||||
"ContinueWatchingEp": ep,
|
||||
"ContinueWatchingTime": cwSeconds,
|
||||
"EpisodesCount": episodesCount.Count,
|
||||
"EpisodesCountLabel": episodesCount.Label,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -124,6 +224,9 @@ func (h *AnimeHandler) handleAnimeDetailsSection(c *gin.Context, id int, section
|
||||
|
||||
data, tplName, err := h.loadAnimeDetailsSection(sectionCtx, id, section)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return
|
||||
}
|
||||
observability.Warn(
|
||||
"anime_section_fetch_failed",
|
||||
"anime",
|
||||
@@ -162,6 +265,18 @@ func (h *AnimeHandler) loadAnimeDetailsSection(ctx context.Context, id int, sect
|
||||
case "statistics":
|
||||
data, err := h.svc.GetStatistics(ctx, id)
|
||||
return data, "anime_statistics", err
|
||||
case "episode-count":
|
||||
anime, err := h.svc.GetAnimeByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return h.animeEpisodeCount(ctx, anime, time.Now()), "anime_episode_count", nil
|
||||
case "audio-availability":
|
||||
anime, err := h.svc.GetAnimeByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return h.animeAudioAvailability(ctx, anime), "anime_audio_availability", nil
|
||||
case "themes":
|
||||
data, err := h.svc.GetThemes(ctx, id)
|
||||
return data, "anime_themes", err
|
||||
@@ -202,13 +317,13 @@ func (h *AnimeHandler) HandleHTMLWatchOrder(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
relationAnimeIDs := make([]int64, 0, len(relations))
|
||||
ids := make([]int64, 0, len(relations))
|
||||
for _, relation := range relations {
|
||||
if relation.Anime.MalID > 0 {
|
||||
relationAnimeIDs = append(relationAnimeIDs, int64(relation.Anime.MalID))
|
||||
ids = append(ids, int64(relation.Anime.MalID))
|
||||
}
|
||||
}
|
||||
watchlistMap := h.watchlistMapForIDs(c.Request.Context(), userID, relationAnimeIDs)
|
||||
watchlistMap := h.watchlistMapForIDs(c.Request.Context(), userID, ids)
|
||||
|
||||
c.HTML(http.StatusOK, "anime.gohtml", gin.H{
|
||||
"_fragment": "watch_order",
|
||||
|
||||
@@ -67,7 +67,7 @@ func (h *AnimeHandler) Register(r *gin.Engine) {
|
||||
r.GET("/anime/:id/reviews", h.HandleAnimeReviews)
|
||||
r.GET("/api/watch-order", h.HandleHTMLWatchOrder)
|
||||
r.GET("/api/search-quick", h.HandleQuickSearch)
|
||||
r.GET("/api/command-palette", h.HandleCommandPalette)
|
||||
r.GET("/api/search", h.HandleSearchAPI)
|
||||
r.GET("/api/jikan/random/anime", h.HandleRandomAnime)
|
||||
r.GET("/api/jikan/producers", h.HandleProducers)
|
||||
}
|
||||
|
||||
@@ -6,16 +6,19 @@ import (
|
||||
"mal/integrations/jikan"
|
||||
"mal/internal/domain"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type stubEpisodeService struct {
|
||||
episodes domain.CanonicalEpisodeList
|
||||
err error
|
||||
forced bool
|
||||
episodes domain.CanonicalEpisodeList
|
||||
err error
|
||||
called int
|
||||
forceRefresh bool
|
||||
}
|
||||
|
||||
func (s *stubEpisodeService) GetCanonicalEpisodes(ctx context.Context, anime domain.Anime, forceRefresh bool) (domain.CanonicalEpisodeList, error) {
|
||||
s.forced = forceRefresh
|
||||
s.called++
|
||||
s.forceRefresh = forceRefresh
|
||||
if s.err != nil {
|
||||
return domain.CanonicalEpisodeList{}, s.err
|
||||
}
|
||||
@@ -26,6 +29,174 @@ func (s *stubEpisodeService) RefreshTrackedDue(ctx context.Context, limit int) e
|
||||
return nil
|
||||
}
|
||||
|
||||
type releasedCountTest struct {
|
||||
name string
|
||||
anime domain.Anime
|
||||
now time.Time
|
||||
want int
|
||||
}
|
||||
|
||||
var releasedCountTests = []releasedCountTest{
|
||||
{
|
||||
name: "weekly airing count",
|
||||
anime: domain.Anime{Anime: jikan.Anime{
|
||||
Airing: true,
|
||||
Episodes: 24,
|
||||
Aired: jikan.Aired{From: "2026-04-04T15:00:00+00:00"},
|
||||
}},
|
||||
now: time.Date(2026, time.June, 13, 15, 0, 0, 0, time.UTC),
|
||||
want: 11,
|
||||
},
|
||||
{
|
||||
name: "before first release",
|
||||
anime: domain.Anime{Anime: jikan.Anime{
|
||||
Airing: true,
|
||||
Aired: jikan.Aired{From: "2026-04-04T15:00:00+00:00"},
|
||||
}},
|
||||
now: time.Date(2026, time.April, 4, 14, 59, 0, 0, time.UTC),
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "first release counts as one",
|
||||
anime: domain.Anime{Anime: jikan.Anime{
|
||||
Airing: true,
|
||||
Aired: jikan.Aired{From: "2026-04-04T15:00:00+00:00"},
|
||||
}},
|
||||
now: time.Date(2026, time.April, 4, 15, 0, 0, 0, time.UTC),
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
name: "caps at total episode count",
|
||||
anime: domain.Anime{Anime: jikan.Anime{
|
||||
Airing: true,
|
||||
Episodes: 12,
|
||||
Aired: jikan.Aired{From: "2026-04-04T15:00:00+00:00"},
|
||||
}},
|
||||
now: time.Date(2026, time.December, 1, 15, 0, 0, 0, time.UTC),
|
||||
want: 12,
|
||||
},
|
||||
{
|
||||
name: "unknown total still estimates current count",
|
||||
anime: domain.Anime{Anime: jikan.Anime{
|
||||
Airing: true,
|
||||
Aired: jikan.Aired{From: "2026-04-04T15:00:00+00:00"},
|
||||
}},
|
||||
now: time.Date(2026, time.April, 18, 15, 0, 0, 0, time.UTC),
|
||||
want: 3,
|
||||
},
|
||||
{
|
||||
name: "non airing anime is not estimated",
|
||||
anime: domain.Anime{Anime: jikan.Anime{
|
||||
Airing: false,
|
||||
Aired: jikan.Aired{From: "2026-04-04T15:00:00+00:00"},
|
||||
}},
|
||||
now: time.Date(2026, time.April, 18, 15, 0, 0, 0, time.UTC),
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "invalid aired date is ignored",
|
||||
anime: domain.Anime{Anime: jikan.Anime{
|
||||
Airing: true,
|
||||
Aired: jikan.Aired{From: "not-a-date"},
|
||||
}},
|
||||
now: time.Date(2026, time.April, 18, 15, 0, 0, 0, time.UTC),
|
||||
want: 0,
|
||||
},
|
||||
}
|
||||
|
||||
func TestReleasedEpisodeCount(t *testing.T) {
|
||||
for _, tt := range releasedCountTests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := releasedEpisodeCount(tt.anime, tt.now)
|
||||
if got != tt.want {
|
||||
t.Fatalf("releasedEpisodeCount() = %d, want %d", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListedEpisodeCount(t *testing.T) {
|
||||
episodes := []domain.EpisodeData{
|
||||
{MalID: 1, Title: "Episode 1"},
|
||||
{MalID: 2, Title: "Episode 2"},
|
||||
{MalID: 3, Title: "Recap", IsRecap: true},
|
||||
{Title: "missing id"},
|
||||
}
|
||||
|
||||
got := listedEpisodeCount(episodes)
|
||||
if got != 2 {
|
||||
t.Fatalf("listedEpisodeCount() = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnimeEpisodeCountUsesCanonicalEpisodes(t *testing.T) {
|
||||
episodeSvc := &stubEpisodeService{
|
||||
episodes: domain.CanonicalEpisodeList{
|
||||
Source: "AllAnime",
|
||||
Episodes: []domain.CanonicalEpisode{
|
||||
{Number: 1},
|
||||
{Number: 2},
|
||||
{Number: 3},
|
||||
},
|
||||
},
|
||||
}
|
||||
handler := NewAnimeHandler(nil, nil, episodeSvc)
|
||||
|
||||
got := handler.animeEpisodeCount(context.Background(), domain.Anime{Anime: jikan.Anime{
|
||||
MalID: 59970,
|
||||
Airing: true,
|
||||
Episodes: 12,
|
||||
Aired: jikan.Aired{From: "2026-04-03T00:00:00+00:00"},
|
||||
}}, time.Date(2026, time.June, 21, 0, 0, 0, 0, time.UTC))
|
||||
|
||||
if got.Count != 3 || got.Label != "Available episodes" {
|
||||
t.Fatalf("animeEpisodeCount() = %+v, want count=3 label=%q", got, "Available episodes")
|
||||
}
|
||||
if episodeSvc.called != 1 {
|
||||
t.Fatalf("GetCanonicalEpisodes() calls = %d, want 1", episodeSvc.called)
|
||||
}
|
||||
if episodeSvc.forceRefresh {
|
||||
t.Fatal("animeEpisodeCount() should use fresh cache when available")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnimeEpisodeCountFallsBackToMetadata(t *testing.T) {
|
||||
episodeSvc := &stubEpisodeService{err: errors.New("provider unavailable")}
|
||||
handler := NewAnimeHandler(nil, nil, episodeSvc)
|
||||
|
||||
got := handler.animeEpisodeCount(context.Background(), domain.Anime{Anime: jikan.Anime{
|
||||
MalID: 59970,
|
||||
Airing: false,
|
||||
Episodes: 12,
|
||||
}}, time.Date(2026, time.June, 21, 0, 0, 0, 0, time.UTC))
|
||||
|
||||
if got.Count != 12 || got.Label != "Total episodes" {
|
||||
t.Fatalf("animeEpisodeCount() = %+v, want count=12 label=%q", got, "Total episodes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnimeInitialEpisodeCountDoesNotCallEpisodeService(t *testing.T) {
|
||||
episodeSvc := &stubEpisodeService{
|
||||
episodes: domain.CanonicalEpisodeList{
|
||||
Episodes: []domain.CanonicalEpisode{{Number: 1}, {Number: 2}, {Number: 3}},
|
||||
},
|
||||
}
|
||||
|
||||
got := animeInitialEpisodeCount(domain.Anime{Anime: jikan.Anime{
|
||||
MalID: 59970,
|
||||
Airing: true,
|
||||
Episodes: 12,
|
||||
Aired: jikan.Aired{From: "2026-04-03T00:00:00+00:00"},
|
||||
}}, time.Date(2026, time.June, 21, 0, 0, 0, 0, time.UTC))
|
||||
|
||||
if got.Count != 12 || got.Label != "Total episodes" {
|
||||
t.Fatalf("animeInitialEpisodeCount() = %+v, want count=12 label=%q", got, "Total episodes")
|
||||
}
|
||||
if episodeSvc.called != 0 {
|
||||
t.Fatalf("GetCanonicalEpisodes() calls = %d, want 0", episodeSvc.called)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnimeAudioAvailabilityLabel(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -116,7 +287,7 @@ func TestAnimeAudioAvailabilityRequiresAllAnimeSource(t *testing.T) {
|
||||
if got != tt.want {
|
||||
t.Fatalf("animeAudioAvailability() = %q, want %q", got, tt.want)
|
||||
}
|
||||
if !episodeSvc.forced {
|
||||
if !episodeSvc.forceRefresh {
|
||||
t.Fatal("animeAudioAvailability() did not force provider refresh")
|
||||
}
|
||||
})
|
||||
|
||||
@@ -2,830 +2,14 @@ package anime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"mal/integrations/jikan"
|
||||
"mal/internal/db"
|
||||
"mal/internal/anime/recommendations"
|
||||
"mal/internal/domain"
|
||||
"mal/internal/observability"
|
||||
"math"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
const (
|
||||
forYouMaxSeeds = 8
|
||||
forYouMaxRecommendations = 10
|
||||
forYouCandidateFetchLimit = 60
|
||||
forYouResultLimit = 18
|
||||
forYouFullResultLimit = 60
|
||||
forYouProfileSearchLimit = 8
|
||||
forYouProfileGenreSearches = 2
|
||||
forYouProfileThemeSearches = 2
|
||||
forYouCollaborativeWeight = 1.4
|
||||
forYouProfileSearchWeight = 0.8
|
||||
forYouSeedRecencyWindow = 180 * 24 * time.Hour
|
||||
forYouFreshReleaseWindow = 540 * 24 * time.Hour
|
||||
forYouGenreMatchWeight = 1.8
|
||||
forYouThemeMatchWeight = 1.0
|
||||
forYouStudioMatchWeight = 0.7
|
||||
forYouDemographicMatchWeight = 0.9
|
||||
forYouRecentDiversityWindow = 3
|
||||
forYouGenreDiversityPenalty = 1.7
|
||||
forYouThemeDiversityPenalty = 1.2
|
||||
forYouDemoDiversityPenalty = 1.0
|
||||
forYouStudioDiversityPenalty = 0.7
|
||||
)
|
||||
|
||||
type recommendationSeed struct {
|
||||
animeID int
|
||||
weight float64
|
||||
}
|
||||
|
||||
type weightedEntity struct {
|
||||
id int
|
||||
weight float64
|
||||
}
|
||||
|
||||
type profileSearchQuery struct {
|
||||
genreIDs []int
|
||||
studioID int
|
||||
weight float64
|
||||
}
|
||||
|
||||
type recommendationCandidate struct {
|
||||
anime jikan.Anime
|
||||
score float64
|
||||
genreMatches int
|
||||
themeMatches int
|
||||
studioMatches int
|
||||
demographicMatches int
|
||||
}
|
||||
|
||||
type userTasteProfile struct {
|
||||
genres map[int]float64
|
||||
themes map[int]float64
|
||||
studios map[int]float64
|
||||
demographics map[int]float64
|
||||
prefersAiring bool
|
||||
prefersRecent bool
|
||||
}
|
||||
|
||||
func buildRecommendationSeeds(
|
||||
now time.Time,
|
||||
watchlist []db.GetUserWatchListRow,
|
||||
) []recommendationSeed {
|
||||
seeds := make([]recommendationSeed, 0, min(len(watchlist), forYouMaxSeeds))
|
||||
|
||||
for _, entry := range watchlist {
|
||||
weight := recommendationEntryWeight(now, entry)
|
||||
if weight <= 0 || entry.AnimeID <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
seeds = append(seeds, recommendationSeed{
|
||||
animeID: int(entry.AnimeID),
|
||||
weight: weight,
|
||||
})
|
||||
if len(seeds) >= forYouMaxSeeds {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return seeds
|
||||
}
|
||||
|
||||
func recommendationEntryWeight(now time.Time, entry db.GetUserWatchListRow) float64 {
|
||||
status := strings.TrimSpace(entry.Status)
|
||||
|
||||
var statusWeight float64
|
||||
switch status {
|
||||
case "completed":
|
||||
statusWeight = 1.0
|
||||
case "watching":
|
||||
statusWeight = 0.9
|
||||
case "plan_to_watch":
|
||||
statusWeight = 0.35
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
|
||||
recencyWeight := 1.0
|
||||
if !entry.UpdatedAt.IsZero() {
|
||||
age := now.Sub(entry.UpdatedAt)
|
||||
if age > 0 {
|
||||
recencyWeight = math.Max(0.35, 1-(age.Hours()/forYouSeedRecencyWindow.Hours()))
|
||||
}
|
||||
}
|
||||
|
||||
progressWeight := 0.6
|
||||
if entry.CurrentEpisode.Valid && entry.CurrentEpisode.Int64 > 0 {
|
||||
progressWeight = min(1.0, 0.6+(0.08*float64(entry.CurrentEpisode.Int64)))
|
||||
}
|
||||
|
||||
return statusWeight * recencyWeight * progressWeight
|
||||
}
|
||||
|
||||
func buildTasteProfile(
|
||||
now time.Time,
|
||||
seeds []recommendationSeed,
|
||||
seedAnimes []jikan.Anime,
|
||||
) userTasteProfile {
|
||||
profile := userTasteProfile{
|
||||
genres: make(map[int]float64),
|
||||
themes: make(map[int]float64),
|
||||
studios: make(map[int]float64),
|
||||
demographics: make(map[int]float64),
|
||||
}
|
||||
|
||||
var totalWeight float64
|
||||
var airingWeight float64
|
||||
var recentWeight float64
|
||||
|
||||
for i, anime := range seedAnimes {
|
||||
seedWeight := 1.0
|
||||
if i < len(seeds) && seeds[i].weight > 0 {
|
||||
seedWeight = seeds[i].weight
|
||||
}
|
||||
|
||||
addEntityWeights(profile.genres, anime.Genres, seedWeight)
|
||||
addEntityWeights(profile.themes, anime.Themes, seedWeight*0.7)
|
||||
addEntityWeights(profile.studios, anime.Studios, seedWeight*0.5)
|
||||
addEntityWeights(profile.demographics, anime.Demographics, seedWeight*0.7)
|
||||
|
||||
if anime.Airing {
|
||||
airingWeight += seedWeight
|
||||
}
|
||||
if anime.Year > 0 && now.Year()-anime.Year <= 4 {
|
||||
recentWeight += seedWeight
|
||||
}
|
||||
totalWeight += seedWeight
|
||||
}
|
||||
|
||||
if totalWeight > 0 {
|
||||
profile.prefersAiring = airingWeight/totalWeight >= 0.5
|
||||
profile.prefersRecent = recentWeight/totalWeight >= 0.5
|
||||
}
|
||||
|
||||
return profile
|
||||
}
|
||||
|
||||
func addEntityWeights(target map[int]float64, entities []jikan.NamedEntity, weight float64) {
|
||||
for _, entity := range entities {
|
||||
if entity.MalID <= 0 {
|
||||
continue
|
||||
}
|
||||
target[entity.MalID] += weight
|
||||
}
|
||||
}
|
||||
|
||||
func buildProfileSearchQueries(profile userTasteProfile) []profileSearchQuery {
|
||||
queries := make([]profileSearchQuery, 0, 6)
|
||||
|
||||
for _, entity := range strongestWeightedEntities(profile.genres, forYouProfileGenreSearches) {
|
||||
queries = append(queries, profileSearchQuery{
|
||||
genreIDs: []int{entity.id},
|
||||
weight: entity.weight,
|
||||
})
|
||||
}
|
||||
|
||||
for _, entity := range strongestWeightedEntities(profile.themes, forYouProfileThemeSearches) {
|
||||
queries = append(queries, profileSearchQuery{
|
||||
genreIDs: []int{entity.id},
|
||||
weight: entity.weight * 0.8,
|
||||
})
|
||||
}
|
||||
|
||||
for _, entity := range strongestWeightedEntities(profile.demographics, 1) {
|
||||
queries = append(queries, profileSearchQuery{
|
||||
genreIDs: []int{entity.id},
|
||||
weight: entity.weight * 0.8,
|
||||
})
|
||||
}
|
||||
|
||||
for _, entity := range strongestWeightedEntities(profile.studios, 1) {
|
||||
queries = append(queries, profileSearchQuery{
|
||||
studioID: entity.id,
|
||||
weight: entity.weight * 0.7,
|
||||
})
|
||||
}
|
||||
|
||||
return queries
|
||||
}
|
||||
|
||||
func strongestWeightedEntities(weights map[int]float64, limit int) []weightedEntity {
|
||||
if limit <= 0 || len(weights) == 0 {
|
||||
return []weightedEntity{}
|
||||
}
|
||||
|
||||
items := make([]weightedEntity, 0, len(weights))
|
||||
for id, weight := range weights {
|
||||
if id <= 0 || weight <= 0 {
|
||||
continue
|
||||
}
|
||||
items = append(items, weightedEntity{id: id, weight: weight})
|
||||
}
|
||||
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
if items[i].weight == items[j].weight {
|
||||
return items[i].id < items[j].id
|
||||
}
|
||||
return items[i].weight > items[j].weight
|
||||
})
|
||||
|
||||
if len(items) > limit {
|
||||
return items[:limit]
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func profileSearchRankWeight(rank int) float64 {
|
||||
return math.Max(0.35, 1-(float64(rank)*0.08))
|
||||
}
|
||||
|
||||
func rankedCandidateRetrievalScore(collaborativeScore float64, profileSearchScore float64) float64 {
|
||||
return (math.Log1p(collaborativeScore) * forYouCollaborativeWeight) +
|
||||
(profileSearchScore * forYouProfileSearchWeight)
|
||||
}
|
||||
|
||||
func hasTasteMetadata(anime jikan.Anime) bool {
|
||||
return len(anime.Genres) > 0 ||
|
||||
len(anime.Themes) > 0 ||
|
||||
len(anime.Studios) > 0 ||
|
||||
len(anime.Demographics) > 0
|
||||
}
|
||||
|
||||
func scoreRecommendationCandidate(
|
||||
now time.Time,
|
||||
profile userTasteProfile,
|
||||
candidate jikan.Anime,
|
||||
collaborativeScore float64,
|
||||
profileSearchScore float64,
|
||||
) recommendationCandidate {
|
||||
genreMatches, genreScore := weightedEntityMatch(profile.genres, candidate.Genres)
|
||||
themeMatches, themeScore := weightedEntityMatch(profile.themes, candidate.Themes)
|
||||
studioMatches, studioScore := weightedEntityMatch(profile.studios, candidate.Studios)
|
||||
demographicMatches, demographicScore := weightedEntityMatch(profile.demographics, candidate.Demographics)
|
||||
|
||||
score := rankedCandidateRetrievalScore(collaborativeScore, profileSearchScore)
|
||||
score += genreScore * forYouGenreMatchWeight
|
||||
score += themeScore * forYouThemeMatchWeight
|
||||
score += studioScore * forYouStudioMatchWeight
|
||||
score += demographicScore * forYouDemographicMatchWeight
|
||||
score += recommendationCandidateScoreAdjustments(now, profile, candidate)
|
||||
|
||||
return recommendationCandidate{
|
||||
anime: candidate,
|
||||
score: score,
|
||||
genreMatches: genreMatches,
|
||||
themeMatches: themeMatches,
|
||||
studioMatches: studioMatches,
|
||||
demographicMatches: demographicMatches,
|
||||
}
|
||||
}
|
||||
|
||||
func recommendationCandidateScoreAdjustments(
|
||||
now time.Time,
|
||||
profile userTasteProfile,
|
||||
candidate jikan.Anime,
|
||||
) float64 {
|
||||
var score float64
|
||||
|
||||
if candidate.Score > 0 {
|
||||
score += min(candidate.Score/10.0, 1.0)
|
||||
}
|
||||
if candidate.Popularity > 0 {
|
||||
score += 1.0 / math.Log(float64(candidate.Popularity)+8)
|
||||
}
|
||||
if profile.prefersAiring && candidate.Airing {
|
||||
score += 0.5
|
||||
}
|
||||
if profile.prefersRecent && isRecentCandidate(now, candidate.Year) {
|
||||
score += 0.45
|
||||
}
|
||||
if isClassicCandidate(now, candidate.Year) {
|
||||
score -= 0.2
|
||||
}
|
||||
if candidate.Status == "Not yet aired" {
|
||||
score -= 0.35
|
||||
}
|
||||
if isFreshRelease(now, candidate.Aired.From) {
|
||||
score += 0.3
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
func isRecentCandidate(now time.Time, year int) bool {
|
||||
return year > 0 && now.Year()-year <= 4
|
||||
}
|
||||
|
||||
func isClassicCandidate(now time.Time, year int) bool {
|
||||
return year > 0 && now.Year()-year > 15
|
||||
}
|
||||
|
||||
func isFreshRelease(now time.Time, airedFrom string) bool {
|
||||
if airedFrom == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
airedAt, err := time.Parse(time.RFC3339, airedFrom)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return now.Sub(airedAt) <= forYouFreshReleaseWindow
|
||||
}
|
||||
|
||||
func weightedEntityMatch(weights map[int]float64, entities []jikan.NamedEntity) (int, float64) {
|
||||
var (
|
||||
matches int
|
||||
score float64
|
||||
)
|
||||
|
||||
for _, entity := range entities {
|
||||
weight, ok := weights[entity.MalID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
matches++
|
||||
score += weight
|
||||
}
|
||||
|
||||
return matches, score
|
||||
}
|
||||
|
||||
func rerankRecommendationCandidates(candidates []recommendationCandidate, limit int) []domain.Anime {
|
||||
selected := make([]domain.Anime, 0, min(limit, len(candidates)))
|
||||
remaining := slices.Clone(candidates)
|
||||
seenFeatures := newDiversityFeatureCounts()
|
||||
recentFeatures := make([]diversityFeatureSet, 0, forYouRecentDiversityWindow)
|
||||
|
||||
for len(selected) < limit && len(remaining) > 0 {
|
||||
bestIndex := bestDiverseCandidateIndex(remaining, seenFeatures, recentFeatures)
|
||||
candidate := remaining[bestIndex]
|
||||
remaining = slices.Delete(remaining, bestIndex, bestIndex+1)
|
||||
|
||||
if slices.ContainsFunc(selected, func(anime domain.Anime) bool {
|
||||
return anime.MalID == candidate.anime.MalID
|
||||
}) {
|
||||
continue
|
||||
}
|
||||
|
||||
selected = append(selected, domain.Anime{Anime: candidate.anime})
|
||||
features := diversityFeatures(candidate.anime)
|
||||
seenFeatures.add(features)
|
||||
recentFeatures = append(recentFeatures, features)
|
||||
if len(recentFeatures) > forYouRecentDiversityWindow {
|
||||
recentFeatures = recentFeatures[1:]
|
||||
}
|
||||
}
|
||||
|
||||
return selected
|
||||
}
|
||||
|
||||
type diversityFeatureSet struct {
|
||||
genres map[int]struct{}
|
||||
themes map[int]struct{}
|
||||
demographics map[int]struct{}
|
||||
studios map[int]struct{}
|
||||
}
|
||||
|
||||
type diversityFeatureCounts struct {
|
||||
genres map[int]int
|
||||
themes map[int]int
|
||||
demographics map[int]int
|
||||
studios map[int]int
|
||||
}
|
||||
|
||||
func newDiversityFeatureCounts() diversityFeatureCounts {
|
||||
return diversityFeatureCounts{
|
||||
genres: make(map[int]int),
|
||||
themes: make(map[int]int),
|
||||
demographics: make(map[int]int),
|
||||
studios: make(map[int]int),
|
||||
}
|
||||
}
|
||||
|
||||
func (counts diversityFeatureCounts) add(features diversityFeatureSet) {
|
||||
addDiversityCounts(counts.genres, features.genres)
|
||||
addDiversityCounts(counts.themes, features.themes)
|
||||
addDiversityCounts(counts.demographics, features.demographics)
|
||||
addDiversityCounts(counts.studios, features.studios)
|
||||
}
|
||||
|
||||
func addDiversityCounts(target map[int]int, features map[int]struct{}) {
|
||||
for id := range features {
|
||||
target[id]++
|
||||
}
|
||||
}
|
||||
|
||||
func bestDiverseCandidateIndex(
|
||||
candidates []recommendationCandidate,
|
||||
seen diversityFeatureCounts,
|
||||
recent []diversityFeatureSet,
|
||||
) int {
|
||||
bestIndex := 0
|
||||
bestScore := math.Inf(-1)
|
||||
|
||||
for i, candidate := range candidates {
|
||||
score := candidate.score - diversityPenalty(diversityFeatures(candidate.anime), seen, recent)
|
||||
if score == bestScore {
|
||||
if candidate.score <= candidates[bestIndex].score {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if score > bestScore {
|
||||
bestScore = score
|
||||
bestIndex = i
|
||||
}
|
||||
}
|
||||
|
||||
return bestIndex
|
||||
}
|
||||
|
||||
func diversityFeatures(anime jikan.Anime) diversityFeatureSet {
|
||||
return diversityFeatureSet{
|
||||
genres: entityIDSet(anime.Genres),
|
||||
themes: entityIDSet(anime.Themes),
|
||||
demographics: entityIDSet(anime.Demographics),
|
||||
studios: entityIDSet(anime.Studios),
|
||||
}
|
||||
}
|
||||
|
||||
func entityIDSet(entities []jikan.NamedEntity) map[int]struct{} {
|
||||
ids := make(map[int]struct{}, len(entities))
|
||||
for _, entity := range entities {
|
||||
if entity.MalID <= 0 {
|
||||
continue
|
||||
}
|
||||
ids[entity.MalID] = struct{}{}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func diversityPenalty(
|
||||
features diversityFeatureSet,
|
||||
seen diversityFeatureCounts,
|
||||
recent []diversityFeatureSet,
|
||||
) float64 {
|
||||
penalty := 0.0
|
||||
penalty += repeatedFeaturePenalty(features.genres, seen.genres, recentGenreCounts(recent), forYouGenreDiversityPenalty)
|
||||
penalty += repeatedFeaturePenalty(features.themes, seen.themes, recentThemeCounts(recent), forYouThemeDiversityPenalty)
|
||||
penalty += repeatedFeaturePenalty(
|
||||
features.demographics,
|
||||
seen.demographics,
|
||||
recentDemographicCounts(recent),
|
||||
forYouDemoDiversityPenalty,
|
||||
)
|
||||
penalty += repeatedFeaturePenalty(features.studios, seen.studios, recentStudioCounts(recent), forYouStudioDiversityPenalty)
|
||||
|
||||
return penalty
|
||||
}
|
||||
|
||||
func repeatedFeaturePenalty(
|
||||
features map[int]struct{},
|
||||
seen map[int]int,
|
||||
recent map[int]int,
|
||||
weight float64,
|
||||
) float64 {
|
||||
total := 0.0
|
||||
for id := range features {
|
||||
total += float64(seen[id]) * weight * 0.35
|
||||
total += float64(recent[id]) * weight
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func recentGenreCounts(recent []diversityFeatureSet) map[int]int {
|
||||
return recentFeatureCounts(recent, func(features diversityFeatureSet) map[int]struct{} {
|
||||
return features.genres
|
||||
})
|
||||
}
|
||||
|
||||
func recentThemeCounts(recent []diversityFeatureSet) map[int]int {
|
||||
return recentFeatureCounts(recent, func(features diversityFeatureSet) map[int]struct{} {
|
||||
return features.themes
|
||||
})
|
||||
}
|
||||
|
||||
func recentDemographicCounts(recent []diversityFeatureSet) map[int]int {
|
||||
return recentFeatureCounts(recent, func(features diversityFeatureSet) map[int]struct{} {
|
||||
return features.demographics
|
||||
})
|
||||
}
|
||||
|
||||
func recentStudioCounts(recent []diversityFeatureSet) map[int]int {
|
||||
return recentFeatureCounts(recent, func(features diversityFeatureSet) map[int]struct{} {
|
||||
return features.studios
|
||||
})
|
||||
}
|
||||
|
||||
func recentFeatureCounts(
|
||||
recent []diversityFeatureSet,
|
||||
selectFeatures func(diversityFeatureSet) map[int]struct{},
|
||||
) map[int]int {
|
||||
counts := make(map[int]int)
|
||||
for _, features := range recent {
|
||||
addDiversityCounts(counts, selectFeatures(features))
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
type rankedCandidate struct {
|
||||
id int
|
||||
collaborativeScore float64
|
||||
profileSearchScore float64
|
||||
anime jikan.Anime
|
||||
hasAnime bool
|
||||
}
|
||||
|
||||
type candidateStore struct {
|
||||
watchlistAnimeIDs map[int]struct{}
|
||||
byID map[int]rankedCandidate
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func newCandidateStore(watchlist []db.GetUserWatchListRow) *candidateStore {
|
||||
watchlistAnimeIDs := make(map[int]struct{}, len(watchlist))
|
||||
for _, entry := range watchlist {
|
||||
if entry.AnimeID <= 0 {
|
||||
continue
|
||||
}
|
||||
watchlistAnimeIDs[int(entry.AnimeID)] = struct{}{}
|
||||
}
|
||||
|
||||
return &candidateStore{
|
||||
watchlistAnimeIDs: watchlistAnimeIDs,
|
||||
byID: map[int]rankedCandidate{},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *candidateStore) upsert(candidate rankedCandidate) {
|
||||
if candidate.id <= 0 {
|
||||
return
|
||||
}
|
||||
if _, exists := s.watchlistAnimeIDs[candidate.id]; exists {
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
current, ok := s.byID[candidate.id]
|
||||
if !ok {
|
||||
s.byID[candidate.id] = candidate
|
||||
return
|
||||
}
|
||||
|
||||
current.collaborativeScore += candidate.collaborativeScore
|
||||
current.profileSearchScore += candidate.profileSearchScore
|
||||
if candidate.hasAnime {
|
||||
current.anime = candidate.anime
|
||||
current.hasAnime = true
|
||||
}
|
||||
s.byID[candidate.id] = current
|
||||
}
|
||||
|
||||
func (s *candidateStore) ranked() []rankedCandidate {
|
||||
ranked := make([]rankedCandidate, 0, len(s.byID))
|
||||
for _, item := range s.byID {
|
||||
ranked = append(ranked, item)
|
||||
}
|
||||
|
||||
sort.Slice(ranked, func(i, j int) bool {
|
||||
left := rankedCandidateRetrievalScore(ranked[i].collaborativeScore, ranked[i].profileSearchScore)
|
||||
right := rankedCandidateRetrievalScore(ranked[j].collaborativeScore, ranked[j].profileSearchScore)
|
||||
if left == right {
|
||||
return ranked[i].id < ranked[j].id
|
||||
}
|
||||
return left > right
|
||||
})
|
||||
|
||||
return ranked
|
||||
}
|
||||
|
||||
func (s *animeService) GetTopPickForYou(ctx context.Context, userID string) (domain.CatalogSectionData, error) {
|
||||
return s.getTopPicksForYou(ctx, userID, forYouResultLimit)
|
||||
return recommendations.GetTopPicksForYou(ctx, s.jikan, s.repo, userID, recommendations.TopPickLimit)
|
||||
}
|
||||
|
||||
func (s *animeService) GetTopPicksForYou(ctx context.Context, userID string) (domain.CatalogSectionData, error) {
|
||||
return s.getTopPicksForYou(ctx, userID, forYouFullResultLimit)
|
||||
}
|
||||
|
||||
func (s *animeService) fetchSeedAnimes(ctx context.Context, seedPool []recommendationSeed) ([]jikan.Anime, error) {
|
||||
seedAnimes := make([]jikan.Anime, len(seedPool))
|
||||
var g errgroup.Group
|
||||
g.SetLimit(4)
|
||||
|
||||
for i, seed := range seedPool {
|
||||
g.Go(func() error {
|
||||
anime, err := s.jikan.GetAnimeByID(ctx, seed.animeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
seedAnimes[i] = anime
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return seedAnimes, nil
|
||||
}
|
||||
|
||||
func (s *animeService) collectCollaborativeCandidates(ctx context.Context, seedPool []recommendationSeed, store *candidateStore) error {
|
||||
var g errgroup.Group
|
||||
g.SetLimit(4)
|
||||
|
||||
for _, seed := range seedPool {
|
||||
g.Go(func() error {
|
||||
recs, err := s.jikan.GetAnimeRecommendations(ctx, seed.animeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i, rec := range recs {
|
||||
if i >= forYouMaxRecommendations {
|
||||
break
|
||||
}
|
||||
id := rec.Entry.MalID
|
||||
if id <= 0 || id == seed.animeID {
|
||||
continue
|
||||
}
|
||||
store.upsert(rankedCandidate{
|
||||
id: id,
|
||||
collaborativeScore: float64(rec.Votes) * seed.weight,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
return g.Wait()
|
||||
}
|
||||
|
||||
func (s *animeService) collectProfileSearchCandidates(ctx context.Context, profile userTasteProfile, store *candidateStore) error {
|
||||
queries := buildProfileSearchQueries(profile)
|
||||
var g errgroup.Group
|
||||
g.SetLimit(3)
|
||||
|
||||
for _, query := range queries {
|
||||
g.Go(func() error {
|
||||
res, err := s.jikan.SearchAdvanced(
|
||||
ctx,
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"score",
|
||||
"desc",
|
||||
query.genreIDs,
|
||||
query.studioID,
|
||||
true,
|
||||
1,
|
||||
forYouProfileSearchLimit,
|
||||
)
|
||||
if err != nil {
|
||||
observability.Warn(
|
||||
"top_pick_profile_search_failed",
|
||||
"anime",
|
||||
"",
|
||||
map[string]any{
|
||||
"genres": query.genreIDs,
|
||||
"studio_id": query.studioID,
|
||||
},
|
||||
err,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
for i, anime := range res.Animes {
|
||||
if anime.MalID <= 0 {
|
||||
continue
|
||||
}
|
||||
store.upsert(rankedCandidate{
|
||||
id: anime.MalID,
|
||||
profileSearchScore: query.weight * profileSearchRankWeight(i),
|
||||
anime: anime,
|
||||
hasAnime: true,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
return g.Wait()
|
||||
}
|
||||
|
||||
func (s *animeService) scoreRankedCandidates(
|
||||
ctx context.Context,
|
||||
now time.Time,
|
||||
profile userTasteProfile,
|
||||
ranked []rankedCandidate,
|
||||
) ([]recommendationCandidate, error) {
|
||||
limit := min(len(ranked), forYouCandidateFetchLimit)
|
||||
candidates := make([]recommendationCandidate, 0, limit)
|
||||
var candidatesMu sync.Mutex
|
||||
var g errgroup.Group
|
||||
g.SetLimit(6)
|
||||
|
||||
for i := 0; i < limit; i++ {
|
||||
item := ranked[i]
|
||||
g.Go(func() error {
|
||||
anime := item.anime
|
||||
if !item.hasAnime || !hasTasteMetadata(anime) {
|
||||
fetchedAnime, err := s.jikan.GetAnimeByID(ctx, item.id)
|
||||
if err != nil {
|
||||
observability.Warn(
|
||||
"recommendation_anime_fetch_failed",
|
||||
"anime",
|
||||
"",
|
||||
map[string]any{"anime_id": item.id},
|
||||
err,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
anime = fetchedAnime
|
||||
}
|
||||
|
||||
candidate := scoreRecommendationCandidate(
|
||||
now,
|
||||
profile,
|
||||
anime,
|
||||
item.collaborativeScore,
|
||||
item.profileSearchScore,
|
||||
)
|
||||
candidatesMu.Lock()
|
||||
candidates = append(candidates, candidate)
|
||||
candidatesMu.Unlock()
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sort.Slice(candidates, func(i, j int) bool {
|
||||
if candidates[i].score == candidates[j].score {
|
||||
return candidates[i].anime.MalID < candidates[j].anime.MalID
|
||||
}
|
||||
return candidates[i].score > candidates[j].score
|
||||
})
|
||||
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
func (s *animeService) getTopPicksForYou(
|
||||
ctx context.Context,
|
||||
userID string,
|
||||
resultLimit int,
|
||||
) (domain.CatalogSectionData, error) {
|
||||
if strings.TrimSpace(userID) == "" {
|
||||
return domain.CatalogSectionData{Animes: []domain.Anime{}}, nil
|
||||
}
|
||||
|
||||
watchlist, err := s.repo.GetUserWatchList(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.CatalogSectionData{}, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
seedPool := buildRecommendationSeeds(now, watchlist)
|
||||
if len(seedPool) == 0 {
|
||||
return domain.CatalogSectionData{Animes: []domain.Anime{}}, nil
|
||||
}
|
||||
|
||||
seedAnimes, err := s.fetchSeedAnimes(ctx, seedPool)
|
||||
if err != nil {
|
||||
return domain.CatalogSectionData{}, err
|
||||
}
|
||||
|
||||
profile := buildTasteProfile(now, seedPool, seedAnimes)
|
||||
store := newCandidateStore(watchlist)
|
||||
|
||||
if err := s.collectCollaborativeCandidates(ctx, seedPool, store); err != nil {
|
||||
return domain.CatalogSectionData{}, err
|
||||
}
|
||||
if err := s.collectProfileSearchCandidates(ctx, profile, store); err != nil {
|
||||
return domain.CatalogSectionData{}, err
|
||||
}
|
||||
|
||||
ranked := store.ranked()
|
||||
if len(ranked) == 0 {
|
||||
return domain.CatalogSectionData{Animes: []domain.Anime{}}, nil
|
||||
}
|
||||
|
||||
candidates, err := s.scoreRankedCandidates(ctx, now, profile, ranked)
|
||||
if err != nil {
|
||||
return domain.CatalogSectionData{}, err
|
||||
}
|
||||
|
||||
return domain.CatalogSectionData{
|
||||
Animes: rerankRecommendationCandidates(candidates, resultLimit),
|
||||
}, nil
|
||||
return recommendations.GetTopPicksForYou(ctx, s.jikan, s.repo, userID, recommendations.TopPicksLimit)
|
||||
}
|
||||
|
||||
28
internal/anime/recommendations/constants.go
Normal file
28
internal/anime/recommendations/constants.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package recommendations
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
maxSeeds = 8
|
||||
maxRecommendations = 10
|
||||
candidateFetchLimit = 60
|
||||
candidateFetchBuffer = 6
|
||||
TopPickLimit = 18
|
||||
TopPicksLimit = 60
|
||||
profileSearchLimit = 8
|
||||
profileGenreSearches = 2
|
||||
profileThemeSearches = 2
|
||||
collaborativeWeight = 1.4
|
||||
profileSearchWeight = 0.8
|
||||
seedRecencyWindow = 180 * 24 * time.Hour
|
||||
freshReleaseWindow = 540 * 24 * time.Hour
|
||||
genreMatchWeight = 1.8
|
||||
themeMatchWeight = 1.0
|
||||
studioMatchWeight = 0.7
|
||||
demographicMatchWeight = 0.9
|
||||
recentDiversityWindow = 3
|
||||
genreDiversityPenalty = 1.7
|
||||
themeDiversityPenalty = 1.2
|
||||
demoDiversityPenalty = 1.0
|
||||
studioDiversityPenalty = 0.7
|
||||
)
|
||||
262
internal/anime/recommendations/engine.go
Normal file
262
internal/anime/recommendations/engine.go
Normal file
@@ -0,0 +1,262 @@
|
||||
package recommendations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"mal/integrations/jikan"
|
||||
"mal/internal/domain"
|
||||
"mal/internal/observability"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
type engine struct {
|
||||
jikan *jikan.Client
|
||||
repo domain.AnimeRepository
|
||||
}
|
||||
|
||||
func GetTopPicksForYou(
|
||||
ctx context.Context,
|
||||
jikanClient *jikan.Client,
|
||||
repo domain.AnimeRepository,
|
||||
userID string,
|
||||
resultLimit int,
|
||||
) (domain.CatalogSectionData, error) {
|
||||
return engine{jikan: jikanClient, repo: repo}.getTopPicksForYou(ctx, userID, resultLimit)
|
||||
}
|
||||
|
||||
func (e engine) getTopPicksForYou(ctx context.Context, userID string, resultLimit int) (domain.CatalogSectionData, error) {
|
||||
if strings.TrimSpace(userID) == "" {
|
||||
return domain.CatalogSectionData{Animes: []domain.Anime{}}, nil
|
||||
}
|
||||
|
||||
watchlist, err := e.repo.GetUserWatchList(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.CatalogSectionData{}, fmt.Errorf("get user watchlist for %q: %w", userID, err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
seedPool := buildRecommendationSeeds(now, watchlist)
|
||||
if len(seedPool) == 0 {
|
||||
return domain.CatalogSectionData{Animes: []domain.Anime{}}, nil
|
||||
}
|
||||
|
||||
seedAnimes, err := e.fetchSeedAnimes(ctx, seedPool)
|
||||
if err != nil {
|
||||
return domain.CatalogSectionData{}, fmt.Errorf("fetch seed animes: %w", err)
|
||||
}
|
||||
|
||||
profile := buildTasteProfile(now, seedPool, seedAnimes)
|
||||
store := newCandidateStore(watchlist)
|
||||
|
||||
if err := e.collectCollaborativeCandidates(ctx, seedPool, store); err != nil {
|
||||
return domain.CatalogSectionData{}, fmt.Errorf("collect collaborative candidates: %w", err)
|
||||
}
|
||||
if err := e.collectProfileSearchCandidates(ctx, profile, store); err != nil {
|
||||
return domain.CatalogSectionData{}, fmt.Errorf("collect profile search candidates: %w", err)
|
||||
}
|
||||
|
||||
ranked := store.ranked()
|
||||
if len(ranked) == 0 {
|
||||
return domain.CatalogSectionData{Animes: []domain.Anime{}}, nil
|
||||
}
|
||||
|
||||
candidates, err := e.scoreRankedCandidates(ctx, now, profile, ranked, resultLimit)
|
||||
if err != nil {
|
||||
return domain.CatalogSectionData{}, fmt.Errorf("score ranked candidates: %w", err)
|
||||
}
|
||||
|
||||
return domain.CatalogSectionData{
|
||||
Animes: rerankRecommendationCandidates(candidates, resultLimit),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (e engine) fetchSeedAnimes(ctx context.Context, seedPool []recommendationSeed) ([]jikan.Anime, error) {
|
||||
seedAnimes := make([]jikan.Anime, len(seedPool))
|
||||
var g errgroup.Group
|
||||
g.SetLimit(4)
|
||||
|
||||
for i, seed := range seedPool {
|
||||
g.Go(func() error {
|
||||
anime, err := e.jikan.GetAnimeByID(ctx, seed.animeID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get seed anime %d: %w", seed.animeID, err)
|
||||
}
|
||||
seedAnimes[i] = anime
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, fmt.Errorf("wait for seed anime fetches: %w", err)
|
||||
}
|
||||
|
||||
return seedAnimes, nil
|
||||
}
|
||||
|
||||
func (e engine) collectCollaborativeCandidates(ctx context.Context, seedPool []recommendationSeed, store *candidateStore) error {
|
||||
var g errgroup.Group
|
||||
g.SetLimit(4)
|
||||
|
||||
for _, seed := range seedPool {
|
||||
g.Go(func() error {
|
||||
recs, err := e.jikan.GetAnimeRecommendations(ctx, seed.animeID)
|
||||
if err != nil {
|
||||
observability.Warn(
|
||||
"collaborative_recommendations_failed",
|
||||
"anime",
|
||||
"",
|
||||
map[string]any{"seed_id": seed.animeID},
|
||||
err,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
for i, rec := range recs {
|
||||
if i >= maxRecommendations {
|
||||
break
|
||||
}
|
||||
id := rec.Entry.MalID
|
||||
if id <= 0 || id == seed.animeID {
|
||||
continue
|
||||
}
|
||||
store.upsert(rankedCandidate{
|
||||
id: id,
|
||||
collaborativeScore: float64(rec.Votes) * seed.weight,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return fmt.Errorf("wait for collaborative candidate fetches: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e engine) collectProfileSearchCandidates(ctx context.Context, profile userTasteProfile, store *candidateStore) error {
|
||||
queries := buildProfileSearchQueries(profile)
|
||||
var g errgroup.Group
|
||||
g.SetLimit(3)
|
||||
|
||||
for _, query := range queries {
|
||||
g.Go(func() error {
|
||||
res, err := e.jikan.SearchAdvanced(
|
||||
ctx,
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"score",
|
||||
"desc",
|
||||
query.genreIDs,
|
||||
query.studioID,
|
||||
true,
|
||||
1,
|
||||
profileSearchLimit,
|
||||
)
|
||||
if err != nil {
|
||||
observability.Warn(
|
||||
"top_pick_profile_search_failed",
|
||||
"anime",
|
||||
"",
|
||||
map[string]any{
|
||||
"genres": query.genreIDs,
|
||||
"studio_id": query.studioID,
|
||||
},
|
||||
err,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
for i, anime := range res.Animes {
|
||||
if anime.MalID <= 0 {
|
||||
continue
|
||||
}
|
||||
store.upsert(rankedCandidate{
|
||||
id: anime.MalID,
|
||||
profileSearchScore: query.weight * profileSearchRankWeight(i),
|
||||
anime: anime,
|
||||
hasAnime: true,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return fmt.Errorf("wait for profile search candidate fetches: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e engine) scoreRankedCandidates(
|
||||
ctx context.Context,
|
||||
now time.Time,
|
||||
profile userTasteProfile,
|
||||
ranked []rankedCandidate,
|
||||
resultLimit int,
|
||||
) ([]recommendationCandidate, error) {
|
||||
limit := min(len(ranked), candidateScoreLimit(resultLimit))
|
||||
candidates := make([]recommendationCandidate, 0, limit)
|
||||
var candidatesMu sync.Mutex
|
||||
var g errgroup.Group
|
||||
g.SetLimit(6)
|
||||
|
||||
for i := range limit {
|
||||
item := ranked[i]
|
||||
g.Go(func() error {
|
||||
anime := item.anime
|
||||
if !item.hasAnime || !hasTasteMetadata(anime) {
|
||||
fetchedAnime, err := e.jikan.GetAnimeByID(ctx, item.id)
|
||||
if err != nil {
|
||||
observability.Warn(
|
||||
"recommendation_anime_fetch_failed",
|
||||
"anime",
|
||||
"",
|
||||
map[string]any{"anime_id": item.id},
|
||||
err,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
anime = fetchedAnime
|
||||
}
|
||||
|
||||
candidate := scoreRecommendationCandidate(
|
||||
now,
|
||||
profile,
|
||||
anime,
|
||||
item.collaborativeScore,
|
||||
item.profileSearchScore,
|
||||
)
|
||||
candidatesMu.Lock()
|
||||
candidates = append(candidates, candidate)
|
||||
candidatesMu.Unlock()
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, fmt.Errorf("wait for candidate scoring: %w", err)
|
||||
}
|
||||
|
||||
sort.Slice(candidates, func(i, j int) bool {
|
||||
if candidates[i].score == candidates[j].score {
|
||||
return candidates[i].anime.MalID < candidates[j].anime.MalID
|
||||
}
|
||||
return candidates[i].score > candidates[j].score
|
||||
})
|
||||
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
func candidateScoreLimit(resultLimit int) int {
|
||||
if resultLimit <= 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
return min(candidateFetchLimit, resultLimit+candidateFetchBuffer)
|
||||
}
|
||||
171
internal/anime/recommendations/profile.go
Normal file
171
internal/anime/recommendations/profile.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package recommendations
|
||||
|
||||
import (
|
||||
"mal/integrations/jikan"
|
||||
"mal/internal/db"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func buildRecommendationSeeds(now time.Time, watchlist []db.GetUserWatchListRow) []recommendationSeed {
|
||||
seeds := make([]recommendationSeed, 0, min(len(watchlist), maxSeeds))
|
||||
|
||||
for _, entry := range watchlist {
|
||||
weight := recommendationEntryWeight(now, entry)
|
||||
if weight <= 0 || entry.AnimeID <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
seeds = append(seeds, recommendationSeed{
|
||||
animeID: int(entry.AnimeID),
|
||||
weight: weight,
|
||||
})
|
||||
if len(seeds) >= maxSeeds {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return seeds
|
||||
}
|
||||
|
||||
func recommendationEntryWeight(now time.Time, entry db.GetUserWatchListRow) float64 {
|
||||
status := strings.TrimSpace(entry.Status)
|
||||
|
||||
var statusWeight float64
|
||||
switch status {
|
||||
case "completed":
|
||||
statusWeight = 1.0
|
||||
case "watching":
|
||||
statusWeight = 0.9
|
||||
case "plan_to_watch":
|
||||
statusWeight = 0.35
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
|
||||
recencyWeight := 1.0
|
||||
if !entry.UpdatedAt.IsZero() {
|
||||
age := now.Sub(entry.UpdatedAt)
|
||||
if age > 0 {
|
||||
recencyWeight = math.Max(0.35, 1-(age.Hours()/seedRecencyWindow.Hours()))
|
||||
}
|
||||
}
|
||||
|
||||
progressWeight := 0.6
|
||||
if entry.CurrentEpisode.Valid && entry.CurrentEpisode.Int64 > 0 {
|
||||
progressWeight = min(1.0, 0.6+(0.08*float64(entry.CurrentEpisode.Int64)))
|
||||
}
|
||||
|
||||
return statusWeight * recencyWeight * progressWeight
|
||||
}
|
||||
|
||||
func buildTasteProfile(now time.Time, seeds []recommendationSeed, seedAnimes []jikan.Anime) userTasteProfile {
|
||||
profile := userTasteProfile{
|
||||
genres: make(map[int]float64),
|
||||
themes: make(map[int]float64),
|
||||
studios: make(map[int]float64),
|
||||
demographics: make(map[int]float64),
|
||||
}
|
||||
|
||||
var totalWeight float64
|
||||
var airingWeight float64
|
||||
var recentWeight float64
|
||||
|
||||
for i, anime := range seedAnimes {
|
||||
seedWeight := 1.0
|
||||
if i < len(seeds) && seeds[i].weight > 0 {
|
||||
seedWeight = seeds[i].weight
|
||||
}
|
||||
|
||||
addEntityWeights(profile.genres, anime.Genres, seedWeight)
|
||||
addEntityWeights(profile.themes, anime.Themes, seedWeight*0.7)
|
||||
addEntityWeights(profile.studios, anime.Studios, seedWeight*0.5)
|
||||
addEntityWeights(profile.demographics, anime.Demographics, seedWeight*0.7)
|
||||
|
||||
if anime.Airing {
|
||||
airingWeight += seedWeight
|
||||
}
|
||||
if anime.Year > 0 && now.Year()-anime.Year <= 4 {
|
||||
recentWeight += seedWeight
|
||||
}
|
||||
totalWeight += seedWeight
|
||||
}
|
||||
|
||||
if totalWeight > 0 {
|
||||
profile.prefersAiring = airingWeight/totalWeight >= 0.5
|
||||
profile.prefersRecent = recentWeight/totalWeight >= 0.5
|
||||
}
|
||||
|
||||
return profile
|
||||
}
|
||||
|
||||
func addEntityWeights(target map[int]float64, entities []jikan.NamedEntity, weight float64) {
|
||||
for _, entity := range entities {
|
||||
if entity.MalID <= 0 {
|
||||
continue
|
||||
}
|
||||
target[entity.MalID] += weight
|
||||
}
|
||||
}
|
||||
|
||||
func buildProfileSearchQueries(profile userTasteProfile) []profileSearchQuery {
|
||||
queries := make([]profileSearchQuery, 0, 6)
|
||||
|
||||
for _, entity := range strongestWeightedEntities(profile.genres, profileGenreSearches) {
|
||||
queries = append(queries, profileSearchQuery{
|
||||
genreIDs: []int{entity.id},
|
||||
weight: entity.weight,
|
||||
})
|
||||
}
|
||||
|
||||
for _, entity := range strongestWeightedEntities(profile.themes, profileThemeSearches) {
|
||||
queries = append(queries, profileSearchQuery{
|
||||
genreIDs: []int{entity.id},
|
||||
weight: entity.weight * 0.8,
|
||||
})
|
||||
}
|
||||
|
||||
for _, entity := range strongestWeightedEntities(profile.demographics, 1) {
|
||||
queries = append(queries, profileSearchQuery{
|
||||
genreIDs: []int{entity.id},
|
||||
weight: entity.weight * 0.8,
|
||||
})
|
||||
}
|
||||
|
||||
for _, entity := range strongestWeightedEntities(profile.studios, 1) {
|
||||
queries = append(queries, profileSearchQuery{
|
||||
studioID: entity.id,
|
||||
weight: entity.weight * 0.7,
|
||||
})
|
||||
}
|
||||
|
||||
return queries
|
||||
}
|
||||
|
||||
func strongestWeightedEntities(weights map[int]float64, limit int) []weightedEntity {
|
||||
if limit <= 0 || len(weights) == 0 {
|
||||
return []weightedEntity{}
|
||||
}
|
||||
|
||||
items := make([]weightedEntity, 0, len(weights))
|
||||
for id, weight := range weights {
|
||||
if id <= 0 || weight <= 0 {
|
||||
continue
|
||||
}
|
||||
items = append(items, weightedEntity{id: id, weight: weight})
|
||||
}
|
||||
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
if items[i].weight == items[j].weight {
|
||||
return items[i].id < items[j].id
|
||||
}
|
||||
return items[i].weight > items[j].weight
|
||||
})
|
||||
|
||||
if len(items) > limit {
|
||||
return items[:limit]
|
||||
}
|
||||
return items
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
package anime
|
||||
package recommendations
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"mal/integrations/jikan"
|
||||
"mal/internal/db"
|
||||
"mal/internal/domain"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -174,6 +175,18 @@ func TestRerankRecommendationCandidatesSpreadsRepeatedGenres(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCandidateScoreLimitTracksRequestedResultSize(t *testing.T) {
|
||||
if got := candidateScoreLimit(TopPickLimit); got != TopPickLimit+candidateFetchBuffer {
|
||||
t.Fatalf("expected top-pick scoring to fetch a small oversample, got %d", got)
|
||||
}
|
||||
if got := candidateScoreLimit(TopPicksLimit); got != candidateFetchLimit {
|
||||
t.Fatalf("expected full top-picks scoring to keep existing cap, got %d", got)
|
||||
}
|
||||
if got := candidateScoreLimit(0); got != 0 {
|
||||
t.Fatalf("expected zero result limit to skip scoring, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func testRecommendationAnime(id int, genreID int) jikan.Anime {
|
||||
return jikan.Anime{
|
||||
MalID: id,
|
||||
@@ -207,10 +220,8 @@ func animeIDs(animes []domain.Anime) []int {
|
||||
|
||||
func hasGenreSearchQuery(queries []profileSearchQuery, genreID int) bool {
|
||||
for _, query := range queries {
|
||||
for _, id := range query.genreIDs {
|
||||
if id == genreID {
|
||||
return true
|
||||
}
|
||||
if slices.Contains(query.genreIDs, genreID) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
167
internal/anime/recommendations/rerank.go
Normal file
167
internal/anime/recommendations/rerank.go
Normal file
@@ -0,0 +1,167 @@
|
||||
package recommendations
|
||||
|
||||
import (
|
||||
"mal/integrations/jikan"
|
||||
"mal/internal/domain"
|
||||
"math"
|
||||
"slices"
|
||||
)
|
||||
|
||||
func rerankRecommendationCandidates(candidates []recommendationCandidate, limit int) []domain.Anime {
|
||||
selected := make([]domain.Anime, 0, min(limit, len(candidates)))
|
||||
remaining := slices.Clone(candidates)
|
||||
seen := newDiversityFeatureCounts()
|
||||
recent := make([]diversityFeatureSet, 0, recentDiversityWindow)
|
||||
|
||||
for len(selected) < limit && len(remaining) > 0 {
|
||||
bestIndex := bestDiverseCandidateIndex(remaining, seen, recent)
|
||||
candidate := remaining[bestIndex]
|
||||
remaining = slices.Delete(remaining, bestIndex, bestIndex+1)
|
||||
|
||||
if slices.ContainsFunc(selected, func(anime domain.Anime) bool {
|
||||
return anime.MalID == candidate.anime.MalID
|
||||
}) {
|
||||
continue
|
||||
}
|
||||
|
||||
selected = append(selected, domain.Anime{Anime: candidate.anime})
|
||||
features := diversityFeatures(candidate.anime)
|
||||
seen.add(features)
|
||||
recent = append(recent, features)
|
||||
if len(recent) > recentDiversityWindow {
|
||||
recent = recent[1:]
|
||||
}
|
||||
}
|
||||
|
||||
return selected
|
||||
}
|
||||
|
||||
type diversityFeatureSet struct {
|
||||
genres map[int]struct{}
|
||||
themes map[int]struct{}
|
||||
demographics map[int]struct{}
|
||||
studios map[int]struct{}
|
||||
}
|
||||
|
||||
type diversityFeatureCounts struct {
|
||||
genres map[int]int
|
||||
themes map[int]int
|
||||
demographics map[int]int
|
||||
studios map[int]int
|
||||
}
|
||||
|
||||
func newDiversityFeatureCounts() diversityFeatureCounts {
|
||||
return diversityFeatureCounts{
|
||||
genres: make(map[int]int),
|
||||
themes: make(map[int]int),
|
||||
demographics: make(map[int]int),
|
||||
studios: make(map[int]int),
|
||||
}
|
||||
}
|
||||
|
||||
func (counts diversityFeatureCounts) add(features diversityFeatureSet) {
|
||||
addDiversityCounts(counts.genres, features.genres)
|
||||
addDiversityCounts(counts.themes, features.themes)
|
||||
addDiversityCounts(counts.demographics, features.demographics)
|
||||
addDiversityCounts(counts.studios, features.studios)
|
||||
}
|
||||
|
||||
func addDiversityCounts(target map[int]int, features map[int]struct{}) {
|
||||
for id := range features {
|
||||
target[id]++
|
||||
}
|
||||
}
|
||||
|
||||
func bestDiverseCandidateIndex(candidates []recommendationCandidate, seen diversityFeatureCounts, recent []diversityFeatureSet) int {
|
||||
bestIndex := 0
|
||||
bestScore := math.Inf(-1)
|
||||
|
||||
for i, candidate := range candidates {
|
||||
score := candidate.score - diversityPenalty(diversityFeatures(candidate.anime), seen, recent)
|
||||
if score == bestScore {
|
||||
if candidate.score <= candidates[bestIndex].score {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if score > bestScore {
|
||||
bestScore = score
|
||||
bestIndex = i
|
||||
}
|
||||
}
|
||||
|
||||
return bestIndex
|
||||
}
|
||||
|
||||
func diversityFeatures(anime jikan.Anime) diversityFeatureSet {
|
||||
return diversityFeatureSet{
|
||||
genres: entityIDSet(anime.Genres),
|
||||
themes: entityIDSet(anime.Themes),
|
||||
demographics: entityIDSet(anime.Demographics),
|
||||
studios: entityIDSet(anime.Studios),
|
||||
}
|
||||
}
|
||||
|
||||
func entityIDSet(entities []jikan.NamedEntity) map[int]struct{} {
|
||||
ids := make(map[int]struct{}, len(entities))
|
||||
for _, entity := range entities {
|
||||
if entity.MalID <= 0 {
|
||||
continue
|
||||
}
|
||||
ids[entity.MalID] = struct{}{}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func diversityPenalty(features diversityFeatureSet, seen diversityFeatureCounts, recent []diversityFeatureSet) float64 {
|
||||
penalty := 0.0
|
||||
penalty += repeatedFeaturePenalty(features.genres, seen.genres, recentGenreCounts(recent), genreDiversityPenalty)
|
||||
penalty += repeatedFeaturePenalty(features.themes, seen.themes, recentThemeCounts(recent), themeDiversityPenalty)
|
||||
penalty += repeatedFeaturePenalty(features.demographics, seen.demographics, recentDemographicCounts(recent), demoDiversityPenalty)
|
||||
penalty += repeatedFeaturePenalty(features.studios, seen.studios, recentStudioCounts(recent), studioDiversityPenalty)
|
||||
|
||||
return penalty
|
||||
}
|
||||
|
||||
func repeatedFeaturePenalty(features map[int]struct{}, seen map[int]int, recent map[int]int, weight float64) float64 {
|
||||
total := 0.0
|
||||
for id := range features {
|
||||
total += float64(seen[id]) * weight * 0.35
|
||||
total += float64(recent[id]) * weight
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func recentGenreCounts(recent []diversityFeatureSet) map[int]int {
|
||||
return recentFeatureCounts(recent, func(features diversityFeatureSet) map[int]struct{} {
|
||||
return features.genres
|
||||
})
|
||||
}
|
||||
|
||||
func recentThemeCounts(recent []diversityFeatureSet) map[int]int {
|
||||
return recentFeatureCounts(recent, func(features diversityFeatureSet) map[int]struct{} {
|
||||
return features.themes
|
||||
})
|
||||
}
|
||||
|
||||
func recentDemographicCounts(recent []diversityFeatureSet) map[int]int {
|
||||
return recentFeatureCounts(recent, func(features diversityFeatureSet) map[int]struct{} {
|
||||
return features.demographics
|
||||
})
|
||||
}
|
||||
|
||||
func recentStudioCounts(recent []diversityFeatureSet) map[int]int {
|
||||
return recentFeatureCounts(recent, func(features diversityFeatureSet) map[int]struct{} {
|
||||
return features.studios
|
||||
})
|
||||
}
|
||||
|
||||
func recentFeatureCounts(
|
||||
recent []diversityFeatureSet,
|
||||
selectFeatures func(diversityFeatureSet) map[int]struct{},
|
||||
) map[int]int {
|
||||
counts := make(map[int]int)
|
||||
for _, features := range recent {
|
||||
addDiversityCounts(counts, selectFeatures(features))
|
||||
}
|
||||
return counts
|
||||
}
|
||||
117
internal/anime/recommendations/scoring.go
Normal file
117
internal/anime/recommendations/scoring.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package recommendations
|
||||
|
||||
import (
|
||||
"mal/integrations/jikan"
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
func profileSearchRankWeight(rank int) float64 {
|
||||
return math.Max(0.35, 1-(float64(rank)*0.08))
|
||||
}
|
||||
|
||||
func rankedCandidateRetrievalScore(collaborativeScore float64, profileSearchScore float64) float64 {
|
||||
return (math.Log1p(collaborativeScore) * collaborativeWeight) +
|
||||
(profileSearchScore * profileSearchWeight)
|
||||
}
|
||||
|
||||
func hasTasteMetadata(anime jikan.Anime) bool {
|
||||
return len(anime.Genres) > 0 ||
|
||||
len(anime.Themes) > 0 ||
|
||||
len(anime.Studios) > 0 ||
|
||||
len(anime.Demographics) > 0
|
||||
}
|
||||
|
||||
func scoreRecommendationCandidate(
|
||||
now time.Time,
|
||||
profile userTasteProfile,
|
||||
candidate jikan.Anime,
|
||||
collaborativeScore float64,
|
||||
profileSearchScore float64,
|
||||
) recommendationCandidate {
|
||||
genres, genreScore := weightedEntityMatch(profile.genres, candidate.Genres)
|
||||
themes, themeScore := weightedEntityMatch(profile.themes, candidate.Themes)
|
||||
studios, studioScore := weightedEntityMatch(profile.studios, candidate.Studios)
|
||||
demos, demoScore := weightedEntityMatch(profile.demographics, candidate.Demographics)
|
||||
|
||||
score := rankedCandidateRetrievalScore(collaborativeScore, profileSearchScore)
|
||||
score += genreScore * genreMatchWeight
|
||||
score += themeScore * themeMatchWeight
|
||||
score += studioScore * studioMatchWeight
|
||||
score += demoScore * demographicMatchWeight
|
||||
score += recommendationCandidateScoreAdjustments(now, profile, candidate)
|
||||
|
||||
return recommendationCandidate{
|
||||
anime: candidate,
|
||||
score: score,
|
||||
genreMatches: genres,
|
||||
themeMatches: themes,
|
||||
studioMatches: studios,
|
||||
demographicMatches: demos,
|
||||
}
|
||||
}
|
||||
|
||||
func recommendationCandidateScoreAdjustments(now time.Time, profile userTasteProfile, candidate jikan.Anime) float64 {
|
||||
var score float64
|
||||
|
||||
if candidate.Score > 0 {
|
||||
score += min(candidate.Score/10.0, 1.0)
|
||||
}
|
||||
if candidate.Popularity > 0 {
|
||||
score += 1.0 / math.Log(float64(candidate.Popularity)+8)
|
||||
}
|
||||
if profile.prefersAiring && candidate.Airing {
|
||||
score += 0.5
|
||||
}
|
||||
if profile.prefersRecent && isRecentCandidate(now, candidate.Year) {
|
||||
score += 0.45
|
||||
}
|
||||
if isClassicCandidate(now, candidate.Year) {
|
||||
score -= 0.2
|
||||
}
|
||||
if candidate.Status == "Not yet aired" {
|
||||
score -= 0.35
|
||||
}
|
||||
if isFreshRelease(now, candidate.Aired.From) {
|
||||
score += 0.3
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
func isRecentCandidate(now time.Time, year int) bool {
|
||||
return year > 0 && now.Year()-year <= 4
|
||||
}
|
||||
|
||||
func isClassicCandidate(now time.Time, year int) bool {
|
||||
return year > 0 && now.Year()-year > 15
|
||||
}
|
||||
|
||||
func isFreshRelease(now time.Time, airedFrom string) bool {
|
||||
if airedFrom == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
airedAt, err := time.Parse(time.RFC3339, airedFrom)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return now.Sub(airedAt) <= freshReleaseWindow
|
||||
}
|
||||
|
||||
func weightedEntityMatch(weights map[int]float64, entities []jikan.NamedEntity) (int, float64) {
|
||||
var matches int
|
||||
var score float64
|
||||
|
||||
for _, entity := range entities {
|
||||
weight, ok := weights[entity.MalID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
matches++
|
||||
score += weight
|
||||
}
|
||||
|
||||
return matches, score
|
||||
}
|
||||
136
internal/anime/recommendations/scoring_profile_extra_test.go
Normal file
136
internal/anime/recommendations/scoring_profile_extra_test.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package recommendations
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"mal/integrations/jikan"
|
||||
)
|
||||
|
||||
func TestProfileSearchRankWeightHasFloor(t *testing.T) {
|
||||
if got := profileSearchRankWeight(0); got != 1 {
|
||||
t.Fatalf("rank 0 weight = %f, want 1", got)
|
||||
}
|
||||
if got := profileSearchRankWeight(100); got != 0.35 {
|
||||
t.Fatalf("rank 100 weight = %f, want floor 0.35", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRankedCandidateRetrievalScoreUsesLogForCollaborativeSignal(t *testing.T) {
|
||||
low := rankedCandidateRetrievalScore(1, 0)
|
||||
high := rankedCandidateRetrievalScore(100, 0)
|
||||
if high <= low {
|
||||
t.Fatalf("expected higher collaborative score to rank higher, low=%f high=%f", low, high)
|
||||
}
|
||||
linearGrowth := 100.0 - 1.0
|
||||
actualGrowth := high - low
|
||||
if actualGrowth >= linearGrowth {
|
||||
t.Fatalf("expected log scaling, growth=%f linear=%f", actualGrowth, linearGrowth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasTasteMetadata(t *testing.T) {
|
||||
if hasTasteMetadata(jikan.Anime{}) {
|
||||
t.Fatalf("empty anime should not have taste metadata")
|
||||
}
|
||||
if !hasTasteMetadata(jikan.Anime{Studios: []jikan.NamedEntity{{MalID: 1}}}) {
|
||||
t.Fatalf("studio metadata should count as taste metadata")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecommendationCandidateScoreAdjustments(t *testing.T) {
|
||||
now := time.Date(2026, time.June, 4, 12, 0, 0, 0, time.UTC)
|
||||
profile := userTasteProfile{prefersAiring: true, prefersRecent: true}
|
||||
|
||||
preferred := recommendationCandidateScoreAdjustments(now, profile, jikan.Anime{
|
||||
Score: 9,
|
||||
Popularity: 10,
|
||||
Airing: true,
|
||||
Year: 2026,
|
||||
Aired: jikan.Aired{From: now.Add(-24 * time.Hour).Format(time.RFC3339)},
|
||||
})
|
||||
penalized := recommendationCandidateScoreAdjustments(now, profile, jikan.Anime{
|
||||
Score: 9,
|
||||
Year: 2000,
|
||||
Status: "Not yet aired",
|
||||
})
|
||||
|
||||
if preferred <= penalized {
|
||||
t.Fatalf("expected preferred candidate to outscore penalized candidate, preferred=%f penalized=%f", preferred, penalized)
|
||||
}
|
||||
if !isRecentCandidate(now, 2024) || isRecentCandidate(now, 2010) {
|
||||
t.Fatalf("recent candidate boundary failed")
|
||||
}
|
||||
if !isClassicCandidate(now, 2010) || isClassicCandidate(now, 2020) {
|
||||
t.Fatalf("classic candidate boundary failed")
|
||||
}
|
||||
if !isFreshRelease(now, now.Add(-freshReleaseWindow+time.Hour).Format(time.RFC3339)) {
|
||||
t.Fatalf("expected fresh release inside window")
|
||||
}
|
||||
if isFreshRelease(now, "not a date") {
|
||||
t.Fatalf("invalid release timestamp should not be fresh")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeightedEntityMatchCountsAndScoresMatches(t *testing.T) {
|
||||
matches, score := weightedEntityMatch(map[int]float64{1: 2.5, 3: 1.0}, []jikan.NamedEntity{
|
||||
{MalID: 1, Name: "Action"},
|
||||
{MalID: 2, Name: "Drama"},
|
||||
{MalID: 3, Name: "Sports"},
|
||||
})
|
||||
|
||||
if matches != 2 || score != 3.5 {
|
||||
t.Fatalf("weightedEntityMatch = matches:%d score:%f, want 2 and 3.5", matches, score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddEntityWeightsSkipsInvalidIDsAndAccumulates(t *testing.T) {
|
||||
target := map[int]float64{1: 1.0}
|
||||
addEntityWeights(target, []jikan.NamedEntity{{MalID: 0}, {MalID: 1}, {MalID: 2}}, 0.5)
|
||||
|
||||
if target[1] != 1.5 || target[2] != 0.5 {
|
||||
t.Fatalf("entity weights = %#v, want accumulated valid ids", target)
|
||||
}
|
||||
if _, ok := target[0]; ok {
|
||||
t.Fatalf("invalid id should not be added: %#v", target)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrongestWeightedEntitiesSortsByWeightThenID(t *testing.T) {
|
||||
got := strongestWeightedEntities(map[int]float64{3: 1, 2: 2, 1: 2, 4: -1}, 3)
|
||||
want := []weightedEntity{{id: 1, weight: 2}, {id: 2, weight: 2}, {id: 3, weight: 1}}
|
||||
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("len(got) = %d, want %d", len(got), len(want))
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("got[%d] = %+v, want %+v", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScoreRecommendationCandidateIncludesMatchCounts(t *testing.T) {
|
||||
now := time.Date(2026, time.June, 4, 12, 0, 0, 0, time.UTC)
|
||||
profile := userTasteProfile{
|
||||
genres: map[int]float64{1: 1, 2: 1},
|
||||
themes: map[int]float64{10: 1},
|
||||
studios: map[int]float64{20: 1},
|
||||
demographics: map[int]float64{30: 1},
|
||||
}
|
||||
|
||||
candidate := scoreRecommendationCandidate(now, profile, jikan.Anime{
|
||||
Genres: []jikan.NamedEntity{{MalID: 1}, {MalID: 2}},
|
||||
Themes: []jikan.NamedEntity{{MalID: 10}},
|
||||
Studios: []jikan.NamedEntity{{MalID: 20}},
|
||||
Demographics: []jikan.NamedEntity{{MalID: 30}},
|
||||
}, 0, 0)
|
||||
|
||||
if candidate.genreMatches != 2 || candidate.themeMatches != 1 || candidate.studioMatches != 1 || candidate.demographicMatches != 1 {
|
||||
t.Fatalf("match counts = genres:%d themes:%d studios:%d demos:%d", candidate.genreMatches, candidate.themeMatches, candidate.studioMatches, candidate.demographicMatches)
|
||||
}
|
||||
if math.Abs(candidate.score) < 0.001 {
|
||||
t.Fatalf("expected non-zero score for metadata matches")
|
||||
}
|
||||
}
|
||||
72
internal/anime/recommendations/store.go
Normal file
72
internal/anime/recommendations/store.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package recommendations
|
||||
|
||||
import (
|
||||
"mal/internal/db"
|
||||
"sort"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type candidateStore struct {
|
||||
watchlistAnimeIDs map[int]struct{}
|
||||
byID map[int]rankedCandidate
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func newCandidateStore(watchlist []db.GetUserWatchListRow) *candidateStore {
|
||||
watched := make(map[int]struct{}, len(watchlist))
|
||||
for _, entry := range watchlist {
|
||||
if entry.AnimeID <= 0 {
|
||||
continue
|
||||
}
|
||||
watched[int(entry.AnimeID)] = struct{}{}
|
||||
}
|
||||
|
||||
return &candidateStore{
|
||||
watchlistAnimeIDs: watched,
|
||||
byID: map[int]rankedCandidate{},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *candidateStore) upsert(candidate rankedCandidate) {
|
||||
if candidate.id <= 0 {
|
||||
return
|
||||
}
|
||||
if _, exists := s.watchlistAnimeIDs[candidate.id]; exists {
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
current, ok := s.byID[candidate.id]
|
||||
if !ok {
|
||||
s.byID[candidate.id] = candidate
|
||||
return
|
||||
}
|
||||
|
||||
current.collaborativeScore += candidate.collaborativeScore
|
||||
current.profileSearchScore += candidate.profileSearchScore
|
||||
if candidate.hasAnime {
|
||||
current.anime = candidate.anime
|
||||
current.hasAnime = true
|
||||
}
|
||||
s.byID[candidate.id] = current
|
||||
}
|
||||
|
||||
func (s *candidateStore) ranked() []rankedCandidate {
|
||||
ranked := make([]rankedCandidate, 0, len(s.byID))
|
||||
for _, item := range s.byID {
|
||||
ranked = append(ranked, item)
|
||||
}
|
||||
|
||||
sort.Slice(ranked, func(i, j int) bool {
|
||||
left := rankedCandidateRetrievalScore(ranked[i].collaborativeScore, ranked[i].profileSearchScore)
|
||||
right := rankedCandidateRetrievalScore(ranked[j].collaborativeScore, ranked[j].profileSearchScore)
|
||||
if left == right {
|
||||
return ranked[i].id < ranked[j].id
|
||||
}
|
||||
return left > right
|
||||
})
|
||||
|
||||
return ranked
|
||||
}
|
||||
45
internal/anime/recommendations/types.go
Normal file
45
internal/anime/recommendations/types.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package recommendations
|
||||
|
||||
import "mal/integrations/jikan"
|
||||
|
||||
type recommendationSeed struct {
|
||||
animeID int
|
||||
weight float64
|
||||
}
|
||||
|
||||
type weightedEntity struct {
|
||||
id int
|
||||
weight float64
|
||||
}
|
||||
|
||||
type profileSearchQuery struct {
|
||||
genreIDs []int
|
||||
studioID int
|
||||
weight float64
|
||||
}
|
||||
|
||||
type recommendationCandidate struct {
|
||||
anime jikan.Anime
|
||||
score float64
|
||||
genreMatches int
|
||||
themeMatches int
|
||||
studioMatches int
|
||||
demographicMatches int
|
||||
}
|
||||
|
||||
type userTasteProfile struct {
|
||||
genres map[int]float64
|
||||
themes map[int]float64
|
||||
studios map[int]float64
|
||||
demographics map[int]float64
|
||||
prefersAiring bool
|
||||
prefersRecent bool
|
||||
}
|
||||
|
||||
type rankedCandidate struct {
|
||||
id int
|
||||
collaborativeScore float64
|
||||
profileSearchScore float64
|
||||
anime jikan.Anime
|
||||
hasAnime bool
|
||||
}
|
||||
@@ -15,14 +15,19 @@ type reviewsQuery struct {
|
||||
}
|
||||
|
||||
func parseReviewsQuery(c *gin.Context) (reviewsQuery, error) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
return reviewsQuery{}, fmt.Errorf("invalid anime id")
|
||||
rawID := c.Param("id")
|
||||
id, err := strconv.Atoi(rawID)
|
||||
if err != nil {
|
||||
return reviewsQuery{}, fmt.Errorf("invalid anime id %q: %w", rawID, err)
|
||||
}
|
||||
if id <= 0 {
|
||||
return reviewsQuery{}, fmt.Errorf("invalid anime id %d", id)
|
||||
}
|
||||
|
||||
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
rawPage := c.DefaultQuery("page", "1")
|
||||
page, err := strconv.Atoi(rawPage)
|
||||
if err != nil {
|
||||
return reviewsQuery{}, fmt.Errorf("invalid page")
|
||||
return reviewsQuery{}, fmt.Errorf("invalid page %q: %w", rawPage, err)
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
|
||||
80
internal/anime/search_api.go
Normal file
80
internal/anime/search_api.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package anime
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"mal/internal/server"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const searchAnimeLimit = 24
|
||||
|
||||
type searchItem struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Label string `json:"label"`
|
||||
Subtitle string `json:"subtitle"`
|
||||
Href string `json:"href"`
|
||||
Image string `json:"image,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
InWatchlist bool `json:"inWatchlist,omitempty"`
|
||||
}
|
||||
|
||||
type searchResponse struct {
|
||||
Items []searchItem `json:"items"`
|
||||
HasNextPage bool `json:"hasNextPage"`
|
||||
NextPage int `json:"nextPage,omitempty"`
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleSearchAPI(c *gin.Context) {
|
||||
user := server.CurrentUser(c)
|
||||
if user == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
query := strings.TrimSpace(c.Query("q"))
|
||||
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if err != nil || page < 1 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid page"})
|
||||
return
|
||||
}
|
||||
|
||||
if query == "" || len(query) < 2 {
|
||||
c.JSON(http.StatusOK, searchResponse{})
|
||||
return
|
||||
}
|
||||
|
||||
items, hasNextPage := h.searchAnimeResults(c, user.ID, query, page)
|
||||
c.JSON(http.StatusOK, searchResponse{
|
||||
Items: items,
|
||||
HasNextPage: hasNextPage,
|
||||
NextPage: page + 1,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) searchAnimeResults(c *gin.Context, userID string, query string, page int) ([]searchItem, bool) {
|
||||
res, err := h.svc.SearchAdvanced(c.Request.Context(), query, "", "", "", "", nil, 0, true, page, searchAnimeLimit)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
animes := wrapAnimes(res.Animes)
|
||||
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, animes)
|
||||
items := make([]searchItem, 0, len(animes))
|
||||
for _, anime := range animes {
|
||||
items = append(items, searchItem{
|
||||
ID: fmt.Sprintf("anime:%d", anime.MalID),
|
||||
Type: "anime",
|
||||
Label: anime.DisplayTitle(),
|
||||
Subtitle: strings.TrimSpace("Anime " + anime.Type),
|
||||
Href: fmt.Sprintf("/anime/%d", anime.MalID),
|
||||
Image: anime.Images.Webp.LargeImageURL,
|
||||
InWatchlist: watchlistMap[int64(anime.MalID)],
|
||||
})
|
||||
}
|
||||
return items, res.HasNextPage
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package anime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"mal/integrations/jikan"
|
||||
"mal/internal/db"
|
||||
"mal/internal/domain"
|
||||
@@ -46,19 +47,25 @@ func (s *animeService) GetCatalogSection(ctx context.Context, userID string, sec
|
||||
case "Popular":
|
||||
res, err = s.jikan.GetTopAnime(gCtx, 1)
|
||||
}
|
||||
return err
|
||||
if err != nil {
|
||||
return fmt.Errorf("get catalog section %q: %w", section, err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if userID != "" && section == "Continue" {
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
cw, err = s.repo.GetContinueWatchingEntries(gCtx, userID)
|
||||
return err
|
||||
if err != nil {
|
||||
return fmt.Errorf("get continue watching entries for %q: %w", userID, err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return domain.CatalogSectionData{}, err
|
||||
return domain.CatalogSectionData{}, fmt.Errorf("wait for catalog section %q: %w", section, err)
|
||||
}
|
||||
|
||||
animes := wrapAnimes(res.Animes)
|
||||
@@ -75,7 +82,7 @@ func (s *animeService) GetCatalogSection(ctx context.Context, userID string, sec
|
||||
func (s *animeService) GetAnimeByID(ctx context.Context, id int) (domain.Anime, error) {
|
||||
anime, err := s.jikan.GetAnimeByID(ctx, id)
|
||||
if err != nil {
|
||||
return domain.Anime{}, err
|
||||
return domain.Anime{}, fmt.Errorf("get anime by id: %w", err)
|
||||
}
|
||||
return domain.Anime{Anime: anime}, nil
|
||||
}
|
||||
@@ -87,7 +94,7 @@ func (s *animeService) SearchAdvanced(ctx context.Context, q, animeType, status,
|
||||
func (s *animeService) GetProducerNameByID(ctx context.Context, id int) (string, error) {
|
||||
res, err := s.jikan.GetProducerByID(ctx, id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", fmt.Errorf("get producer name: %w", err)
|
||||
}
|
||||
for _, t := range res.Data.Titles {
|
||||
if t.Title != "" {
|
||||
@@ -104,7 +111,7 @@ func (s *animeService) GetProducers(ctx context.Context, query string, page int,
|
||||
func (s *animeService) GetGenres(ctx context.Context) ([]domain.Genre, error) {
|
||||
genres, err := s.jikan.GetAnimeGenres(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("get genres: %w", err)
|
||||
}
|
||||
out := make([]domain.Genre, 0, len(genres))
|
||||
for _, g := range genres {
|
||||
@@ -119,7 +126,7 @@ func (s *animeService) GetGenres(ctx context.Context) ([]domain.Genre, error) {
|
||||
func (s *animeService) GetCharacters(ctx context.Context, id int) ([]domain.CharacterEntry, error) {
|
||||
items, err := s.jikan.GetAnimeCharacters(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("get characters: %w", err)
|
||||
}
|
||||
|
||||
out := make([]domain.CharacterEntry, 0, len(items))
|
||||
@@ -155,7 +162,7 @@ func (s *animeService) GetCharacters(ctx context.Context, id int) ([]domain.Char
|
||||
func (s *animeService) GetRecommendations(ctx context.Context, id int) ([]domain.RecommendationEntry, error) {
|
||||
items, err := s.jikan.GetAnimeRecommendations(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("get recommendations: %w", err)
|
||||
}
|
||||
|
||||
out := make([]domain.RecommendationEntry, 0, len(items))
|
||||
@@ -188,7 +195,7 @@ func (s *animeService) GetEpisodes(ctx context.Context, id int, page int) (jikan
|
||||
func (s *animeService) GetStaff(ctx context.Context, id int) ([]domain.StaffEntry, error) {
|
||||
items, err := s.jikan.GetAnimeStaff(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("get staff: %w", err)
|
||||
}
|
||||
|
||||
out := make([]domain.StaffEntry, 0, len(items))
|
||||
@@ -207,7 +214,7 @@ func (s *animeService) GetStaff(ctx context.Context, id int) ([]domain.StaffEntr
|
||||
func (s *animeService) GetStatistics(ctx context.Context, id int) (domain.Statistics, error) {
|
||||
stats, err := s.jikan.GetAnimeStatistics(ctx, id)
|
||||
if err != nil {
|
||||
return domain.Statistics{}, err
|
||||
return domain.Statistics{}, fmt.Errorf("get statistics: %w", err)
|
||||
}
|
||||
|
||||
out := domain.Statistics{
|
||||
@@ -230,7 +237,7 @@ func (s *animeService) GetStatistics(ctx context.Context, id int) (domain.Statis
|
||||
func (s *animeService) GetThemes(ctx context.Context, id int) (domain.ThemesData, error) {
|
||||
themes, err := s.jikan.GetAnimeThemes(ctx, id)
|
||||
if err != nil {
|
||||
return domain.ThemesData{}, err
|
||||
return domain.ThemesData{}, fmt.Errorf("get themes: %w", err)
|
||||
}
|
||||
return domain.ThemesData{
|
||||
Openings: append([]string(nil), themes.Openings...),
|
||||
@@ -241,7 +248,7 @@ func (s *animeService) GetThemes(ctx context.Context, id int) (domain.ThemesData
|
||||
func (s *animeService) GetReviews(ctx context.Context, id int, page int) ([]domain.ReviewEntry, bool, error) {
|
||||
data, pag, err := s.jikan.GetAnimeReviews(ctx, id, page)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
return nil, false, fmt.Errorf("get reviews: %w", err)
|
||||
}
|
||||
out := make([]domain.ReviewEntry, 0, len(data))
|
||||
for _, it := range data {
|
||||
@@ -299,13 +306,13 @@ func (s *animeService) GetRandomAnime(ctx context.Context) (domain.Anime, error)
|
||||
return domain.Anime{Anime: res.Animes[r.Intn(len(res.Animes))]}, nil
|
||||
}
|
||||
|
||||
return domain.Anime{}, err
|
||||
return domain.Anime{}, fmt.Errorf("get random anime: %w", err)
|
||||
}
|
||||
|
||||
func (s *animeService) GetAllEpisodes(ctx context.Context, id int) ([]domain.EpisodeData, error) {
|
||||
episodes, err := s.jikan.GetAllEpisodes(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("get all episodes: %w", err)
|
||||
}
|
||||
result := make([]domain.EpisodeData, len(episodes))
|
||||
for i, ep := range episodes {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// Package app bootstraps and wires the application dependencies.
|
||||
package app
|
||||
package internal
|
||||
|
||||
import (
|
||||
"mal/integrations/jikan"
|
||||
@@ -36,6 +35,7 @@ func NewApp() *fx.App {
|
||||
playback.Module,
|
||||
templates.Module,
|
||||
server.Module,
|
||||
fx.Invoke(RunMigrationsAndFixes),
|
||||
fx.Provide(func(r *templates.Renderer) render.HTMLRender {
|
||||
return r
|
||||
}),
|
||||
@@ -56,14 +56,24 @@ func openTestDB(t *testing.T) *sql.DB {
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTemp: %v", err)
|
||||
}
|
||||
_ = tmp.Close()
|
||||
t.Cleanup(func() { _ = os.Remove(tmp.Name()) })
|
||||
if err := tmp.Close(); err != nil {
|
||||
t.Fatalf("close temp db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := os.Remove(tmp.Name()); err != nil {
|
||||
t.Errorf("remove temp db: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
sqlDB, err := db.Open(tmp.Name())
|
||||
if err != nil {
|
||||
t.Fatalf("db.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
t.Cleanup(func() {
|
||||
if err := sqlDB.Close(); err != nil {
|
||||
t.Errorf("close sqlite: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
if err := database.RunMigrations(sqlDB); err != nil {
|
||||
t.Fatalf("RunMigrations: %v", err)
|
||||
@@ -87,7 +97,11 @@ func queryAuditRow(t *testing.T, sqlDB *sql.DB, userID string) auditRow {
|
||||
if err != nil {
|
||||
t.Fatalf("Query: %v", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
defer func() {
|
||||
if err := rows.Close(); err != nil {
|
||||
t.Errorf("close audit rows: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if !rows.Next() {
|
||||
t.Fatalf("expected audit row")
|
||||
|
||||
@@ -3,6 +3,7 @@ package auth
|
||||
|
||||
import (
|
||||
"mal/internal/domain"
|
||||
"mal/internal/observability"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -54,7 +55,9 @@ func (h *AuthHandler) HandleLogin(c *gin.Context) {
|
||||
func (h *AuthHandler) HandleLogout(c *gin.Context) {
|
||||
sessionID, err := c.Cookie("session_id")
|
||||
if err == nil {
|
||||
_ = h.svc.Logout(c.Request.Context(), sessionID)
|
||||
if err := h.svc.Logout(c.Request.Context(), sessionID); err != nil {
|
||||
observability.WarnContext(c.Request.Context(), "logout_failed", "auth", "", nil, err)
|
||||
}
|
||||
}
|
||||
|
||||
c.SetCookie("session_id", "", -1, "/", "", false, true)
|
||||
|
||||
255
internal/auth/handler_middleware_test.go
Normal file
255
internal/auth/handler_middleware_test.go
Normal file
@@ -0,0 +1,255 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"mal/internal/db"
|
||||
"mal/internal/domain"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestHandleAPILogin(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
svc := &fakeAuthService{
|
||||
apiToken: "token-1",
|
||||
apiUser: &domain.User{User: db.User{ID: "user-1", Username: "alice", AvatarUrl: "avatar.png"}},
|
||||
}
|
||||
router := gin.New()
|
||||
NewAuthHandler(svc).Register(router)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/api/auth/login", strings.NewReader(`{"username":"alice","password":"correct","name":"phone"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), `"token":"token-1"`) {
|
||||
t.Fatalf("response missing token: %s", rec.Body.String())
|
||||
}
|
||||
if svc.apiLoginName != "phone" {
|
||||
t.Fatalf("api token name = %q, want phone", svc.apiLoginName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAPILoginRejectsInvalidRequests(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
loginErr error
|
||||
wantStatus int
|
||||
}{
|
||||
{name: "bad json", body: `{`, wantStatus: http.StatusBadRequest},
|
||||
{name: "missing password", body: `{"username":"alice"}`, wantStatus: http.StatusBadRequest},
|
||||
{name: "bad credentials", body: `{"username":"alice","password":"wrong"}`, loginErr: ErrWrongPassword, wantStatus: http.StatusUnauthorized},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
svc := &fakeAuthService{apiLoginErr: tt.loginErr}
|
||||
router := gin.New()
|
||||
NewAuthHandler(svc).Register(router)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/api/auth/login", strings.NewReader(tt.body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != tt.wantStatus {
|
||||
t.Fatalf("status = %d, want %d; body=%s", rec.Code, tt.wantStatus, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMiddlewareAllowsPublicRoutes(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
svc := &fakeAuthService{}
|
||||
router := gin.New()
|
||||
router.Use(AuthMiddleware(svc))
|
||||
router.GET("/static/app.js", func(c *gin.Context) { c.String(http.StatusOK, "asset") })
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/static/app.js", nil)
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
if svc.validateSessionCalled || svc.validateAPITokenCalled {
|
||||
t.Fatalf("public route should not authenticate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMiddlewareAuthenticatesAPIBearerToken(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
svc := &fakeAuthService{user: &domain.User{User: db.User{ID: "user-1", Username: "alice"}}}
|
||||
router := gin.New()
|
||||
router.Use(AuthMiddleware(svc))
|
||||
router.GET("/api/me", func(c *gin.Context) {
|
||||
user, _ := c.Get("User")
|
||||
if user.(*domain.User).ID != "user-1" {
|
||||
c.Status(http.StatusTeapot)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/me", nil)
|
||||
req.Header.Set("Authorization", "Bearer api-token")
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
if svc.validatedAPIToken != "api-token" {
|
||||
t.Fatalf("validated api token = %q, want api-token", svc.validatedAPIToken)
|
||||
}
|
||||
if svc.refreshSessionCalled {
|
||||
t.Fatalf("bearer token auth should not refresh cookie session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMiddlewareAuthenticatesCookieSessionAndRefreshes(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
svc := &fakeAuthService{user: &domain.User{User: db.User{ID: "user-1", Username: "alice"}}}
|
||||
router := gin.New()
|
||||
router.Use(AuthMiddleware(svc))
|
||||
router.GET("/", func(c *gin.Context) { c.Status(http.StatusOK) })
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "session_id", Value: "session-1"})
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
if svc.validatedSessionID != "session-1" {
|
||||
t.Fatalf("validated session id = %q, want session-1", svc.validatedSessionID)
|
||||
}
|
||||
if svc.refreshedSessionID != "session-1" {
|
||||
t.Fatalf("refreshed session id = %q, want session-1", svc.refreshedSessionID)
|
||||
}
|
||||
if got := rec.Header().Values("Set-Cookie"); len(got) == 0 || !strings.Contains(got[0], "session_id=session-1") {
|
||||
t.Fatalf("Set-Cookie = %v, want refreshed session cookie", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMiddlewareRejectsUnauthenticatedRequests(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
wantStatus int
|
||||
wantHeader string
|
||||
}{
|
||||
{name: "api", method: http.MethodGet, path: "/api/me", wantStatus: http.StatusUnauthorized},
|
||||
{name: "page", method: http.MethodGet, path: "/", wantStatus: http.StatusSeeOther, wantHeader: "/login"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
router := gin.New()
|
||||
router.Use(AuthMiddleware(&fakeAuthService{validateErr: errors.New("no auth")}))
|
||||
router.Handle(tt.method, tt.path, func(c *gin.Context) { c.Status(http.StatusOK) })
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequestWithContext(context.Background(), tt.method, tt.path, nil)
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != tt.wantStatus {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, tt.wantStatus)
|
||||
}
|
||||
if tt.wantHeader != "" && rec.Header().Get("Location") != tt.wantHeader {
|
||||
t.Fatalf("Location = %q, want %q", rec.Header().Get("Location"), tt.wantHeader)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type fakeAuthService struct {
|
||||
user *domain.User
|
||||
|
||||
apiToken string
|
||||
apiUser *domain.User
|
||||
|
||||
loginErr error
|
||||
apiLoginErr error
|
||||
validateErr error
|
||||
|
||||
apiLoginName string
|
||||
validatedSessionID string
|
||||
validatedAPIToken string
|
||||
refreshedSessionID string
|
||||
loggedOutSessionID string
|
||||
validateSessionCalled bool
|
||||
validateAPITokenCalled bool
|
||||
refreshSessionCalled bool
|
||||
revokedAPITokensForUser string
|
||||
}
|
||||
|
||||
func (s *fakeAuthService) Login(_ context.Context, _, _ string) (*domain.Session, error) {
|
||||
if s.loginErr != nil {
|
||||
return nil, s.loginErr
|
||||
}
|
||||
return &domain.Session{Session: db.Session{ID: "session-1", UserID: "user-1"}}, nil
|
||||
}
|
||||
|
||||
func (s *fakeAuthService) LoginForAPIToken(_ context.Context, _, _, name string) (string, *domain.User, error) {
|
||||
s.apiLoginName = name
|
||||
if s.apiLoginErr != nil {
|
||||
return "", nil, s.apiLoginErr
|
||||
}
|
||||
return s.apiToken, s.apiUser, nil
|
||||
}
|
||||
|
||||
func (s *fakeAuthService) ValidateSession(_ context.Context, sessionID string) (*domain.User, error) {
|
||||
s.validateSessionCalled = true
|
||||
s.validatedSessionID = sessionID
|
||||
if s.validateErr != nil {
|
||||
return nil, s.validateErr
|
||||
}
|
||||
return s.user, nil
|
||||
}
|
||||
|
||||
func (s *fakeAuthService) RefreshSession(_ context.Context, sessionID string) error {
|
||||
s.refreshSessionCalled = true
|
||||
s.refreshedSessionID = sessionID
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fakeAuthService) ValidateAPIToken(_ context.Context, token string) (*domain.User, error) {
|
||||
s.validateAPITokenCalled = true
|
||||
s.validatedAPIToken = token
|
||||
if s.validateErr != nil {
|
||||
return nil, s.validateErr
|
||||
}
|
||||
return s.user, nil
|
||||
}
|
||||
|
||||
func (s *fakeAuthService) Logout(_ context.Context, sessionID string) error {
|
||||
s.loggedOutSessionID = sessionID
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fakeAuthService) RevokeAllAPITokensForUser(_ context.Context, userID string) error {
|
||||
s.revokedAPITokensForUser = userID
|
||||
return nil
|
||||
}
|
||||
@@ -24,9 +24,6 @@ var publicRoutes = []publicRoute{
|
||||
{path: "/static", prefix: true},
|
||||
{path: "/dist", prefix: true},
|
||||
|
||||
// Observability endpoints.
|
||||
{method: http.MethodGet, path: "/metrics"},
|
||||
|
||||
// Auth API.
|
||||
{method: http.MethodPost, path: "/api/auth/login"},
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"mal/internal/domain"
|
||||
"mal/internal/observability"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -17,6 +18,11 @@ import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUserNotFound = fmt.Errorf("user not found")
|
||||
ErrWrongPassword = fmt.Errorf("wrong password")
|
||||
)
|
||||
|
||||
type authService struct {
|
||||
repo domain.AuthRepository
|
||||
auditSvc domain.AuditService
|
||||
@@ -32,11 +38,11 @@ func (s *authService) Login(ctx context.Context, username, password string) (*do
|
||||
return nil, err
|
||||
}
|
||||
if user == nil {
|
||||
return nil, errors.New("invalid credentials")
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
|
||||
return nil, errors.New("invalid credentials")
|
||||
return nil, ErrWrongPassword
|
||||
}
|
||||
|
||||
sessionID := uuid.New().String()
|
||||
@@ -49,11 +55,11 @@ func (s *authService) LoginForAPIToken(ctx context.Context, username, password,
|
||||
return "", nil, err
|
||||
}
|
||||
if user == nil {
|
||||
return "", nil, errors.New("invalid credentials")
|
||||
return "", nil, ErrUserNotFound
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
|
||||
return "", nil, errors.New("invalid credentials")
|
||||
return "", nil, ErrWrongPassword
|
||||
}
|
||||
|
||||
trimmedName := strings.TrimSpace(name)
|
||||
@@ -69,22 +75,25 @@ func (s *authService) LoginForAPIToken(ctx context.Context, username, password,
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
metadataBytes, err := json.Marshal(struct {
|
||||
event := domain.AuditEvent{
|
||||
UserID: user.ID,
|
||||
Action: "api_token_created",
|
||||
ResourceType: "api_token",
|
||||
}
|
||||
metadataBytes, marshalErr := json.Marshal(struct {
|
||||
Name string `json:"name"`
|
||||
}{Name: trimmedName})
|
||||
if err == nil {
|
||||
_ = s.auditSvc.Record(ctx, domain.AuditEvent{
|
||||
UserID: user.ID,
|
||||
Action: "api_token_created",
|
||||
ResourceType: "api_token",
|
||||
MetadataJSON: metadataBytes,
|
||||
})
|
||||
} else {
|
||||
_ = s.auditSvc.Record(ctx, domain.AuditEvent{
|
||||
UserID: user.ID,
|
||||
Action: "api_token_created",
|
||||
ResourceType: "api_token",
|
||||
})
|
||||
if marshalErr == nil {
|
||||
event.MetadataJSON = metadataBytes
|
||||
}
|
||||
if err := s.auditSvc.Record(ctx, event); err != nil {
|
||||
observability.Warn(
|
||||
"audit_record_failed",
|
||||
"auth",
|
||||
"",
|
||||
map[string]any{"user_id": user.ID, "action": "api_token_created"},
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
return rawToken, user, nil
|
||||
@@ -100,7 +109,15 @@ func (s *authService) ValidateSession(ctx context.Context, sessionID string) (*d
|
||||
}
|
||||
|
||||
if session.ExpiresAt.Before(time.Now()) {
|
||||
_ = s.repo.DeleteSession(ctx, sessionID)
|
||||
if err := s.repo.DeleteSession(ctx, sessionID); err != nil {
|
||||
observability.Warn(
|
||||
"delete_expired_session_failed",
|
||||
"auth",
|
||||
"",
|
||||
map[string]any{"session_id": sessionID},
|
||||
err,
|
||||
)
|
||||
}
|
||||
return nil, errors.New("session expired")
|
||||
}
|
||||
|
||||
@@ -132,7 +149,15 @@ func (s *authService) ValidateAPIToken(ctx context.Context, token string) (*doma
|
||||
return nil, errors.New("token not found")
|
||||
}
|
||||
|
||||
_ = s.repo.TouchAPITokenLastUsedAt(ctx, t.ID)
|
||||
if err := s.repo.TouchAPITokenLastUsedAt(ctx, t.ID); err != nil {
|
||||
observability.Warn(
|
||||
"touch_api_token_last_used_at_failed",
|
||||
"auth",
|
||||
"",
|
||||
map[string]any{"token_id": t.ID},
|
||||
err,
|
||||
)
|
||||
}
|
||||
return s.repo.GetUserByID(ctx, t.UserID)
|
||||
}
|
||||
|
||||
@@ -147,11 +172,19 @@ func (s *authService) RevokeAllAPITokensForUser(ctx context.Context, userID stri
|
||||
if err := s.repo.RevokeAllAPITokensForUser(ctx, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = s.auditSvc.Record(ctx, domain.AuditEvent{
|
||||
if err := s.auditSvc.Record(ctx, domain.AuditEvent{
|
||||
UserID: userID,
|
||||
Action: "api_token_revoked_all",
|
||||
ResourceType: "api_token",
|
||||
})
|
||||
}); err != nil {
|
||||
observability.Warn(
|
||||
"audit_record_failed",
|
||||
"auth",
|
||||
"",
|
||||
map[string]any{"user_id": userID, "action": "api_token_revoked_all"},
|
||||
err,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
243
internal/auth/service_test.go
Normal file
243
internal/auth/service_test.go
Normal file
@@ -0,0 +1,243 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"mal/internal/db"
|
||||
"mal/internal/domain"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestAuthServiceLogin(t *testing.T) {
|
||||
passwordHash := hashPassword(t, "correct")
|
||||
repo := &fakeAuthRepository{
|
||||
usersByUsername: map[string]*domain.User{
|
||||
"alice": {User: db.User{ID: "user-1", Username: "alice", PasswordHash: passwordHash}},
|
||||
},
|
||||
}
|
||||
svc := NewAuthService(repo, &fakeAuditService{})
|
||||
|
||||
session, err := svc.Login(context.Background(), "alice", "correct")
|
||||
if err != nil {
|
||||
t.Fatalf("Login: %v", err)
|
||||
}
|
||||
if session.UserID != "user-1" {
|
||||
t.Fatalf("session user id = %q, want %q", session.UserID, "user-1")
|
||||
}
|
||||
if session.ID == "" {
|
||||
t.Fatalf("expected generated session id")
|
||||
}
|
||||
if repo.createdSessionUserID != "user-1" {
|
||||
t.Fatalf("created session user id = %q, want user-1", repo.createdSessionUserID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthServiceLoginRejectsMissingUserAndWrongPassword(t *testing.T) {
|
||||
passwordHash := hashPassword(t, "correct")
|
||||
repo := &fakeAuthRepository{
|
||||
usersByUsername: map[string]*domain.User{
|
||||
"alice": {User: db.User{ID: "user-1", Username: "alice", PasswordHash: passwordHash}},
|
||||
},
|
||||
}
|
||||
svc := NewAuthService(repo, &fakeAuditService{})
|
||||
|
||||
if _, err := svc.Login(context.Background(), "missing", "correct"); !errors.Is(err, ErrUserNotFound) {
|
||||
t.Fatalf("missing user error = %v, want %v", err, ErrUserNotFound)
|
||||
}
|
||||
if _, err := svc.Login(context.Background(), "alice", "wrong"); !errors.Is(err, ErrWrongPassword) {
|
||||
t.Fatalf("wrong password error = %v, want %v", err, ErrWrongPassword)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthServiceValidateSession(t *testing.T) {
|
||||
repo := &fakeAuthRepository{
|
||||
usersByID: map[string]*domain.User{
|
||||
"user-1": {User: db.User{ID: "user-1", Username: "alice"}},
|
||||
},
|
||||
sessions: map[string]*domain.Session{
|
||||
"fresh": {Session: db.Session{ID: "fresh", UserID: "user-1", ExpiresAt: time.Now().Add(time.Hour)}},
|
||||
"expired": {Session: db.Session{ID: "expired", UserID: "user-1", ExpiresAt: time.Now().Add(-time.Hour)}},
|
||||
},
|
||||
}
|
||||
svc := NewAuthService(repo, &fakeAuditService{})
|
||||
|
||||
user, err := svc.ValidateSession(context.Background(), "fresh")
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateSession fresh: %v", err)
|
||||
}
|
||||
if user == nil || user.ID != "user-1" {
|
||||
t.Fatalf("validated user = %#v, want user-1", user)
|
||||
}
|
||||
|
||||
if _, err := svc.ValidateSession(context.Background(), "expired"); err == nil || err.Error() != "session expired" {
|
||||
t.Fatalf("expired session error = %v, want session expired", err)
|
||||
}
|
||||
if !repo.deletedSessions["expired"] {
|
||||
t.Fatalf("expected expired session to be deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthServiceLoginForAPITokenCreatesTokenAndAuditEvent(t *testing.T) {
|
||||
passwordHash := hashPassword(t, "correct")
|
||||
repo := &fakeAuthRepository{
|
||||
usersByUsername: map[string]*domain.User{
|
||||
"alice": {User: db.User{ID: "user-1", Username: "alice", PasswordHash: passwordHash}},
|
||||
},
|
||||
}
|
||||
auditSvc := &fakeAuditService{}
|
||||
svc := NewAuthService(repo, auditSvc)
|
||||
|
||||
token, user, err := svc.LoginForAPIToken(context.Background(), "alice", "correct", " phone ")
|
||||
if err != nil {
|
||||
t.Fatalf("LoginForAPIToken: %v", err)
|
||||
}
|
||||
if token == "" {
|
||||
t.Fatalf("expected raw token")
|
||||
}
|
||||
if user == nil || user.ID != "user-1" {
|
||||
t.Fatalf("user = %#v, want user-1", user)
|
||||
}
|
||||
if repo.createdAPITokenName != "phone" {
|
||||
t.Fatalf("api token name = %q, want phone", repo.createdAPITokenName)
|
||||
}
|
||||
if repo.createdAPITokenHash == "" || repo.createdAPITokenHash == token {
|
||||
t.Fatalf("expected stored token hash, got %q", repo.createdAPITokenHash)
|
||||
}
|
||||
if len(auditSvc.events) != 1 || auditSvc.events[0].Action != "api_token_created" {
|
||||
t.Fatalf("audit events = %#v, want api_token_created", auditSvc.events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthServiceValidateAPIToken(t *testing.T) {
|
||||
rawToken := "secret-token"
|
||||
sum := sha256.Sum256([]byte(rawToken))
|
||||
tokenHash := hex.EncodeToString(sum[:])
|
||||
repo := &fakeAuthRepository{
|
||||
usersByID: map[string]*domain.User{
|
||||
"user-1": {User: db.User{ID: "user-1", Username: "alice"}},
|
||||
},
|
||||
apiTokensByHash: map[string]*domain.APIToken{
|
||||
tokenHash: {ApiToken: db.ApiToken{ID: "token-1", UserID: "user-1", TokenHash: tokenHash}},
|
||||
},
|
||||
}
|
||||
svc := NewAuthService(repo, &fakeAuditService{})
|
||||
|
||||
user, err := svc.ValidateAPIToken(context.Background(), " "+rawToken+" ")
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateAPIToken: %v", err)
|
||||
}
|
||||
if user == nil || user.ID != "user-1" {
|
||||
t.Fatalf("user = %#v, want user-1", user)
|
||||
}
|
||||
if repo.touchedTokenID != "token-1" {
|
||||
t.Fatalf("touched token id = %q, want token-1", repo.touchedTokenID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthServiceRevokeAllAPITokensForUser(t *testing.T) {
|
||||
repo := &fakeAuthRepository{}
|
||||
auditSvc := &fakeAuditService{}
|
||||
svc := NewAuthService(repo, auditSvc)
|
||||
|
||||
if err := svc.RevokeAllAPITokensForUser(context.Background(), "user-1"); err != nil {
|
||||
t.Fatalf("RevokeAllAPITokensForUser: %v", err)
|
||||
}
|
||||
if repo.revokedUserID != "user-1" {
|
||||
t.Fatalf("revoked user id = %q, want user-1", repo.revokedUserID)
|
||||
}
|
||||
if len(auditSvc.events) != 1 || auditSvc.events[0].Action != "api_token_revoked_all" {
|
||||
t.Fatalf("audit events = %#v, want api_token_revoked_all", auditSvc.events)
|
||||
}
|
||||
|
||||
if err := svc.RevokeAllAPITokensForUser(context.Background(), " "); err == nil || err.Error() != "user id missing" {
|
||||
t.Fatalf("blank user id error = %v, want user id missing", err)
|
||||
}
|
||||
}
|
||||
|
||||
func hashPassword(t *testing.T, password string) string {
|
||||
t.Helper()
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateFromPassword: %v", err)
|
||||
}
|
||||
return string(hash)
|
||||
}
|
||||
|
||||
type fakeAuthRepository struct {
|
||||
usersByUsername map[string]*domain.User
|
||||
usersByID map[string]*domain.User
|
||||
sessions map[string]*domain.Session
|
||||
apiTokensByHash map[string]*domain.APIToken
|
||||
|
||||
createdSessionUserID string
|
||||
createdAPITokenHash string
|
||||
createdAPITokenName string
|
||||
touchedTokenID string
|
||||
revokedUserID string
|
||||
deletedSessions map[string]bool
|
||||
}
|
||||
|
||||
func (r *fakeAuthRepository) GetUserByUsername(_ context.Context, username string) (*domain.User, error) {
|
||||
return r.usersByUsername[username], nil
|
||||
}
|
||||
|
||||
func (r *fakeAuthRepository) GetUserByID(_ context.Context, id string) (*domain.User, error) {
|
||||
return r.usersByID[id], nil
|
||||
}
|
||||
|
||||
func (r *fakeAuthRepository) CreateSession(_ context.Context, userID string, sessionID string) (*domain.Session, error) {
|
||||
r.createdSessionUserID = userID
|
||||
return &domain.Session{Session: db.Session{ID: sessionID, UserID: userID, ExpiresAt: time.Now().Add(domain.SessionLifetime)}}, nil
|
||||
}
|
||||
|
||||
func (r *fakeAuthRepository) GetSession(_ context.Context, sessionID string) (*domain.Session, error) {
|
||||
return r.sessions[sessionID], nil
|
||||
}
|
||||
|
||||
func (r *fakeAuthRepository) RefreshSession(_ context.Context, _ string, _ time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeAuthRepository) DeleteSession(_ context.Context, sessionID string) error {
|
||||
if r.deletedSessions == nil {
|
||||
r.deletedSessions = make(map[string]bool)
|
||||
}
|
||||
r.deletedSessions[sessionID] = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeAuthRepository) CreateAPIToken(_ context.Context, userID, tokenHash, name string) (*domain.APIToken, error) {
|
||||
r.createdAPITokenHash = tokenHash
|
||||
r.createdAPITokenName = name
|
||||
return &domain.APIToken{ApiToken: db.ApiToken{ID: "token-1", UserID: userID, TokenHash: tokenHash, Name: name}}, nil
|
||||
}
|
||||
|
||||
func (r *fakeAuthRepository) GetAPITokenByHash(_ context.Context, tokenHash string) (*domain.APIToken, error) {
|
||||
return r.apiTokensByHash[tokenHash], nil
|
||||
}
|
||||
|
||||
func (r *fakeAuthRepository) TouchAPITokenLastUsedAt(_ context.Context, tokenID string) error {
|
||||
r.touchedTokenID = tokenID
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeAuthRepository) RevokeAllAPITokensForUser(_ context.Context, userID string) error {
|
||||
r.revokedUserID = userID
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeAuditService struct {
|
||||
events []domain.AuditEvent
|
||||
}
|
||||
|
||||
func (s *fakeAuditService) Record(_ context.Context, event domain.AuditEvent) error {
|
||||
s.events = append(s.events, event)
|
||||
return nil
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"mal/internal/database"
|
||||
dbfixes "mal/internal/database/fixes"
|
||||
)
|
||||
|
||||
func DefaultAvatarURL(username string) string {
|
||||
@@ -10,3 +14,9 @@ func DefaultAvatarURL(username string) string {
|
||||
params.Set("seed", strings.TrimSpace(username))
|
||||
return "https://api.dicebear.com/9.x/dylan/svg?" + params.Encode()
|
||||
}
|
||||
|
||||
func RunMigrationsAndFixes(sqlDB *sql.DB) error {
|
||||
return database.RunMigrationsAndFixes(sqlDB, dbfixes.Dependencies{
|
||||
DefaultAvatarURL: DefaultAvatarURL,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"mal/internal/config"
|
||||
dbfixes "mal/internal/database/fixes"
|
||||
"mal/internal/db"
|
||||
"mal/internal/observability"
|
||||
|
||||
@@ -21,7 +22,7 @@ var Module = fx.Options(
|
||||
ProvideSQLDB,
|
||||
ProvideQueries,
|
||||
),
|
||||
fx.Invoke(RunMigrationsAndFixes),
|
||||
fx.Invoke(RegisterJikanCacheCleanupWorker),
|
||||
)
|
||||
|
||||
func ProvideSQLDB(cfg config.Config) (*sql.DB, error) {
|
||||
@@ -58,9 +59,12 @@ func RunMigrations(sqlDB *sql.DB) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
func RunMigrationsAndFixes(sqlDB *sql.DB) error {
|
||||
func RunMigrationsAndFixes(sqlDB *sql.DB, deps dbfixes.Dependencies) error {
|
||||
if err := RunMigrations(sqlDB); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("run migrations: %w", err)
|
||||
}
|
||||
return RunDataFixes(sqlDB)
|
||||
if err := RunDataFixes(sqlDB, deps); err != nil {
|
||||
return fmt.Errorf("run data fixes: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package database
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"mal/internal/db"
|
||||
"testing"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
@@ -13,7 +14,11 @@ func TestRunMigrationsCreatesHotPathIndexes(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
defer func() { _ = sqlDB.Close() }()
|
||||
defer func() {
|
||||
if err := sqlDB.Close(); err != nil {
|
||||
t.Errorf("close sqlite: %v", err)
|
||||
}
|
||||
}()
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
|
||||
if err := RunMigrations(sqlDB); err != nil {
|
||||
@@ -39,3 +44,81 @@ func TestRunMigrationsCreatesHotPathIndexes(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupExpiredJikanCache(t *testing.T) {
|
||||
sqlDB := newMigratedTestDB(t)
|
||||
defer closeTestDB(t, sqlDB)
|
||||
|
||||
ctx := context.Background()
|
||||
for _, row := range []struct {
|
||||
key string
|
||||
expiresAt string
|
||||
}{
|
||||
{key: "expired", expiresAt: "2000-01-01T00:00:00Z"},
|
||||
{key: "fresh", expiresAt: "2999-01-01T00:00:00Z"},
|
||||
} {
|
||||
_, err := sqlDB.ExecContext(ctx, `INSERT INTO jikan_cache (key, data, expires_at) VALUES (?, ?, ?)`, row.key, "{}", row.expiresAt)
|
||||
if err != nil {
|
||||
t.Fatalf("insert %s cache row: %v", row.key, err)
|
||||
}
|
||||
}
|
||||
|
||||
cleanupExpiredJikanCache(ctx, db.New(sqlDB))
|
||||
|
||||
keys := jikanCacheKeys(ctx, t, sqlDB)
|
||||
if len(keys) != 1 || keys[0] != "fresh" {
|
||||
t.Fatalf("remaining cache keys = %v, want [fresh]", keys)
|
||||
}
|
||||
}
|
||||
|
||||
func newMigratedTestDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
|
||||
sqlDB, err := sql.Open("sqlite3", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
|
||||
if err := RunMigrations(sqlDB); err != nil {
|
||||
closeTestDB(t, sqlDB)
|
||||
t.Fatalf("RunMigrations: %v", err)
|
||||
}
|
||||
|
||||
return sqlDB
|
||||
}
|
||||
|
||||
func closeTestDB(t *testing.T, sqlDB *sql.DB) {
|
||||
t.Helper()
|
||||
|
||||
if err := sqlDB.Close(); err != nil {
|
||||
t.Errorf("close sqlite: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func jikanCacheKeys(ctx context.Context, t *testing.T, sqlDB *sql.DB) []string {
|
||||
t.Helper()
|
||||
|
||||
var keys []string
|
||||
rows, err := sqlDB.QueryContext(ctx, `SELECT key FROM jikan_cache ORDER BY key`)
|
||||
if err != nil {
|
||||
t.Fatalf("query cache keys: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := rows.Close(); err != nil {
|
||||
t.Errorf("close rows: %v", err)
|
||||
}
|
||||
}()
|
||||
for rows.Next() {
|
||||
var key string
|
||||
if err := rows.Scan(&key); err != nil {
|
||||
t.Fatalf("scan key: %v", err)
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatalf("iterate keys: %v", err)
|
||||
}
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
@@ -8,9 +8,10 @@ import (
|
||||
|
||||
dbfixes "mal/internal/database/fixes"
|
||||
"mal/internal/observability"
|
||||
errlog "mal/pkg"
|
||||
)
|
||||
|
||||
func RunDataFixes(sqlDB *sql.DB) error {
|
||||
func RunDataFixes(sqlDB *sql.DB, deps dbfixes.Dependencies) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
@@ -21,12 +22,12 @@ func RunDataFixes(sqlDB *sql.DB) error {
|
||||
}
|
||||
|
||||
if err := ensureDataFixTable(ctx, sqlDB); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("ensure data fix table: %w", err)
|
||||
}
|
||||
|
||||
applied, err := loadAppliedFixes(ctx, sqlDB)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("load applied data fixes: %w", err)
|
||||
}
|
||||
|
||||
for _, fix := range fixes {
|
||||
@@ -42,11 +43,11 @@ func RunDataFixes(sqlDB *sql.DB) error {
|
||||
"id": fix.ID,
|
||||
},
|
||||
)
|
||||
if err := fix.Apply(ctx, sqlDB); err != nil {
|
||||
if err := fix.Apply(ctx, sqlDB, deps); err != nil {
|
||||
return fmt.Errorf("data fix %s failed: %w", fix.ID, err)
|
||||
}
|
||||
if err := markFixApplied(ctx, sqlDB, fix.ID); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("mark data fix %s applied: %w", fix.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +73,7 @@ func loadAppliedFixes(ctx context.Context, sqlDB *sql.DB) (map[string]bool, erro
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load applied data fixes: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
defer errlog.Close(rows, "failed to close applied data fixes rows")
|
||||
|
||||
applied := make(map[string]bool)
|
||||
for rows.Next() {
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
func init() {
|
||||
Register(Fix{
|
||||
ID: "20260526_episode_availability_backfill_next_refresh_at",
|
||||
Apply: func(ctx context.Context, sqlDB *sql.DB) error {
|
||||
Apply: func(ctx context.Context, sqlDB *sql.DB, _ Dependencies) error {
|
||||
// Old caches could have next_refresh_at NULL (especially for airing shows with missing broadcast metadata),
|
||||
// which can result in "never refresh again" behavior on the server.
|
||||
_, err := sqlDB.ExecContext(ctx, `
|
||||
|
||||
@@ -4,18 +4,22 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"mal/internal"
|
||||
errlog "mal/pkg"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Register(Fix{
|
||||
ID: "20260528_backfill_avatar_url",
|
||||
Apply: func(ctx context.Context, sqlDB *sql.DB) error {
|
||||
Apply: func(ctx context.Context, sqlDB *sql.DB, deps Dependencies) error {
|
||||
if deps.DefaultAvatarURL == nil {
|
||||
return fmt.Errorf("default avatar URL dependency is required")
|
||||
}
|
||||
|
||||
rows, err := sqlDB.QueryContext(ctx, `SELECT id, username FROM user WHERE avatar_url = ''`)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("query users missing avatar_url: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
defer errlog.Close(rows, "failed to close avatar backfill rows")
|
||||
|
||||
type userRow struct {
|
||||
id string
|
||||
@@ -25,16 +29,16 @@ func init() {
|
||||
for rows.Next() {
|
||||
var r userRow
|
||||
if err := rows.Scan(&r.id, &r.username); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("scan user missing avatar_url: %w", err)
|
||||
}
|
||||
toUpdate = append(toUpdate, r)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("iterate users missing avatar_url: %w", err)
|
||||
}
|
||||
|
||||
for _, u := range toUpdate {
|
||||
avatarURL := internal.DefaultAvatarURL(u.username)
|
||||
avatarURL := deps.DefaultAvatarURL(u.username)
|
||||
if _, err := sqlDB.ExecContext(ctx, `UPDATE user SET avatar_url = ? WHERE id = ?`, avatarURL, u.id); err != nil {
|
||||
return fmt.Errorf("update avatar_url for user %s: %w", u.id, err)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"mal/integrations/jikan"
|
||||
"mal/internal/config"
|
||||
"mal/internal/db"
|
||||
"mal/internal/observability"
|
||||
errlog "mal/pkg"
|
||||
)
|
||||
|
||||
type animeDurationRow struct {
|
||||
@@ -17,18 +17,20 @@ type animeDurationRow struct {
|
||||
|
||||
func init() {
|
||||
Register(Fix{
|
||||
ID: "20260608_backfill_anime_duration_seconds",
|
||||
Apply: applyAnimeDurationSecondsBackfill,
|
||||
ID: "20260608_backfill_anime_duration_seconds",
|
||||
Apply: func(ctx context.Context, sqlDB *sql.DB, _ Dependencies) error {
|
||||
return applyAnimeDurationSecondsBackfill(ctx, sqlDB)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func applyAnimeDurationSecondsBackfill(ctx context.Context, sqlDB *sql.DB) error {
|
||||
toUpdate, err := listAnimeMissingDurationSeconds(ctx, sqlDB)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("list anime missing duration_seconds: %w", err)
|
||||
}
|
||||
|
||||
client := jikan.NewClient(config.Config{}, db.New(sqlDB), observability.NewMetrics())
|
||||
client := jikan.NewClient(config.Config{}, db.New(sqlDB))
|
||||
for _, row := range toUpdate {
|
||||
anime, err := client.GetAnimeByID(ctx, int(row.id))
|
||||
if err != nil {
|
||||
@@ -62,7 +64,7 @@ WHERE duration_seconds IS NULL;
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query anime rows missing duration_seconds: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
defer errlog.Close(rows, "failed to close anime duration backfill rows")
|
||||
|
||||
var toUpdate []animeDurationRow
|
||||
for rows.Next() {
|
||||
|
||||
@@ -9,7 +9,11 @@ import (
|
||||
|
||||
type Fix struct {
|
||||
ID string
|
||||
Apply func(ctx context.Context, sqlDB *sql.DB) error
|
||||
Apply func(ctx context.Context, sqlDB *sql.DB, deps Dependencies) error
|
||||
}
|
||||
|
||||
type Dependencies struct {
|
||||
DefaultAvatarURL func(username string) string
|
||||
}
|
||||
|
||||
var registered []Fix
|
||||
|
||||
71
internal/database/jikan_cache_cleanup.go
Normal file
71
internal/database/jikan_cache_cleanup.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"mal/internal/db"
|
||||
"mal/internal/observability"
|
||||
"time"
|
||||
|
||||
"go.uber.org/fx"
|
||||
)
|
||||
|
||||
const (
|
||||
jikanCacheCleanupInterval = time.Hour
|
||||
jikanCacheCleanupTimeout = 30 * time.Second
|
||||
jikanCacheCleanupWorker = "jikan_cache_cleanup"
|
||||
)
|
||||
|
||||
func RegisterJikanCacheCleanupWorker(lc fx.Lifecycle, queries *db.Queries) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
lc.Append(fx.Hook{
|
||||
OnStart: func(startCtx context.Context) error {
|
||||
go func() {
|
||||
<-startCtx.Done()
|
||||
cancel()
|
||||
}()
|
||||
go runJikanCacheCleanupWorker(ctx, queries)
|
||||
return nil
|
||||
},
|
||||
OnStop: func(context.Context) error {
|
||||
cancel()
|
||||
return nil
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func runJikanCacheCleanupWorker(ctx context.Context, queries *db.Queries) {
|
||||
observability.Info("jikan_cache_cleanup_worker_start", "database", "", nil)
|
||||
|
||||
ticker := time.NewTicker(jikanCacheCleanupInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
cleanupExpiredJikanCache(ctx, queries)
|
||||
|
||||
select {
|
||||
case <-ticker.C:
|
||||
case <-ctx.Done():
|
||||
observability.Info("jikan_cache_cleanup_worker_stop", "database", "", nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cleanupExpiredJikanCache(ctx context.Context, queries *db.Queries) {
|
||||
cleanupCtx, cancel := context.WithTimeout(ctx, jikanCacheCleanupTimeout)
|
||||
defer cancel()
|
||||
|
||||
err := queries.DeleteExpiredJikanCache(cleanupCtx)
|
||||
if err != nil {
|
||||
observability.Warn(
|
||||
"jikan_cache_cleanup_failed",
|
||||
"database",
|
||||
"",
|
||||
map[string]any{
|
||||
"worker": jikanCacheCleanupWorker,
|
||||
},
|
||||
err,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
-- +goose Up
|
||||
CREATE INDEX IF NOT EXISTS idx_anime_relation_related_anime_id
|
||||
ON anime_relation(related_anime_id);
|
||||
|
||||
-- +goose Down
|
||||
DROP INDEX IF EXISTS idx_anime_relation_related_anime_id;
|
||||
@@ -1,180 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (q *Queries) GetCommandPaletteContinueWatching(ctx context.Context, userID string, query string, limit int64) ([]GetContinueWatchingEntriesRow, error) {
|
||||
if userID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
limit = commandPaletteLimit(limit)
|
||||
|
||||
needle, pattern := commandPalettePattern(query)
|
||||
rows, err := q.db.QueryContext(ctx, `
|
||||
SELECT
|
||||
c.id,
|
||||
c.user_id,
|
||||
c.anime_id,
|
||||
c.current_episode,
|
||||
c.current_time_seconds,
|
||||
c.duration_seconds,
|
||||
c.created_at,
|
||||
c.updated_at,
|
||||
a.title_original,
|
||||
a.title_english,
|
||||
a.title_japanese,
|
||||
a.image_url,
|
||||
a.duration_seconds as anime_duration_seconds
|
||||
FROM continue_watching_entry c
|
||||
JOIN anime a ON c.anime_id = a.id
|
||||
WHERE c.user_id = ?
|
||||
AND (
|
||||
? = ''
|
||||
OR lower(a.title_original) LIKE ?
|
||||
OR lower(coalesce(a.title_english, '')) LIKE ?
|
||||
OR lower(coalesce(a.title_japanese, '')) LIKE ?
|
||||
OR lower('Continue watching') LIKE ?
|
||||
)
|
||||
ORDER BY c.updated_at DESC
|
||||
LIMIT ?`, userID, needle, pattern, pattern, pattern, pattern, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
items := make([]GetContinueWatchingEntriesRow, 0, int(limit))
|
||||
for rows.Next() {
|
||||
item, err := scanContinueWatchingEntry(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func scanContinueWatchingEntry(rows scanner) (GetContinueWatchingEntriesRow, error) {
|
||||
var item GetContinueWatchingEntriesRow
|
||||
err := rows.Scan(
|
||||
&item.ID,
|
||||
&item.UserID,
|
||||
&item.AnimeID,
|
||||
&item.CurrentEpisode,
|
||||
&item.CurrentTimeSeconds,
|
||||
&item.DurationSeconds,
|
||||
&item.CreatedAt,
|
||||
&item.UpdatedAt,
|
||||
&item.TitleOriginal,
|
||||
&item.TitleEnglish,
|
||||
&item.TitleJapanese,
|
||||
&item.ImageUrl,
|
||||
&item.AnimeDurationSeconds,
|
||||
)
|
||||
return item, err
|
||||
}
|
||||
|
||||
func (q *Queries) GetCommandPaletteWatchlist(ctx context.Context, userID string, query string, limit int64) ([]GetUserWatchListRow, error) {
|
||||
if userID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
limit = commandPaletteLimit(limit)
|
||||
|
||||
needle, pattern := commandPalettePattern(query)
|
||||
rows, err := q.db.QueryContext(ctx, `
|
||||
SELECT
|
||||
e.id,
|
||||
e.user_id,
|
||||
e.anime_id,
|
||||
e.status,
|
||||
e.created_at,
|
||||
e.updated_at,
|
||||
e.current_episode,
|
||||
e.last_episode_at,
|
||||
e.current_time_seconds,
|
||||
a.title_original,
|
||||
a.title_english,
|
||||
a.title_japanese,
|
||||
a.image_url,
|
||||
a.airing
|
||||
FROM watch_list_entry e
|
||||
JOIN anime a ON e.anime_id = a.id
|
||||
WHERE e.user_id = ?
|
||||
AND e.status IN ('watching', 'plan_to_watch')
|
||||
AND (
|
||||
? = ''
|
||||
OR lower(a.title_original) LIKE ?
|
||||
OR lower(coalesce(a.title_english, '')) LIKE ?
|
||||
OR lower(coalesce(a.title_japanese, '')) LIKE ?
|
||||
OR lower(e.status) LIKE ?
|
||||
)
|
||||
ORDER BY
|
||||
CASE e.status
|
||||
WHEN 'watching' THEN 0
|
||||
WHEN 'plan_to_watch' THEN 1
|
||||
ELSE 2
|
||||
END,
|
||||
e.updated_at DESC
|
||||
LIMIT ?`, userID, needle, pattern, pattern, pattern, pattern, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
items := make([]GetUserWatchListRow, 0, int(limit))
|
||||
for rows.Next() {
|
||||
item, err := scanWatchListEntry(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func scanWatchListEntry(rows scanner) (GetUserWatchListRow, error) {
|
||||
var item GetUserWatchListRow
|
||||
err := rows.Scan(
|
||||
&item.ID,
|
||||
&item.UserID,
|
||||
&item.AnimeID,
|
||||
&item.Status,
|
||||
&item.CreatedAt,
|
||||
&item.UpdatedAt,
|
||||
&item.CurrentEpisode,
|
||||
&item.LastEpisodeAt,
|
||||
&item.CurrentTimeSeconds,
|
||||
&item.TitleOriginal,
|
||||
&item.TitleEnglish,
|
||||
&item.TitleJapanese,
|
||||
&item.ImageUrl,
|
||||
&item.Airing,
|
||||
)
|
||||
return item, err
|
||||
}
|
||||
|
||||
func commandPalettePattern(query string) (string, string) {
|
||||
needle := strings.ToLower(strings.TrimSpace(query))
|
||||
return needle, "%" + needle + "%"
|
||||
}
|
||||
|
||||
func commandPaletteLimit(limit int64) int64 {
|
||||
if limit <= 0 {
|
||||
return 5
|
||||
}
|
||||
|
||||
return limit
|
||||
}
|
||||
|
||||
type scanner interface {
|
||||
Scan(dest ...interface{}) error
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
func TestGetCommandPaletteContinueWatchingFiltersAndLimits(t *testing.T) {
|
||||
sqlDB := openCommandPaletteTestDB(t)
|
||||
|
||||
got, err := New(sqlDB).GetCommandPaletteContinueWatching(context.Background(), "user-a", "continue", 1)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCommandPaletteContinueWatching: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].AnimeID != 20 {
|
||||
t.Fatalf("continue rows = %+v, want latest anime 20 only", got)
|
||||
}
|
||||
|
||||
got, err = New(sqlDB).GetCommandPaletteContinueWatching(context.Background(), "user-a", "nar", 5)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCommandPaletteContinueWatching filtered: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].AnimeID != 10 {
|
||||
t.Fatalf("filtered continue rows = %+v, want anime 10", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCommandPaletteWatchlistFiltersAndOrders(t *testing.T) {
|
||||
sqlDB := openCommandPaletteTestDB(t)
|
||||
|
||||
got, err := New(sqlDB).GetCommandPaletteWatchlist(context.Background(), "user-a", "", 5)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCommandPaletteWatchlist: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("watchlist rows len = %d, want 2", len(got))
|
||||
}
|
||||
if got[0].AnimeID != 10 || got[1].AnimeID != 20 {
|
||||
t.Fatalf("watchlist order = [%d %d], want watching anime 10 before plan anime 20", got[0].AnimeID, got[1].AnimeID)
|
||||
}
|
||||
|
||||
got, err = New(sqlDB).GetCommandPaletteWatchlist(context.Background(), "user-a", "plan", 5)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCommandPaletteWatchlist filtered: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].AnimeID != 20 {
|
||||
t.Fatalf("filtered watchlist rows = %+v, want anime 20", got)
|
||||
}
|
||||
}
|
||||
|
||||
func openCommandPaletteTestDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
|
||||
sqlDB, err := sql.Open("sqlite3", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
|
||||
_, err = sqlDB.ExecContext(context.Background(), `
|
||||
CREATE TABLE anime (
|
||||
id INTEGER PRIMARY KEY,
|
||||
title_original TEXT NOT NULL,
|
||||
title_english TEXT,
|
||||
title_japanese TEXT,
|
||||
image_url TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
airing BOOLEAN DEFAULT 0,
|
||||
duration_seconds REAL
|
||||
);
|
||||
CREATE TABLE watch_list_entry (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
anime_id INTEGER NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
current_episode INTEGER,
|
||||
last_episode_at DATETIME,
|
||||
current_time_seconds REAL NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE continue_watching_entry (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
anime_id INTEGER NOT NULL,
|
||||
current_episode INTEGER,
|
||||
current_time_seconds REAL NOT NULL DEFAULT 0,
|
||||
duration_seconds REAL,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL
|
||||
);
|
||||
INSERT INTO anime (id, title_original, title_english, title_japanese, image_url, airing, duration_seconds) VALUES
|
||||
(10, 'Naruto', NULL, NULL, 'naruto.jpg', 0, 1440),
|
||||
(20, 'Frieren', 'Frieren: Beyond Journey''s End', NULL, 'frieren.jpg', 0, 1440),
|
||||
(30, 'Dropped Show', NULL, NULL, 'dropped.jpg', 0, 1440);
|
||||
INSERT INTO watch_list_entry (id, user_id, anime_id, status, created_at, updated_at, current_episode, current_time_seconds) VALUES
|
||||
('w1', 'user-a', 10, 'watching', '2026-01-01 00:00:00', '2026-01-01 00:00:00', 3, 0),
|
||||
('w2', 'user-a', 20, 'plan_to_watch', '2026-01-02 00:00:00', '2026-01-03 00:00:00', 0, 0),
|
||||
('w3', 'user-a', 30, 'dropped', '2026-01-04 00:00:00', '2026-01-04 00:00:00', 0, 0),
|
||||
('w4', 'user-b', 10, 'watching', '2026-01-05 00:00:00', '2026-01-05 00:00:00', 1, 0);
|
||||
INSERT INTO continue_watching_entry (id, user_id, anime_id, current_episode, current_time_seconds, duration_seconds, created_at, updated_at) VALUES
|
||||
('c1', 'user-a', 10, 4, 120, 1440, '2026-01-01 00:00:00', '2026-01-01 00:00:00'),
|
||||
('c2', 'user-a', 20, 1, 60, 1440, '2026-01-02 00:00:00', '2026-01-03 00:00:00'),
|
||||
('c3', 'user-b', 10, 1, 30, 1440, '2026-01-04 00:00:00', '2026-01-04 00:00:00');
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("seed command palette db: %v", err)
|
||||
}
|
||||
|
||||
return sqlDB
|
||||
}
|
||||
@@ -15,6 +15,7 @@ type Querier interface {
|
||||
CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error)
|
||||
DeleteAnimeFetchRetry(ctx context.Context, animeID int64) error
|
||||
DeleteContinueWatchingEntry(ctx context.Context, arg DeleteContinueWatchingEntryParams) error
|
||||
DeleteExpiredFailedEpisodeProviderMappings(ctx context.Context) error
|
||||
DeleteExpiredJikanCache(ctx context.Context) error
|
||||
DeleteSession(ctx context.Context, id string) error
|
||||
DeleteWatchListEntry(ctx context.Context, arg DeleteWatchListEntryParams) error
|
||||
@@ -26,12 +27,11 @@ type Querier interface {
|
||||
GetAuditLogsForUser(ctx context.Context, arg GetAuditLogsForUserParams) ([]AuditLog, error)
|
||||
GetContinueWatchingEntries(ctx context.Context, userID string) ([]GetContinueWatchingEntriesRow, error)
|
||||
GetContinueWatchingEntry(ctx context.Context, arg GetContinueWatchingEntryParams) (ContinueWatchingEntry, error)
|
||||
GetCommandPaletteContinueWatching(ctx context.Context, userID string, query string, limit int64) ([]GetContinueWatchingEntriesRow, error)
|
||||
GetCommandPaletteWatchlist(ctx context.Context, userID string, query string, limit int64) ([]GetUserWatchListRow, error)
|
||||
GetDueAnimeFetchRetries(ctx context.Context, limit int64) ([]AnimeFetchRetry, error)
|
||||
GetEpisodeAvailabilityCache(ctx context.Context, animeID int64) (EpisodeAvailabilityCache, error)
|
||||
GetEpisodeProviderMapping(ctx context.Context, arg GetEpisodeProviderMappingParams) (EpisodeProviderMapping, error)
|
||||
GetJikanCache(ctx context.Context, key string) (string, error)
|
||||
GetJikanCacheStats(ctx context.Context) (GetJikanCacheStatsRow, error)
|
||||
GetJikanCacheStale(ctx context.Context, key string) (string, error)
|
||||
GetSession(ctx context.Context, id string) (Session, error)
|
||||
GetTrackedAiringAnimeIDsDueForEpisodeRefresh(ctx context.Context, limit int64) ([]int64, error)
|
||||
|
||||
@@ -233,7 +233,14 @@ WHERE key = ? AND datetime(expires_at) > CURRENT_TIMESTAMP LIMIT 1;
|
||||
|
||||
-- name: GetJikanCacheStale :one
|
||||
SELECT data FROM jikan_cache
|
||||
WHERE key = ? LIMIT 1;
|
||||
WHERE key = ? AND datetime(expires_at) > datetime(CURRENT_TIMESTAMP, '-14 days') LIMIT 1;
|
||||
|
||||
-- name: GetJikanCacheStats :one
|
||||
SELECT
|
||||
COUNT(*) AS total_rows,
|
||||
COUNT(*) FILTER (WHERE datetime(expires_at) <= CURRENT_TIMESTAMP) AS expired_rows,
|
||||
COALESCE(unixepoch(MIN(expires_at)), 0) AS oldest_expires_at_seconds
|
||||
FROM jikan_cache;
|
||||
|
||||
-- name: SetJikanCache :exec
|
||||
INSERT INTO jikan_cache (key, data, expires_at)
|
||||
@@ -333,6 +340,11 @@ SELECT anime_id, provider, provider_show_id, failed_until, last_error, updated_a
|
||||
FROM episode_provider_mapping
|
||||
WHERE anime_id = ? AND provider = ? LIMIT 1;
|
||||
|
||||
-- name: DeleteExpiredFailedEpisodeProviderMappings :exec
|
||||
DELETE FROM episode_provider_mapping
|
||||
WHERE provider_show_id = ''
|
||||
AND failed_until <= CURRENT_TIMESTAMP;
|
||||
|
||||
-- name: GetTrackedAiringAnimeIDsDueForEpisodeRefresh :many
|
||||
WITH tracked AS (
|
||||
SELECT DISTINCT w.anime_id
|
||||
@@ -357,4 +369,4 @@ LIMIT ?;
|
||||
|
||||
-- name: GetAllCachedAnime :many
|
||||
SELECT data FROM jikan_cache
|
||||
WHERE key LIKE 'anime:%' LIMIT 1000;
|
||||
WHERE key LIKE 'anime:%' AND datetime(expires_at) > CURRENT_TIMESTAMP LIMIT 1000;
|
||||
|
||||
@@ -149,6 +149,17 @@ func (q *Queries) DeleteContinueWatchingEntry(ctx context.Context, arg DeleteCon
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteExpiredFailedEpisodeProviderMappings = `-- name: DeleteExpiredFailedEpisodeProviderMappings :exec
|
||||
DELETE FROM episode_provider_mapping
|
||||
WHERE provider_show_id = ''
|
||||
AND failed_until <= CURRENT_TIMESTAMP
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteExpiredFailedEpisodeProviderMappings(ctx context.Context) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteExpiredFailedEpisodeProviderMappings)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteExpiredJikanCache = `-- name: DeleteExpiredJikanCache :exec
|
||||
DELETE FROM jikan_cache WHERE datetime(expires_at) <= CURRENT_TIMESTAMP
|
||||
`
|
||||
@@ -227,7 +238,7 @@ func (q *Queries) GetAPITokenByHash(ctx context.Context, tokenHash string) (ApiT
|
||||
|
||||
const getAllCachedAnime = `-- name: GetAllCachedAnime :many
|
||||
SELECT data FROM jikan_cache
|
||||
WHERE key LIKE 'anime:%' LIMIT 1000
|
||||
WHERE key LIKE 'anime:%' AND datetime(expires_at) > CURRENT_TIMESTAMP LIMIT 1000
|
||||
`
|
||||
|
||||
func (q *Queries) GetAllCachedAnime(ctx context.Context) ([]string, error) {
|
||||
@@ -235,7 +246,6 @@ func (q *Queries) GetAllCachedAnime(ctx context.Context) ([]string, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []string
|
||||
for rows.Next() {
|
||||
var data string
|
||||
@@ -308,7 +318,6 @@ func (q *Queries) GetAnimeNeedingRelationSync(ctx context.Context) ([]GetAnimeNe
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetAnimeNeedingRelationSyncRow
|
||||
for rows.Next() {
|
||||
var i GetAnimeNeedingRelationSyncRow
|
||||
@@ -344,7 +353,6 @@ func (q *Queries) GetAuditLogsForUser(ctx context.Context, arg GetAuditLogsForUs
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []AuditLog
|
||||
for rows.Next() {
|
||||
var i AuditLog
|
||||
@@ -414,7 +422,6 @@ func (q *Queries) GetContinueWatchingEntries(ctx context.Context, userID string)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetContinueWatchingEntriesRow
|
||||
for rows.Next() {
|
||||
var i GetContinueWatchingEntriesRow
|
||||
@@ -485,7 +492,6 @@ func (q *Queries) GetDueAnimeFetchRetries(ctx context.Context, limit int64) ([]A
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []AnimeFetchRetry
|
||||
for rows.Next() {
|
||||
var i AnimeFetchRetry
|
||||
@@ -570,9 +576,30 @@ func (q *Queries) GetJikanCache(ctx context.Context, key string) (string, error)
|
||||
return data, err
|
||||
}
|
||||
|
||||
const getJikanCacheStats = `-- name: GetJikanCacheStats :one
|
||||
SELECT
|
||||
COUNT(*) AS total_rows,
|
||||
COUNT(*) FILTER (WHERE datetime(expires_at) <= CURRENT_TIMESTAMP) AS expired_rows,
|
||||
COALESCE(unixepoch(MIN(expires_at)), 0) AS oldest_expires_at_seconds
|
||||
FROM jikan_cache
|
||||
`
|
||||
|
||||
type GetJikanCacheStatsRow struct {
|
||||
TotalRows int64 `json:"total_rows"`
|
||||
ExpiredRows int64 `json:"expired_rows"`
|
||||
OldestExpiresAtSeconds int64 `json:"oldest_expires_at_seconds"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetJikanCacheStats(ctx context.Context) (GetJikanCacheStatsRow, error) {
|
||||
row := q.db.QueryRowContext(ctx, getJikanCacheStats)
|
||||
var i GetJikanCacheStatsRow
|
||||
err := row.Scan(&i.TotalRows, &i.ExpiredRows, &i.OldestExpiresAtSeconds)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getJikanCacheStale = `-- name: GetJikanCacheStale :one
|
||||
SELECT data FROM jikan_cache
|
||||
WHERE key = ? LIMIT 1
|
||||
WHERE key = ? AND datetime(expires_at) > datetime(CURRENT_TIMESTAMP, '-14 days') LIMIT 1
|
||||
`
|
||||
|
||||
func (q *Queries) GetJikanCacheStale(ctx context.Context, key string) (string, error) {
|
||||
@@ -626,7 +653,6 @@ func (q *Queries) GetTrackedAiringAnimeIDsDueForEpisodeRefresh(ctx context.Conte
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []int64
|
||||
for rows.Next() {
|
||||
var anime_id int64
|
||||
@@ -703,7 +729,6 @@ func (q *Queries) GetUpcomingSeasons(ctx context.Context, userID string) ([]GetU
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetUpcomingSeasonsRow
|
||||
for rows.Next() {
|
||||
var i GetUpcomingSeasonsRow
|
||||
@@ -803,7 +828,6 @@ func (q *Queries) GetUserWatchList(ctx context.Context, userID string) ([]GetUse
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetUserWatchListRow
|
||||
for rows.Next() {
|
||||
var i GetUserWatchListRow
|
||||
@@ -899,7 +923,6 @@ func (q *Queries) GetWatchingAnime(ctx context.Context, userID string) ([]GetWat
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetWatchingAnimeRow
|
||||
for rows.Next() {
|
||||
var i GetWatchingAnimeRow
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
errlog "mal/pkg"
|
||||
)
|
||||
|
||||
type SkipSegmentOverrideRow struct {
|
||||
@@ -28,7 +29,7 @@ ORDER BY skip_type ASC;
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list skip segment overrides: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
defer errlog.Close(rows, "failed to close skip segment override rows")
|
||||
|
||||
var out []SkipSegmentOverrideRow
|
||||
for rows.Next() {
|
||||
|
||||
@@ -13,7 +13,11 @@ func TestHasSkipSegmentOverrideTableReturnsFalseWhenMissing(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
defer func() { _ = sqlDB.Close() }()
|
||||
defer func() {
|
||||
if err := sqlDB.Close(); err != nil {
|
||||
t.Errorf("close sqlite: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
ok, err := New(sqlDB).HasSkipSegmentOverrideTable(context.Background())
|
||||
if err != nil {
|
||||
@@ -23,3 +27,114 @@ func TestHasSkipSegmentOverrideTableReturnsFalseWhenMissing(t *testing.T) {
|
||||
t.Fatalf("HasSkipSegmentOverrideTable returned true for missing table")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasSkipSegmentOverrideTableReturnsTrueWhenPresent(t *testing.T) {
|
||||
sqlDB, err := openSkipSegmentOverrideTestDB(t)
|
||||
if err != nil {
|
||||
t.Fatalf("open test db: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := sqlDB.Close(); err != nil {
|
||||
t.Errorf("close sqlite: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
queries := New(sqlDB)
|
||||
|
||||
ok, err := queries.HasSkipSegmentOverrideTable(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("HasSkipSegmentOverrideTable: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("HasSkipSegmentOverrideTable returned false for existing table")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkipSegmentOverrideUpsertAndList(t *testing.T) {
|
||||
sqlDB, err := openSkipSegmentOverrideTestDB(t)
|
||||
if err != nil {
|
||||
t.Fatalf("open test db: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := sqlDB.Close(); err != nil {
|
||||
t.Errorf("close sqlite: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
queries := New(sqlDB)
|
||||
|
||||
row := SkipSegmentOverrideRow{
|
||||
ID: "override-1",
|
||||
UserID: "user-a",
|
||||
AnimeID: 123,
|
||||
Episode: 4,
|
||||
SkipType: "op",
|
||||
StartTime: 12.5,
|
||||
EndTime: 28.25,
|
||||
}
|
||||
if err := queries.UpsertSkipSegmentOverride(context.Background(), row); err != nil {
|
||||
t.Fatalf("UpsertSkipSegmentOverride insert: %v", err)
|
||||
}
|
||||
|
||||
got, err := queries.ListSkipSegmentOverrides(context.Background(), row.UserID, row.AnimeID, row.Episode)
|
||||
if err != nil {
|
||||
t.Fatalf("ListSkipSegmentOverrides insert: %v", err)
|
||||
}
|
||||
assertSingleSkipSegmentOverrideRow(t, got, row)
|
||||
|
||||
updated := row
|
||||
updated.StartTime = 13.75
|
||||
updated.EndTime = 29.5
|
||||
if err := queries.UpsertSkipSegmentOverride(context.Background(), updated); err != nil {
|
||||
t.Fatalf("UpsertSkipSegmentOverride update: %v", err)
|
||||
}
|
||||
|
||||
got, err = queries.ListSkipSegmentOverrides(context.Background(), row.UserID, row.AnimeID, row.Episode)
|
||||
if err != nil {
|
||||
t.Fatalf("ListSkipSegmentOverrides update: %v", err)
|
||||
}
|
||||
assertSingleSkipSegmentOverrideRow(t, got, updated)
|
||||
}
|
||||
|
||||
func assertSingleSkipSegmentOverrideRow(t *testing.T, got []SkipSegmentOverrideRow, want SkipSegmentOverrideRow) {
|
||||
t.Helper()
|
||||
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("len(got) = %d, want 1", len(got))
|
||||
}
|
||||
if got[0] != want {
|
||||
t.Fatalf("row = %+v, want %+v", got[0], want)
|
||||
}
|
||||
}
|
||||
|
||||
func openSkipSegmentOverrideTestDB(t *testing.T) (*sql.DB, error) {
|
||||
t.Helper()
|
||||
|
||||
sqlDB, err := sql.Open("sqlite3", ":memory:")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = sqlDB.ExecContext(context.Background(), `
|
||||
CREATE TABLE skip_segment_override (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
anime_id INTEGER NOT NULL,
|
||||
episode INTEGER NOT NULL,
|
||||
skip_type TEXT NOT NULL,
|
||||
start_time REAL NOT NULL,
|
||||
end_time REAL NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, anime_id, episode, skip_type)
|
||||
);
|
||||
`)
|
||||
if err != nil {
|
||||
if closeErr := sqlDB.Close(); closeErr != nil {
|
||||
return nil, closeErr
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return sqlDB, nil
|
||||
}
|
||||
|
||||
@@ -13,12 +13,21 @@ import (
|
||||
func Open(dbFile string) (*sql.DB, error) {
|
||||
// busy_timeout avoids immediate SQLITE_BUSY errors under concurrent access.
|
||||
// foreign_keys ensures FK constraints are enforced for this connection.
|
||||
db, err := sql.Open("sqlite3", fmt.Sprintf("file:%s?_foreign_keys=on&_busy_timeout=5000", dbFile))
|
||||
// txlock=immediate acquires SQLite's write lock when a transaction starts,
|
||||
// which avoids deferred read->write lock upgrades failing mid-transaction.
|
||||
db, err := sql.Open("sqlite3", fmt.Sprintf("file:%s?_foreign_keys=on&_busy_timeout=5000&_txlock=immediate", dbFile))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open db: %w", err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
db.SetMaxIdleConns(1)
|
||||
|
||||
// WAL improves concurrency between readers and writers.
|
||||
_, _ = db.ExecContext(context.Background(), "PRAGMA journal_mode=WAL;")
|
||||
_, _ = db.ExecContext(context.Background(), "PRAGMA busy_timeout=5000;")
|
||||
if _, err := db.ExecContext(context.Background(), "PRAGMA journal_mode=WAL;"); err != nil {
|
||||
return nil, fmt.Errorf("failed to enable WAL mode: %w", err)
|
||||
}
|
||||
if _, err := db.ExecContext(context.Background(), "PRAGMA busy_timeout=5000;"); err != nil {
|
||||
return nil, fmt.Errorf("failed to set busy timeout: %w", err)
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
errlog "mal/pkg"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -24,7 +25,7 @@ func (q *Queries) GetUserWatchlistAnimeIDs(ctx context.Context, userID string, a
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
defer errlog.Close(rows, "failed to close watchlist id rows")
|
||||
|
||||
matches := make([]int64, 0, len(animeIDs))
|
||||
for rows.Next() {
|
||||
|
||||
@@ -14,7 +14,11 @@ func TestGetUserWatchlistAnimeIDsFiltersRequestedIDs(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
defer func() { _ = sqlDB.Close() }()
|
||||
defer func() {
|
||||
if err := sqlDB.Close(); err != nil {
|
||||
t.Errorf("close sqlite: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
_, err = sqlDB.ExecContext(context.Background(), `
|
||||
CREATE TABLE watch_list_entry (
|
||||
|
||||
@@ -13,8 +13,6 @@ type WatchlistService interface {
|
||||
RemoveEntry(ctx context.Context, userID string, animeID int64) error
|
||||
GetWatchlist(ctx context.Context, userID string) ([]UserWatchListRow, error)
|
||||
GetWatchlistMap(ctx context.Context, userID string, animeIDs []int64) (map[int64]bool, error)
|
||||
GetCommandPaletteWatchlist(ctx context.Context, userID string, query string, limit int64) ([]UserWatchListRow, error)
|
||||
GetCommandPaletteContinueWatching(ctx context.Context, userID string, query string, limit int64) ([]db.GetContinueWatchingEntriesRow, error)
|
||||
GetWatchListEntry(ctx context.Context, userID string, animeID int64) (WatchlistEntry, error)
|
||||
GetContinueWatchingEntry(ctx context.Context, userID string, animeID int64) (db.ContinueWatchingEntry, error)
|
||||
DeleteContinueWatching(ctx context.Context, userID string, animeID int64) error
|
||||
@@ -28,8 +26,6 @@ type WatchlistRepository interface {
|
||||
DeleteWatchListEntry(ctx context.Context, arg db.DeleteWatchListEntryParams) error
|
||||
GetUserWatchList(ctx context.Context, userID string) ([]db.GetUserWatchListRow, error)
|
||||
GetUserWatchlistAnimeIDs(ctx context.Context, userID string, animeIDs []int64) ([]int64, error)
|
||||
GetCommandPaletteWatchlist(ctx context.Context, userID string, query string, limit int64) ([]db.GetUserWatchListRow, error)
|
||||
GetCommandPaletteContinueWatching(ctx context.Context, userID string, query string, limit int64) ([]db.GetContinueWatchingEntriesRow, error)
|
||||
GetWatchListEntry(ctx context.Context, arg db.GetWatchListEntryParams) (db.WatchListEntry, error)
|
||||
GetContinueWatchingEntry(ctx context.Context, arg db.GetContinueWatchingEntryParams) (db.ContinueWatchingEntry, error)
|
||||
DeleteContinueWatchingEntry(ctx context.Context, arg db.DeleteContinueWatchingEntryParams) error
|
||||
|
||||
@@ -131,34 +131,9 @@ func (s *EpisodeService) markFailure(ctx context.Context, anime domain.Anime, ca
|
||||
)
|
||||
}
|
||||
|
||||
func (s *EpisodeService) getCached(ctx context.Context, animeID int) (domain.CanonicalEpisodeList, bool) {
|
||||
row, err := s.queries.GetEpisodeAvailabilityCache(ctx, int64(animeID))
|
||||
if err != nil {
|
||||
s.metrics.ObserveCache("episode_availability", "miss")
|
||||
return domain.CanonicalEpisodeList{}, false
|
||||
}
|
||||
var payload domain.CanonicalEpisodeList
|
||||
if err := json.Unmarshal([]byte(row.Data), &payload); err != nil {
|
||||
s.metrics.ObserveCache("episode_availability", "miss")
|
||||
observability.Warn(
|
||||
"episodes_cached_payload_invalid",
|
||||
"episodes",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": animeID,
|
||||
},
|
||||
err,
|
||||
)
|
||||
return domain.CanonicalEpisodeList{}, false
|
||||
}
|
||||
s.metrics.ObserveCache("episode_availability", "hit")
|
||||
return payload, true
|
||||
}
|
||||
|
||||
func (s *EpisodeService) getFreshCached(ctx context.Context, anime domain.Anime) (domain.CanonicalEpisodeList, bool) {
|
||||
row, err := s.queries.GetEpisodeAvailabilityCache(ctx, int64(anime.MalID))
|
||||
if err != nil {
|
||||
s.metrics.ObserveCache("episode_availability_fresh", "miss")
|
||||
return domain.CanonicalEpisodeList{}, false
|
||||
}
|
||||
|
||||
@@ -167,25 +142,10 @@ func (s *EpisodeService) getFreshCached(ctx context.Context, anime domain.Anime)
|
||||
return domain.CanonicalEpisodeList{}, false
|
||||
}
|
||||
|
||||
payload, ok := s.decodeFreshCachedPayload(anime, row.Data)
|
||||
payload, ok := s.decodeCachedPayload(anime, row.Data)
|
||||
if !ok {
|
||||
return domain.CanonicalEpisodeList{}, false
|
||||
}
|
||||
if !isCanonicalEpisodePayloadValid(payload, anime.Episodes) {
|
||||
s.metrics.ObserveCache("episode_availability_fresh", "miss")
|
||||
observability.Info(
|
||||
"episodes_cached_payload_rejected",
|
||||
"episodes",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": anime.MalID,
|
||||
"expected_count": anime.Episodes,
|
||||
"cached_episodes": len(payload.Episodes),
|
||||
},
|
||||
)
|
||||
return domain.CanonicalEpisodeList{}, false
|
||||
}
|
||||
s.metrics.ObserveCache("episode_availability_fresh", "hit")
|
||||
observability.Info(
|
||||
"episodes_cache_served",
|
||||
"episodes",
|
||||
@@ -199,9 +159,20 @@ func (s *EpisodeService) getFreshCached(ctx context.Context, anime domain.Anime)
|
||||
return payload, true
|
||||
}
|
||||
|
||||
func (s *EpisodeService) getDecodedCached(ctx context.Context, anime domain.Anime) (domain.CanonicalEpisodeList, bool) {
|
||||
row, err := s.queries.GetEpisodeAvailabilityCache(ctx, int64(anime.MalID))
|
||||
if err != nil {
|
||||
return domain.CanonicalEpisodeList{}, false
|
||||
}
|
||||
payload, ok := s.decodeCachedPayload(anime, row.Data)
|
||||
if !ok {
|
||||
return domain.CanonicalEpisodeList{}, false
|
||||
}
|
||||
return payload, true
|
||||
}
|
||||
|
||||
func (s *EpisodeService) isFreshEpisodeCache(anime domain.Anime, row db.EpisodeAvailabilityCache, now time.Time) bool {
|
||||
if row.NextRefreshAt.Valid && !row.NextRefreshAt.Time.After(now) {
|
||||
s.metrics.ObserveCache("episode_availability_fresh", "miss")
|
||||
observability.Info(
|
||||
"episodes_cache_due_for_refresh",
|
||||
"episodes",
|
||||
@@ -214,7 +185,6 @@ func (s *EpisodeService) isFreshEpisodeCache(anime domain.Anime, row db.EpisodeA
|
||||
return false
|
||||
}
|
||||
if anime.Airing && row.UpdatedAt.Before(now.Add(-airingFallbackRefreshInterval)) {
|
||||
s.metrics.ObserveCache("episode_availability_fresh", "miss")
|
||||
observability.Info(
|
||||
"episodes_cache_too_old_for_airing",
|
||||
"episodes",
|
||||
@@ -229,22 +199,34 @@ func (s *EpisodeService) isFreshEpisodeCache(anime domain.Anime, row db.EpisodeA
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *EpisodeService) decodeFreshCachedPayload(anime domain.Anime, raw string) (domain.CanonicalEpisodeList, bool) {
|
||||
func (s *EpisodeService) decodeCachedPayload(anime domain.Anime, raw string) (domain.CanonicalEpisodeList, bool) {
|
||||
var payload domain.CanonicalEpisodeList
|
||||
err := json.Unmarshal([]byte(raw), &payload)
|
||||
if err == nil {
|
||||
return payload, true
|
||||
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
||||
observability.Warn(
|
||||
"episodes_cached_payload_invalid",
|
||||
"episodes",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": anime.MalID,
|
||||
},
|
||||
err,
|
||||
)
|
||||
return domain.CanonicalEpisodeList{}, false
|
||||
}
|
||||
|
||||
s.metrics.ObserveCache("episode_availability_fresh", "miss")
|
||||
observability.Warn(
|
||||
"episodes_cached_payload_invalid",
|
||||
"episodes",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": anime.MalID,
|
||||
},
|
||||
err,
|
||||
)
|
||||
return domain.CanonicalEpisodeList{}, false
|
||||
if !isCanonicalEpisodePayloadValid(payload, anime.Episodes) {
|
||||
observability.Info(
|
||||
"episodes_cached_payload_rejected",
|
||||
"episodes",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": anime.MalID,
|
||||
"expected_count": anime.Episodes,
|
||||
"cached_episodes": len(payload.Episodes),
|
||||
},
|
||||
)
|
||||
return domain.CanonicalEpisodeList{}, false
|
||||
}
|
||||
|
||||
return payload, true
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ func titleCandidates(anime domain.Anime) []string {
|
||||
|
||||
func isCanonicalEpisodePayloadValid(payload domain.CanonicalEpisodeList, expectedCount int) bool {
|
||||
if expectedCount <= 0 {
|
||||
return true
|
||||
return providerBackedPayloadHasAvailability(payload)
|
||||
}
|
||||
if len(payload.Episodes) > expectedCount {
|
||||
return false
|
||||
@@ -46,26 +46,31 @@ func isCanonicalEpisodePayloadValid(payload domain.CanonicalEpisodeList, expecte
|
||||
return false
|
||||
}
|
||||
}
|
||||
return providerBackedPayloadHasAvailability(payload)
|
||||
}
|
||||
|
||||
func providerBackedPayloadHasAvailability(payload domain.CanonicalEpisodeList) bool {
|
||||
if payload.Source == "" || payload.Source == "jikan_fallback" || payload.Source == "legacy_disabled" {
|
||||
return true
|
||||
}
|
||||
for _, episode := range payload.Episodes {
|
||||
if !episode.HasSub && !episode.HasDub {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func mergeEpisodes(jikanEpisodes []jikan.Episode, availability domain.EpisodeAvailability, expectedCount int) []domain.CanonicalEpisode {
|
||||
byNumber := map[int]episodePartial{}
|
||||
providerNumbers := availableEpisodeNumbers(availability, expectedCount)
|
||||
providerBacked := len(providerNumbers) > 0
|
||||
|
||||
for i, ep := range jikanEpisodes {
|
||||
if exceedsExpectedCount(i+1, expectedCount) {
|
||||
break
|
||||
}
|
||||
number, ok := jikanEpisodeNumber(ep, i)
|
||||
if !ok || exceedsExpectedCount(number, expectedCount) {
|
||||
continue
|
||||
}
|
||||
mergeEpisode(&byNumber, number, func(item *episodePartial) {
|
||||
item.title = strings.TrimSpace(ep.Title)
|
||||
item.filler = ep.Filler
|
||||
item.recap = ep.Recap
|
||||
})
|
||||
for number := range providerNumbers {
|
||||
mergeEpisode(&byNumber, number, func(item *episodePartial) {})
|
||||
}
|
||||
|
||||
mergeJikanEpisodes(&byNumber, jikanEpisodes, providerNumbers, providerBacked, expectedCount)
|
||||
mergeAvailability(&byNumber, availability.Sub, expectedCount, func(item *episodePartial) { item.sub = true })
|
||||
mergeAvailability(&byNumber, availability.Dub, expectedCount, func(item *episodePartial) { item.dub = true })
|
||||
|
||||
@@ -95,6 +100,38 @@ func mergeEpisodes(jikanEpisodes []jikan.Episode, availability domain.EpisodeAva
|
||||
return episodes
|
||||
}
|
||||
|
||||
func mergeJikanEpisodes(byNumber *map[int]episodePartial, episodes []jikan.Episode, providerNumbers map[int]bool, providerBacked bool, expectedCount int) {
|
||||
for i, ep := range episodes {
|
||||
if exceedsExpectedCount(i+1, expectedCount) {
|
||||
break
|
||||
}
|
||||
number, ok := jikanEpisodeNumber(ep, i)
|
||||
if !ok || exceedsExpectedCount(number, expectedCount) || (providerBacked && !providerNumbers[number]) {
|
||||
continue
|
||||
}
|
||||
mergeEpisode(byNumber, number, func(item *episodePartial) {
|
||||
item.title = strings.TrimSpace(ep.Title)
|
||||
item.filler = ep.Filler
|
||||
item.recap = ep.Recap
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func availableEpisodeNumbers(availability domain.EpisodeAvailability, expectedCount int) map[int]bool {
|
||||
numbers := map[int]bool{}
|
||||
for _, number := range availability.Sub {
|
||||
if number > 0 && !exceedsExpectedCount(number, expectedCount) {
|
||||
numbers[number] = true
|
||||
}
|
||||
}
|
||||
for _, number := range availability.Dub {
|
||||
if number > 0 && !exceedsExpectedCount(number, expectedCount) {
|
||||
numbers[number] = true
|
||||
}
|
||||
}
|
||||
return numbers
|
||||
}
|
||||
|
||||
func mergeEpisode(byNumber *map[int]episodePartial, number int, update func(*episodePartial)) {
|
||||
item := (*byNumber)[number]
|
||||
update(&item)
|
||||
|
||||
116
internal/episodes/service/policy_merge_extra_test.go
Normal file
116
internal/episodes/service/policy_merge_extra_test.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"mal/integrations/jikan"
|
||||
"mal/internal/domain"
|
||||
)
|
||||
|
||||
func TestMergeEpisodesFiltersProviderBackedJikanToAvailableNumbers(t *testing.T) {
|
||||
episodes := mergeEpisodes([]jikan.Episode{
|
||||
{Episode: "1", Title: "Available"},
|
||||
{Episode: "2", Title: "Unavailable"},
|
||||
{Episode: "3", Title: "Dubbed"},
|
||||
}, domain.EpisodeAvailability{
|
||||
Sub: []int{1},
|
||||
Dub: []int{3},
|
||||
}, 0)
|
||||
|
||||
if len(episodes) != 2 {
|
||||
t.Fatalf("len(episodes) = %d, want 2", len(episodes))
|
||||
}
|
||||
assertEpisode(t, episodes[0], 1, "Available", true, false, true, false)
|
||||
assertEpisode(t, episodes[1], 3, "Dubbed", false, true, false, false)
|
||||
}
|
||||
|
||||
func TestMergeEpisodesHonorsExpectedCountForAvailability(t *testing.T) {
|
||||
episodes := mergeEpisodes(nil, domain.EpisodeAvailability{
|
||||
Sub: []int{0, 1, 2, 4},
|
||||
Dub: []int{-1, 2, 3, 5},
|
||||
}, 3)
|
||||
|
||||
if len(episodes) != 3 {
|
||||
t.Fatalf("len(episodes) = %d, want 3", len(episodes))
|
||||
}
|
||||
assertEpisode(t, episodes[0], 1, "Episode 1", true, false, true, false)
|
||||
assertEpisode(t, episodes[1], 2, "Episode 2", true, true, false, false)
|
||||
assertEpisode(t, episodes[2], 3, "Episode 3", false, true, false, false)
|
||||
}
|
||||
|
||||
func TestIsCanonicalEpisodePayloadValidAllowsProviderPayloadWhenNoExpectedCount(t *testing.T) {
|
||||
payload := domain.CanonicalEpisodeList{
|
||||
Source: "AllAnime",
|
||||
Episodes: []domain.CanonicalEpisode{
|
||||
{Number: 1, HasSub: true},
|
||||
{Number: 2, HasDub: true},
|
||||
},
|
||||
}
|
||||
|
||||
if !isCanonicalEpisodePayloadValid(payload, 0) {
|
||||
t.Fatalf("expected provider-backed payload with availability to be valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsCanonicalEpisodePayloadValidRejectsOutOfRangeEpisodeNumber(t *testing.T) {
|
||||
payload := domain.CanonicalEpisodeList{
|
||||
Episodes: []domain.CanonicalEpisode{
|
||||
{Number: 0, Title: "Invalid"},
|
||||
},
|
||||
}
|
||||
|
||||
if isCanonicalEpisodePayloadValid(payload, 12) {
|
||||
t.Fatalf("expected zero episode number to be invalid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextRefreshAtForFinishedAnimeIsEmpty(t *testing.T) {
|
||||
now := time.Date(2026, 5, 16, 13, 0, 0, 0, time.UTC)
|
||||
got := nextRefreshAt(domain.Anime{Anime: jikan.Anime{Airing: false}}, now)
|
||||
|
||||
if got.Valid {
|
||||
t.Fatalf("nextRefreshAt finished anime = %s, want invalid", got.Time)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextRefreshAtRetriesSoonAfterRecentBroadcast(t *testing.T) {
|
||||
anime := domain.Anime{Anime: jikan.Anime{Airing: true}}
|
||||
anime.Broadcast.Day = "Saturdays"
|
||||
anime.Broadcast.Time = "12:00"
|
||||
anime.Broadcast.Timezone = "UTC"
|
||||
now := time.Date(2026, 5, 16, 13, 0, 0, 0, time.UTC)
|
||||
|
||||
got := nextRefreshAt(anime, now)
|
||||
want := now.Add(retryInterval).UTC()
|
||||
if !got.Valid || !got.Time.Equal(want) {
|
||||
t.Fatalf("nextRefreshAt = %#v, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextRefreshAtFallsBackWhenBroadcastMetadataMissing(t *testing.T) {
|
||||
anime := domain.Anime{Anime: jikan.Anime{Airing: true}}
|
||||
now := time.Date(2026, 5, 16, 13, 0, 0, 0, time.UTC)
|
||||
|
||||
got := nextRefreshAt(anime, now)
|
||||
want := now.Add(airingFallbackRefreshInterval).UTC()
|
||||
if !got.Valid || !got.Time.Equal(want) {
|
||||
t.Fatalf("nextRefreshAt = %#v, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcastHelpersRejectInvalidValues(t *testing.T) {
|
||||
if day := weekdayFromJikan("someday"); day != -1 {
|
||||
t.Fatalf("weekdayFromJikan invalid = %d, want -1", day)
|
||||
}
|
||||
if _, _, ok := parseBroadcastTime("25:99"); ok {
|
||||
t.Fatalf("parseBroadcastTime should reject invalid time")
|
||||
}
|
||||
|
||||
anime := domain.Anime{Anime: jikan.Anime{MalID: 1}}
|
||||
anime.Broadcast.Day = "Saturdays"
|
||||
anime.Broadcast.Time = "bad"
|
||||
if got := nextBroadcastAfter(anime, time.Date(2026, 5, 16, 13, 0, 0, 0, time.UTC)); !got.IsZero() {
|
||||
t.Fatalf("nextBroadcastAfter invalid time = %s, want zero", got)
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,6 @@ func (s *EpisodeService) cachedProviderID(ctx context.Context, anime domain.Anim
|
||||
Provider: provider.Name(),
|
||||
})
|
||||
if err != nil {
|
||||
s.metrics.ObserveCache("episode_provider_mapping", "miss")
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", false, nil
|
||||
}
|
||||
@@ -63,15 +62,12 @@ func (s *EpisodeService) cachedProviderID(ctx context.Context, anime domain.Anim
|
||||
}
|
||||
|
||||
if row.FailedUntil.Valid && row.FailedUntil.Time.After(s.clock.Now()) {
|
||||
s.metrics.ObserveCache("episode_provider_mapping", "hit")
|
||||
return "", true, fmt.Errorf("cached provider mapping failure active until %s: %s", row.FailedUntil.Time.Format(time.RFC3339), row.LastError)
|
||||
}
|
||||
if strings.TrimSpace(row.ProviderShowID) == "" {
|
||||
s.metrics.ObserveCache("episode_provider_mapping", "miss")
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
s.metrics.ObserveCache("episode_provider_mapping", "hit")
|
||||
observability.Info(
|
||||
"episodes_provider_id_cache_hit",
|
||||
"episodes",
|
||||
@@ -86,13 +82,27 @@ func (s *EpisodeService) cachedProviderID(ctx context.Context, anime domain.Anim
|
||||
}
|
||||
|
||||
func (s *EpisodeService) cacheProviderIDFailure(ctx context.Context, anime domain.Anime, provider domain.EpisodeAvailabilityProvider, resolveErr error) {
|
||||
_ = s.queries.UpsertEpisodeProviderMapping(ctx, db.UpsertEpisodeProviderMappingParams{
|
||||
err := s.queries.UpsertEpisodeProviderMapping(ctx, db.UpsertEpisodeProviderMappingParams{
|
||||
AnimeID: int64(anime.MalID),
|
||||
Provider: provider.Name(),
|
||||
ProviderShowID: "",
|
||||
FailedUntil: sql.NullTime{Time: s.clock.Now().Add(time.Hour), Valid: true},
|
||||
LastError: truncate(resolveErr.Error(), 400),
|
||||
})
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
observability.Warn(
|
||||
"episodes_provider_id_cache_write_failed",
|
||||
"episodes",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": anime.MalID,
|
||||
"provider": provider.Name(),
|
||||
},
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
func (s *EpisodeService) cacheProviderIDSuccess(ctx context.Context, anime domain.Anime, provider domain.EpisodeAvailabilityProvider, providerID string) {
|
||||
|
||||
@@ -26,21 +26,19 @@ type EpisodeService struct {
|
||||
providers []domain.EpisodeAvailabilityProvider
|
||||
clock Clock
|
||||
enabled bool
|
||||
metrics *observability.Metrics
|
||||
}
|
||||
|
||||
func NewEpisodeService(queries *db.Queries, jikanClient *jikan.Client, providers []domain.EpisodeAvailabilityProvider, enabled bool, metrics *observability.Metrics) domain.EpisodeService {
|
||||
return NewEpisodeServiceWithClock(queries, jikanClient, providers, enabled, realClock{}, metrics)
|
||||
func NewEpisodeService(queries *db.Queries, jikanClient *jikan.Client, providers []domain.EpisodeAvailabilityProvider, enabled bool) domain.EpisodeService {
|
||||
return NewEpisodeServiceWithClock(queries, jikanClient, providers, enabled, realClock{})
|
||||
}
|
||||
|
||||
func NewEpisodeServiceWithClock(queries *db.Queries, jikanClient *jikan.Client, providers []domain.EpisodeAvailabilityProvider, enabled bool, clock Clock, metrics *observability.Metrics) *EpisodeService {
|
||||
func NewEpisodeServiceWithClock(queries *db.Queries, jikanClient *jikan.Client, providers []domain.EpisodeAvailabilityProvider, enabled bool, clock Clock) *EpisodeService {
|
||||
return &EpisodeService{
|
||||
queries: queries,
|
||||
jikan: jikanClient,
|
||||
providers: providers,
|
||||
clock: clock,
|
||||
enabled: enabled,
|
||||
metrics: metrics,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,10 +138,10 @@ func (s *EpisodeService) refresh(ctx context.Context, anime domain.Anime) (domai
|
||||
)
|
||||
}
|
||||
|
||||
providerAvailability, source, providerErr := s.fetchProviderAvailability(ctx, anime)
|
||||
availability, source, providerErr := s.fetchProviderAvailability(ctx, anime)
|
||||
if providerErr != nil {
|
||||
s.markFailure(ctx, anime, providerErr)
|
||||
if cached, ok := s.getCached(ctx, anime.MalID); ok {
|
||||
if cached, ok := s.getDecodedCached(ctx, anime); ok {
|
||||
observability.Warn(
|
||||
"episodes_provider_failed_serving_stale_cache",
|
||||
"episodes",
|
||||
@@ -167,7 +165,7 @@ func (s *EpisodeService) refresh(ctx context.Context, anime domain.Anime) (domai
|
||||
return domain.CanonicalEpisodeList{}, providerErr
|
||||
}
|
||||
|
||||
return s.store(ctx, anime, jikanEpisodes, providerAvailability, source, now, true)
|
||||
return s.store(ctx, anime, jikanEpisodes, availability, source, now, true)
|
||||
}
|
||||
|
||||
func (s *EpisodeService) fetchProviderAvailability(ctx context.Context, anime domain.Anime) (domain.EpisodeAvailability, string, error) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user