From 2167955bb2ceaf7620c905787008bd8d81eaf8d8 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Wed, 13 May 2026 10:29:34 +0200 Subject: [PATCH 01/71] feat: setup goose and database module --- go.mod | 42 ++++++++- go.sum | 87 +++++++++++++++++++ internal/database/database.go | 55 ++++++++++++ internal/database/migrations/001_init.sql | 42 +++++++++ .../migrations/002_add_anime_titles.sql | 6 ++ .../migrations/003_add_anime_airing.sql | 2 + .../migrations/004_add_notifications.sql | 10 +++ .../migrations/005_add_anime_relations.sql | 9 ++ .../migrations/006_add_jikan_cache.sql | 6 ++ .../migrations/007_add_query_indexes.sql | 11 +++ .../migrations/009_add_anime_fetch_retry.sql | 11 +++ .../010_add_watch_progress_seconds.sql | 1 + .../migrations/011_add_continue_watching.sql | 13 +++ .../migrations/012_remove_recovery_key.sql | 22 +++++ .../database/migrations/013_drop_account.sql | 2 + .../migrations/014_add_watchlist_statuses.sql | 26 ++++++ .../database/migrations/015_add_duration.sql | 5 ++ .../migrations/016_add_avatar_url.sql | 3 + 18 files changed, 350 insertions(+), 3 deletions(-) create mode 100644 internal/database/database.go create mode 100644 internal/database/migrations/001_init.sql create mode 100644 internal/database/migrations/002_add_anime_titles.sql create mode 100644 internal/database/migrations/003_add_anime_airing.sql create mode 100644 internal/database/migrations/004_add_notifications.sql create mode 100644 internal/database/migrations/005_add_anime_relations.sql create mode 100644 internal/database/migrations/006_add_jikan_cache.sql create mode 100644 internal/database/migrations/007_add_query_indexes.sql create mode 100644 internal/database/migrations/009_add_anime_fetch_retry.sql create mode 100644 internal/database/migrations/010_add_watch_progress_seconds.sql create mode 100644 internal/database/migrations/011_add_continue_watching.sql create mode 100644 internal/database/migrations/012_remove_recovery_key.sql create mode 100644 internal/database/migrations/013_drop_account.sql create mode 100644 internal/database/migrations/014_add_watchlist_statuses.sql create mode 100644 internal/database/migrations/015_add_duration.sql create mode 100644 internal/database/migrations/016_add_avatar_url.sql diff --git a/go.mod b/go.mod index 841f11a..df19640 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module mal -go 1.25.0 +go 1.25.7 require ( github.com/PuerkitoBio/goquery v1.11.0 @@ -15,9 +15,45 @@ require ( require github.com/hashicorp/golang-lru/v2 v2.0.7 require ( - github.com/andybalholm/brotli v1.1.0 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/gin-gonic/gin v1.12.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.21 // indirect + github.com/mfridman/interpolate v0.0.2 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pressly/goose/v3 v3.27.1 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/sethvargo/go-retry v0.3.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + go.uber.org/dig v1.19.0 // indirect + go.uber.org/fx v1.24.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.26.0 // indirect + golang.org/x/arch v0.22.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) + +require ( + github.com/andybalholm/brotli v1.2.1 // indirect github.com/andybalholm/cascadia v1.3.3 // indirect - github.com/klauspost/compress v1.17.4 // 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/text v0.36.0 // indirect diff --git a/go.sum b/go.sum index 01abc20..843a244 100644 --- a/go.sum +++ b/go.sum @@ -2,22 +2,104 @@ github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43 github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ= github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= +github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= +github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mattn/go-sqlite3 v1.14.40 h1:f7+saIsbq4EF86mUqe0uiecQOJYMOdfi5uATADmUG94= github.com/mattn/go-sqlite3 v1.14.40/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= +github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= +github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4= +github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo= github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= +github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= +github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4= +go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= +go.uber.org/fx v1.24.0 h1:wE8mruvpg2kiiL1Vqd0CC+tr0/24XIB10Iwp2lLWzkg= +go.uber.org/fx v1.24.0/go.mod h1:AmDeGyS+ZARGKM4tlH4FY2Jr63VjbEDJHtqXTGP5hbo= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= +go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= @@ -91,3 +173,8 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/database/database.go b/internal/database/database.go new file mode 100644 index 0000000..353087d --- /dev/null +++ b/internal/database/database.go @@ -0,0 +1,55 @@ +package database + +import ( + "database/sql" + "embed" + "fmt" + "log" + "mal/internal/db" + "os" + + "github.com/pressly/goose/v3" + "go.uber.org/fx" +) + +//go:embed migrations/*.sql +var migrationsFS embed.FS + +var Module = fx.Options( + fx.Provide( + ProvideSQLDB, + ProvideQueries, + ), + fx.Invoke(RunMigrations), +) + +func ProvideSQLDB() (*sql.DB, error) { + dbPath := os.Getenv("DB_PATH") + if dbPath == "" { + dbPath = "mal.db" + } + dbConn, err := db.Open(dbPath) + if err != nil { + return nil, fmt.Errorf("failed to open database: %w", err) + } + return dbConn, nil +} + +func ProvideQueries(sqlDB *sql.DB) *db.Queries { + return db.New(sqlDB) +} + +func RunMigrations(sqlDB *sql.DB) error { + goose.SetBaseFS(migrationsFS) + + if err := goose.SetDialect("sqlite3"); err != nil { + return fmt.Errorf("failed to set goose dialect: %w", err) + } + + log.Println("Running database migrations...") + if err := goose.Up(sqlDB, "migrations"); err != nil { + return fmt.Errorf("failed to run migrations: %w", err) + } + + return nil +} diff --git a/internal/database/migrations/001_init.sql b/internal/database/migrations/001_init.sql new file mode 100644 index 0000000..f3a2932 --- /dev/null +++ b/internal/database/migrations/001_init.sql @@ -0,0 +1,42 @@ +CREATE TABLE IF NOT EXISTS user ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS session ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE, + expires_at DATETIME NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS account ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + provider_account_id TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(provider, provider_account_id) +); + +CREATE TABLE IF NOT EXISTS anime ( + id INTEGER PRIMARY KEY, -- Jikan ID + title TEXT NOT NULL, + image_url TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS watch_list_entry ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE, + anime_id INTEGER NOT NULL REFERENCES anime(id) ON DELETE CASCADE, + status TEXT NOT NULL CHECK(status IN ('completed', 'dropped', 'plan_to_watch')), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + current_episode INTEGER DEFAULT 0, + last_episode_at DATETIME, + current_time_seconds REAL NOT NULL DEFAULT 0, + UNIQUE(user_id, anime_id) +); diff --git a/internal/database/migrations/002_add_anime_titles.sql b/internal/database/migrations/002_add_anime_titles.sql new file mode 100644 index 0000000..a1f2564 --- /dev/null +++ b/internal/database/migrations/002_add_anime_titles.sql @@ -0,0 +1,6 @@ +-- Add English and Japanese title columns to anime table +ALTER TABLE anime ADD COLUMN title_english TEXT; +ALTER TABLE anime ADD COLUMN title_japanese TEXT; + +-- Rename existing title to title_original for clarity +ALTER TABLE anime RENAME COLUMN title TO title_original; diff --git a/internal/database/migrations/003_add_anime_airing.sql b/internal/database/migrations/003_add_anime_airing.sql new file mode 100644 index 0000000..8f74ee6 --- /dev/null +++ b/internal/database/migrations/003_add_anime_airing.sql @@ -0,0 +1,2 @@ +-- Add airing status column to anime table +ALTER TABLE anime ADD COLUMN airing BOOLEAN DEFAULT 0; diff --git a/internal/database/migrations/004_add_notifications.sql b/internal/database/migrations/004_add_notifications.sql new file mode 100644 index 0000000..a51e1ce --- /dev/null +++ b/internal/database/migrations/004_add_notifications.sql @@ -0,0 +1,10 @@ +-- Note: watch_list_entry columns now in 001_init.sql + +-- Add notification preferences +CREATE TABLE IF NOT EXISTS notification_preference ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE, + notify_new_episodes BOOLEAN NOT NULL DEFAULT TRUE, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id) +); diff --git a/internal/database/migrations/005_add_anime_relations.sql b/internal/database/migrations/005_add_anime_relations.sql new file mode 100644 index 0000000..11a82ee --- /dev/null +++ b/internal/database/migrations/005_add_anime_relations.sql @@ -0,0 +1,9 @@ +ALTER TABLE anime ADD COLUMN status TEXT DEFAULT ''; +ALTER TABLE anime ADD COLUMN relations_synced_at DATETIME; + +CREATE TABLE IF NOT EXISTS anime_relation ( + anime_id INTEGER NOT NULL REFERENCES anime(id) ON DELETE CASCADE, + related_anime_id INTEGER NOT NULL, + relation_type TEXT NOT NULL, + PRIMARY KEY (anime_id, related_anime_id) +); diff --git a/internal/database/migrations/006_add_jikan_cache.sql b/internal/database/migrations/006_add_jikan_cache.sql new file mode 100644 index 0000000..bc4852a --- /dev/null +++ b/internal/database/migrations/006_add_jikan_cache.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS jikan_cache ( + key TEXT PRIMARY KEY, + data TEXT NOT NULL, + expires_at DATETIME NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/internal/database/migrations/007_add_query_indexes.sql b/internal/database/migrations/007_add_query_indexes.sql new file mode 100644 index 0000000..206396f --- /dev/null +++ b/internal/database/migrations/007_add_query_indexes.sql @@ -0,0 +1,11 @@ +CREATE INDEX IF NOT EXISTS idx_watch_list_entry_user_status_updated_at +ON watch_list_entry(user_id, status, updated_at); + +CREATE INDEX IF NOT EXISTS idx_anime_relation_anime_id_relation_type +ON anime_relation(anime_id, relation_type); + +CREATE INDEX IF NOT EXISTS idx_anime_relations_synced_at_status +ON anime(relations_synced_at, status); + +CREATE INDEX IF NOT EXISTS idx_jikan_cache_expires_at +ON jikan_cache(expires_at); diff --git a/internal/database/migrations/009_add_anime_fetch_retry.sql b/internal/database/migrations/009_add_anime_fetch_retry.sql new file mode 100644 index 0000000..ffbbe40 --- /dev/null +++ b/internal/database/migrations/009_add_anime_fetch_retry.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS anime_fetch_retry ( + anime_id INTEGER PRIMARY KEY, + attempts INTEGER NOT NULL DEFAULT 0, + next_retry_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_error TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_anime_fetch_retry_next_retry_at +ON anime_fetch_retry(next_retry_at); diff --git a/internal/database/migrations/010_add_watch_progress_seconds.sql b/internal/database/migrations/010_add_watch_progress_seconds.sql new file mode 100644 index 0000000..c29e82b --- /dev/null +++ b/internal/database/migrations/010_add_watch_progress_seconds.sql @@ -0,0 +1 @@ +-- Note: watch_list_entry columns now in 001_init.sql \ No newline at end of file diff --git a/internal/database/migrations/011_add_continue_watching.sql b/internal/database/migrations/011_add_continue_watching.sql new file mode 100644 index 0000000..d12b2af --- /dev/null +++ b/internal/database/migrations/011_add_continue_watching.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS continue_watching_entry ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE, + anime_id INTEGER NOT NULL REFERENCES anime(id) ON DELETE CASCADE, + current_episode INTEGER, + current_time_seconds REAL NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, anime_id) +); + +CREATE INDEX IF NOT EXISTS idx_continue_watching_user_updated +ON continue_watching_entry(user_id, updated_at DESC); diff --git a/internal/database/migrations/012_remove_recovery_key.sql b/internal/database/migrations/012_remove_recovery_key.sql new file mode 100644 index 0000000..e60c060 --- /dev/null +++ b/internal/database/migrations/012_remove_recovery_key.sql @@ -0,0 +1,22 @@ +PRAGMA foreign_keys = OFF; + +BEGIN TRANSACTION; + +CREATE TABLE user_new ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +INSERT INTO user_new (id, username, password_hash, created_at) +SELECT id, username, password_hash, created_at +FROM user; + +DROP TABLE user; + +ALTER TABLE user_new RENAME TO user; + +COMMIT; + +PRAGMA foreign_keys = ON; diff --git a/internal/database/migrations/013_drop_account.sql b/internal/database/migrations/013_drop_account.sql new file mode 100644 index 0000000..6a6cca9 --- /dev/null +++ b/internal/database/migrations/013_drop_account.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS account; +DROP TABLE IF EXISTS notification_preference; \ No newline at end of file diff --git a/internal/database/migrations/014_add_watchlist_statuses.sql b/internal/database/migrations/014_add_watchlist_statuses.sql new file mode 100644 index 0000000..b0d8bd1 --- /dev/null +++ b/internal/database/migrations/014_add_watchlist_statuses.sql @@ -0,0 +1,26 @@ +-- Add "watching" and "on_hold" to the valid statuses for watch_list_entry + +PRAGMA foreign_keys=OFF; + +ALTER TABLE watch_list_entry RENAME TO watch_list_entry_old; + +CREATE TABLE IF NOT EXISTS watch_list_entry ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE, + anime_id INTEGER NOT NULL REFERENCES anime(id) ON DELETE CASCADE, + status TEXT NOT NULL CHECK(status IN ('watching', 'completed', 'dropped', 'plan_to_watch', 'on_hold')), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + current_episode INTEGER DEFAULT 0, + last_episode_at DATETIME, + current_time_seconds REAL NOT NULL DEFAULT 0, + UNIQUE(user_id, anime_id) +); + +INSERT OR IGNORE INTO watch_list_entry (id, user_id, anime_id, status, created_at, updated_at, current_episode, last_episode_at, current_time_seconds) +SELECT id, user_id, anime_id, status, created_at, updated_at, current_episode, last_episode_at, current_time_seconds +FROM watch_list_entry_old; + +DROP TABLE watch_list_entry_old; + +PRAGMA foreign_keys=ON; diff --git a/internal/database/migrations/015_add_duration.sql b/internal/database/migrations/015_add_duration.sql new file mode 100644 index 0000000..80057a5 --- /dev/null +++ b/internal/database/migrations/015_add_duration.sql @@ -0,0 +1,5 @@ +-- Add duration column to anime table to store episode duration in seconds +ALTER TABLE anime ADD COLUMN duration_seconds REAL; + +-- Add duration_seconds column to continue_watching_entry to track episode duration +ALTER TABLE continue_watching_entry ADD COLUMN duration_seconds REAL; \ No newline at end of file diff --git a/internal/database/migrations/016_add_avatar_url.sql b/internal/database/migrations/016_add_avatar_url.sql new file mode 100644 index 0000000..bb153ac --- /dev/null +++ b/internal/database/migrations/016_add_avatar_url.sql @@ -0,0 +1,3 @@ +ALTER TABLE user ADD COLUMN avatar_url TEXT NOT NULL DEFAULT ''; + +UPDATE user SET avatar_url = 'https://api.dicebear.com/9.x/dylan/svg?seed=' || username WHERE avatar_url = ''; \ No newline at end of file From 17b9fd76864090040fa09fedcc3d390a6cb0c5fc Mon Sep 17 00:00:00 2001 From: mkelvers Date: Wed, 13 May 2026 10:29:37 +0200 Subject: [PATCH 02/71] refactor: remove old migrations directory --- migrations/001_init.sql | 42 ------------------- migrations/002_add_anime_titles.sql | 6 --- migrations/003_add_anime_airing.sql | 2 - migrations/004_add_notifications.sql | 10 ----- migrations/005_add_anime_relations.sql | 9 ---- migrations/006_add_jikan_cache.sql | 6 --- migrations/007_add_query_indexes.sql | 11 ----- migrations/009_add_anime_fetch_retry.sql | 11 ----- migrations/010_add_watch_progress_seconds.sql | 1 - migrations/011_add_continue_watching.sql | 13 ------ migrations/012_remove_recovery_key.sql | 22 ---------- migrations/013_drop_account.sql | 2 - migrations/014_add_watchlist_statuses.sql | 26 ------------ migrations/015_add_duration.sql | 5 --- migrations/016_add_avatar_url.sql | 3 -- 15 files changed, 169 deletions(-) delete mode 100644 migrations/001_init.sql delete mode 100644 migrations/002_add_anime_titles.sql delete mode 100644 migrations/003_add_anime_airing.sql delete mode 100644 migrations/004_add_notifications.sql delete mode 100644 migrations/005_add_anime_relations.sql delete mode 100644 migrations/006_add_jikan_cache.sql delete mode 100644 migrations/007_add_query_indexes.sql delete mode 100644 migrations/009_add_anime_fetch_retry.sql delete mode 100644 migrations/010_add_watch_progress_seconds.sql delete mode 100644 migrations/011_add_continue_watching.sql delete mode 100644 migrations/012_remove_recovery_key.sql delete mode 100644 migrations/013_drop_account.sql delete mode 100644 migrations/014_add_watchlist_statuses.sql delete mode 100644 migrations/015_add_duration.sql delete mode 100644 migrations/016_add_avatar_url.sql diff --git a/migrations/001_init.sql b/migrations/001_init.sql deleted file mode 100644 index f3a2932..0000000 --- a/migrations/001_init.sql +++ /dev/null @@ -1,42 +0,0 @@ -CREATE TABLE IF NOT EXISTS user ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL UNIQUE, - password_hash TEXT NOT NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP -); - -CREATE TABLE IF NOT EXISTS session ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE, - expires_at DATETIME NOT NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP -); - -CREATE TABLE IF NOT EXISTS account ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE, - provider TEXT NOT NULL, - provider_account_id TEXT NOT NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - UNIQUE(provider, provider_account_id) -); - -CREATE TABLE IF NOT EXISTS anime ( - id INTEGER PRIMARY KEY, -- Jikan ID - title TEXT NOT NULL, - image_url TEXT NOT NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP -); - -CREATE TABLE IF NOT EXISTS watch_list_entry ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE, - anime_id INTEGER NOT NULL REFERENCES anime(id) ON DELETE CASCADE, - status TEXT NOT NULL CHECK(status IN ('completed', 'dropped', 'plan_to_watch')), - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - current_episode INTEGER DEFAULT 0, - last_episode_at DATETIME, - current_time_seconds REAL NOT NULL DEFAULT 0, - UNIQUE(user_id, anime_id) -); diff --git a/migrations/002_add_anime_titles.sql b/migrations/002_add_anime_titles.sql deleted file mode 100644 index a1f2564..0000000 --- a/migrations/002_add_anime_titles.sql +++ /dev/null @@ -1,6 +0,0 @@ --- Add English and Japanese title columns to anime table -ALTER TABLE anime ADD COLUMN title_english TEXT; -ALTER TABLE anime ADD COLUMN title_japanese TEXT; - --- Rename existing title to title_original for clarity -ALTER TABLE anime RENAME COLUMN title TO title_original; diff --git a/migrations/003_add_anime_airing.sql b/migrations/003_add_anime_airing.sql deleted file mode 100644 index 8f74ee6..0000000 --- a/migrations/003_add_anime_airing.sql +++ /dev/null @@ -1,2 +0,0 @@ --- Add airing status column to anime table -ALTER TABLE anime ADD COLUMN airing BOOLEAN DEFAULT 0; diff --git a/migrations/004_add_notifications.sql b/migrations/004_add_notifications.sql deleted file mode 100644 index a51e1ce..0000000 --- a/migrations/004_add_notifications.sql +++ /dev/null @@ -1,10 +0,0 @@ --- Note: watch_list_entry columns now in 001_init.sql - --- Add notification preferences -CREATE TABLE IF NOT EXISTS notification_preference ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE, - notify_new_episodes BOOLEAN NOT NULL DEFAULT TRUE, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - UNIQUE(user_id) -); diff --git a/migrations/005_add_anime_relations.sql b/migrations/005_add_anime_relations.sql deleted file mode 100644 index 11a82ee..0000000 --- a/migrations/005_add_anime_relations.sql +++ /dev/null @@ -1,9 +0,0 @@ -ALTER TABLE anime ADD COLUMN status TEXT DEFAULT ''; -ALTER TABLE anime ADD COLUMN relations_synced_at DATETIME; - -CREATE TABLE IF NOT EXISTS anime_relation ( - anime_id INTEGER NOT NULL REFERENCES anime(id) ON DELETE CASCADE, - related_anime_id INTEGER NOT NULL, - relation_type TEXT NOT NULL, - PRIMARY KEY (anime_id, related_anime_id) -); diff --git a/migrations/006_add_jikan_cache.sql b/migrations/006_add_jikan_cache.sql deleted file mode 100644 index bc4852a..0000000 --- a/migrations/006_add_jikan_cache.sql +++ /dev/null @@ -1,6 +0,0 @@ -CREATE TABLE IF NOT EXISTS jikan_cache ( - key TEXT PRIMARY KEY, - data TEXT NOT NULL, - expires_at DATETIME NOT NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP -); diff --git a/migrations/007_add_query_indexes.sql b/migrations/007_add_query_indexes.sql deleted file mode 100644 index 206396f..0000000 --- a/migrations/007_add_query_indexes.sql +++ /dev/null @@ -1,11 +0,0 @@ -CREATE INDEX IF NOT EXISTS idx_watch_list_entry_user_status_updated_at -ON watch_list_entry(user_id, status, updated_at); - -CREATE INDEX IF NOT EXISTS idx_anime_relation_anime_id_relation_type -ON anime_relation(anime_id, relation_type); - -CREATE INDEX IF NOT EXISTS idx_anime_relations_synced_at_status -ON anime(relations_synced_at, status); - -CREATE INDEX IF NOT EXISTS idx_jikan_cache_expires_at -ON jikan_cache(expires_at); diff --git a/migrations/009_add_anime_fetch_retry.sql b/migrations/009_add_anime_fetch_retry.sql deleted file mode 100644 index ffbbe40..0000000 --- a/migrations/009_add_anime_fetch_retry.sql +++ /dev/null @@ -1,11 +0,0 @@ -CREATE TABLE IF NOT EXISTS anime_fetch_retry ( - anime_id INTEGER PRIMARY KEY, - attempts INTEGER NOT NULL DEFAULT 0, - next_retry_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - last_error TEXT NOT NULL DEFAULT '', - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX IF NOT EXISTS idx_anime_fetch_retry_next_retry_at -ON anime_fetch_retry(next_retry_at); diff --git a/migrations/010_add_watch_progress_seconds.sql b/migrations/010_add_watch_progress_seconds.sql deleted file mode 100644 index c29e82b..0000000 --- a/migrations/010_add_watch_progress_seconds.sql +++ /dev/null @@ -1 +0,0 @@ --- Note: watch_list_entry columns now in 001_init.sql \ No newline at end of file diff --git a/migrations/011_add_continue_watching.sql b/migrations/011_add_continue_watching.sql deleted file mode 100644 index d12b2af..0000000 --- a/migrations/011_add_continue_watching.sql +++ /dev/null @@ -1,13 +0,0 @@ -CREATE TABLE IF NOT EXISTS continue_watching_entry ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE, - anime_id INTEGER NOT NULL REFERENCES anime(id) ON DELETE CASCADE, - current_episode INTEGER, - current_time_seconds REAL NOT NULL DEFAULT 0, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - UNIQUE(user_id, anime_id) -); - -CREATE INDEX IF NOT EXISTS idx_continue_watching_user_updated -ON continue_watching_entry(user_id, updated_at DESC); diff --git a/migrations/012_remove_recovery_key.sql b/migrations/012_remove_recovery_key.sql deleted file mode 100644 index e60c060..0000000 --- a/migrations/012_remove_recovery_key.sql +++ /dev/null @@ -1,22 +0,0 @@ -PRAGMA foreign_keys = OFF; - -BEGIN TRANSACTION; - -CREATE TABLE user_new ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL UNIQUE, - password_hash TEXT NOT NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP -); - -INSERT INTO user_new (id, username, password_hash, created_at) -SELECT id, username, password_hash, created_at -FROM user; - -DROP TABLE user; - -ALTER TABLE user_new RENAME TO user; - -COMMIT; - -PRAGMA foreign_keys = ON; diff --git a/migrations/013_drop_account.sql b/migrations/013_drop_account.sql deleted file mode 100644 index 6a6cca9..0000000 --- a/migrations/013_drop_account.sql +++ /dev/null @@ -1,2 +0,0 @@ -DROP TABLE IF EXISTS account; -DROP TABLE IF EXISTS notification_preference; \ No newline at end of file diff --git a/migrations/014_add_watchlist_statuses.sql b/migrations/014_add_watchlist_statuses.sql deleted file mode 100644 index b0d8bd1..0000000 --- a/migrations/014_add_watchlist_statuses.sql +++ /dev/null @@ -1,26 +0,0 @@ --- Add "watching" and "on_hold" to the valid statuses for watch_list_entry - -PRAGMA foreign_keys=OFF; - -ALTER TABLE watch_list_entry RENAME TO watch_list_entry_old; - -CREATE TABLE IF NOT EXISTS watch_list_entry ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE, - anime_id INTEGER NOT NULL REFERENCES anime(id) ON DELETE CASCADE, - status TEXT NOT NULL CHECK(status IN ('watching', 'completed', 'dropped', 'plan_to_watch', 'on_hold')), - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - current_episode INTEGER DEFAULT 0, - last_episode_at DATETIME, - current_time_seconds REAL NOT NULL DEFAULT 0, - UNIQUE(user_id, anime_id) -); - -INSERT OR IGNORE INTO watch_list_entry (id, user_id, anime_id, status, created_at, updated_at, current_episode, last_episode_at, current_time_seconds) -SELECT id, user_id, anime_id, status, created_at, updated_at, current_episode, last_episode_at, current_time_seconds -FROM watch_list_entry_old; - -DROP TABLE watch_list_entry_old; - -PRAGMA foreign_keys=ON; diff --git a/migrations/015_add_duration.sql b/migrations/015_add_duration.sql deleted file mode 100644 index 80057a5..0000000 --- a/migrations/015_add_duration.sql +++ /dev/null @@ -1,5 +0,0 @@ --- Add duration column to anime table to store episode duration in seconds -ALTER TABLE anime ADD COLUMN duration_seconds REAL; - --- Add duration_seconds column to continue_watching_entry to track episode duration -ALTER TABLE continue_watching_entry ADD COLUMN duration_seconds REAL; \ No newline at end of file diff --git a/migrations/016_add_avatar_url.sql b/migrations/016_add_avatar_url.sql deleted file mode 100644 index bb153ac..0000000 --- a/migrations/016_add_avatar_url.sql +++ /dev/null @@ -1,3 +0,0 @@ -ALTER TABLE user ADD COLUMN avatar_url TEXT NOT NULL DEFAULT ''; - -UPDATE user SET avatar_url = 'https://api.dicebear.com/9.x/dylan/svg?seed=' || username WHERE avatar_url = ''; \ No newline at end of file From 83da0b3c2524dfd4a8161805f4106d11e7de0d0e Mon Sep 17 00:00:00 2001 From: mkelvers Date: Wed, 13 May 2026 10:29:50 +0200 Subject: [PATCH 03/71] feat: scaffold fx app and gin server --- internal/app/app.go | 19 ++++++++++ internal/server/server.go | 74 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 internal/app/app.go create mode 100644 internal/server/server.go diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..1d685af --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,19 @@ +package app + +import ( + "mal/internal/database" + "mal/internal/server" + + "github.com/gin-gonic/gin" + "go.uber.org/fx" +) + +func NewApp() *fx.App { + return fx.New( + database.Module, + server.Module, + fx.Invoke(func(r *gin.Engine, registers []server.RouteRegister) { + server.RegisterRoutes(r, registers) + }), + ) +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..42ddbaa --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,74 @@ +package server + +import ( + "context" + "fmt" + "log" + "net/http" + "os" + + "github.com/gin-gonic/gin" + "go.uber.org/fx" +) + +var Module = fx.Options( + fx.Provide(ProvideRouter), + fx.Invoke(RunServer), +) + +func ProvideRouter() *gin.Engine { + if os.Getenv("GIN_MODE") == "" { + gin.SetMode(gin.ReleaseMode) + } + r := gin.New() + r.Use(gin.Logger(), gin.Recovery()) + return r +} + +func RunServer(lifecycle fx.Lifecycle, r *gin.Engine) { + port := os.Getenv("PORT") + if port == "" { + port = "3000" + } + + srv := &http.Server{ + Addr: ":" + port, + Handler: r, + } + + lifecycle.Append(fx.Hook{ + OnStart: func(context.Context) error { + log.Printf("Starting server on http://localhost:%s", port) + go func() { + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("listen: %s\n", err) + } + }() + return nil + }, + OnStop: func(ctx context.Context) error { + log.Println("Shutting down server...") + return srv.Shutdown(ctx) + }, + }) +} + +// RouteRegister is an interface that modules can implement to register their routes. +type RouteRegister interface { + Register(r *gin.Engine) +} + +func RegisterRoutes(r *gin.Engine, registers []RouteRegister) { + for _, reg := range registers { + reg.Register(r) + } +} + +// AsRouteRegister is a helper to provide a RouteRegister to the fx group. +func AsRouteRegister(f any) any { + return fx.Annotate( + f, + fx.As(new(RouteRegister)), + fx.ResultTags(`group:"routes"`), + ) +} From 4dd27d6d52fb4a3b8f38ba975b910c6266d4133a Mon Sep 17 00:00:00 2001 From: mkelvers Date: Wed, 13 May 2026 10:30:26 +0200 Subject: [PATCH 04/71] feat: add jikan integration module --- integrations/jikan/module.go | 11 +++++++++++ internal/app/app.go | 1 + 2 files changed, 12 insertions(+) create mode 100644 integrations/jikan/module.go diff --git a/integrations/jikan/module.go b/integrations/jikan/module.go new file mode 100644 index 0000000..587b6dc --- /dev/null +++ b/integrations/jikan/module.go @@ -0,0 +1,11 @@ +package jikan + +import ( + "mal/internal/db" + + "go.uber.org/fx" +) + +var Module = fx.Options( + fx.Provide(NewClient), +) diff --git a/internal/app/app.go b/internal/app/app.go index 1d685af..35160ed 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -11,6 +11,7 @@ import ( func NewApp() *fx.App { return fx.New( database.Module, + jikan.Module, server.Module, fx.Invoke(func(r *gin.Engine, registers []server.RouteRegister) { server.RegisterRoutes(r, registers) From 424434a2a51fa18ca13f49b6419b65ca154fd72e Mon Sep 17 00:00:00 2001 From: mkelvers Date: Wed, 13 May 2026 10:30:47 +0200 Subject: [PATCH 05/71] feat: add modular template renderer --- internal/app/app.go | 6 + internal/server/server.go | 4 +- internal/templates/renderer.go | 208 +++++++++++++++++++++++++++++++++ 3 files changed, 217 insertions(+), 1 deletion(-) create mode 100644 internal/templates/renderer.go diff --git a/internal/app/app.go b/internal/app/app.go index 35160ed..61bc9e0 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -3,8 +3,10 @@ package app import ( "mal/internal/database" "mal/internal/server" + "mal/internal/templates" "github.com/gin-gonic/gin" + "github.com/gin-gonic/gin/render" "go.uber.org/fx" ) @@ -12,7 +14,11 @@ func NewApp() *fx.App { return fx.New( database.Module, jikan.Module, + templates.Module, server.Module, + fx.Decorate(func(r *templates.Renderer) render.HTMLRender { + return r + }), fx.Invoke(func(r *gin.Engine, registers []server.RouteRegister) { server.RegisterRoutes(r, registers) }), diff --git a/internal/server/server.go b/internal/server/server.go index 42ddbaa..ce2be58 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -8,6 +8,7 @@ import ( "os" "github.com/gin-gonic/gin" + "github.com/gin-gonic/gin/render" "go.uber.org/fx" ) @@ -16,12 +17,13 @@ var Module = fx.Options( fx.Invoke(RunServer), ) -func ProvideRouter() *gin.Engine { +func ProvideRouter(htmlRender render.HTMLRender) *gin.Engine { if os.Getenv("GIN_MODE") == "" { gin.SetMode(gin.ReleaseMode) } r := gin.New() r.Use(gin.Logger(), gin.Recovery()) + r.HTMLRender = htmlRender return r } diff --git a/internal/templates/renderer.go b/internal/templates/renderer.go new file mode 100644 index 0000000..f634c87 --- /dev/null +++ b/internal/templates/renderer.go @@ -0,0 +1,208 @@ +package templates + +import ( + "context" + "encoding/json" + "fmt" + "html/template" + "io" + "log" + "net/http" + "path/filepath" + "slices" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + "github.com/gin-gonic/gin/render" + "go.uber.org/fx" +) + +// FS is the interface for template filesystem, to be provided by the main app or a mock. +type FS interface { + ReadFile(name string) ([]byte, error) + ReadDir(name string) ([]osDirEntry, error) +} + +// We will use embed.FS but wrapped in an interface if needed, or just use it directly. +// For now let's assume we pass the root embed.FS to the constructor. + +type Renderer struct { + templates map[string]*template.Template +} + +var Module = fx.Options( + fx.Provide(ProvideRenderer), +) + +func ProvideRenderer() (*Renderer, error) { + // In the final version, this will use an embedded FS. + // For now, let's keep it working with the local filesystem but as an fx service. + r := &Renderer{ + templates: make(map[string]*template.Template), + } + + funcs := template.FuncMap{ + "dict": func(values ...any) map[string]any { + m := make(map[string]any) + for i := 0; i < len(values)-1; i += 2 { + key, ok := values[i].(string) + if !ok { + continue + } + m[key] = values[i+1] + } + return m + }, + "json": func(v any) template.HTMLAttr { + b, _ := json.Marshal(v) + return template.HTMLAttr(b) + }, + "genresParams": func(genres []int) string { + if len(genres) == 0 { + return "" + } + var s strings.Builder + for _, g := range genres { + s.WriteString("genres=" + fmt.Sprintf("%d", g) + "&") + } + return s.String()[:len(s.String())-1] + }, + "hasGenre": func(id int, genres []int) bool { + return slices.Contains(genres, id) + }, + "add": func(a, b int) int { + return a + b + }, + "sub": func(a, b int) int { + return a - b + }, + "mul": func(a, b float64) float64 { + return a * b + }, + "imul": func(a, b int) int { + return a * b + }, + "div": func(a, b float64) float64 { + if b == 0 { + return 0 + } + return a / b + }, + "ceilDiv": func(a, b int) int { + if b == 0 { + return 0 + } + return (a + b - 1) / b + }, + "toFloat": func(a int) float64 { + return float64(a) + }, + "seq": func(v any) []int { + var count int + switch n := v.(type) { + case int: + count = n + case int64: + count = int(n) + default: + count = 0 + } + res := make([]int, count) + for i := 0; i < count; i++ { + res[i] = i + } + return res + }, + "min": func(a, b int) int { + if a < b { + return a + } + return b + }, + "int": func(v any) int { + switch n := v.(type) { + case int: + return n + case int64: + return int(n) + case float64: + return int(n) + case string: + i, _ := strconv.Atoi(n) + return i + default: + return 0 + } + }, + "percent": func(current, total float64) float64 { + if total == 0 { + return 0 + } + return (current / total) * 100 + }, + } + + pages, err := filepath.Glob(filepath.Join(".", "templates", "*.gohtml")) + if err != nil { + return nil, err + } + + components, err := filepath.Glob(filepath.Join(".", "templates", "components", "*.gohtml")) + if err != nil { + return nil, err + } + + for _, page := range pages { + name := filepath.Base(page) + if name == "base.gohtml" { + continue + } + + tmpl := template.New(name).Funcs(funcs) + tmpl = template.Must(tmpl.ParseFiles(filepath.Join(".", "templates", "base.gohtml"))) + if len(components) > 0 { + tmpl = template.Must(tmpl.ParseFiles(components...)) + } + tmpl = template.Must(tmpl.ParseFiles(page)) + + r.templates[name] = tmpl + } + + return r, nil +} + +func (r *Renderer) Instance(name string, data any) render.Render { + return HTMLRender{ + Renderer: r, + Name: name, + Data: data, + } +} + +type HTMLRender struct { + Renderer *Renderer + Name string + Data any +} + +func (h HTMLRender) Render(w http.ResponseWriter) error { + tmpl, ok := h.Renderer.templates[h.Name] + if !ok { + return fmt.Errorf("template %s not found", h.Name) + } + return tmpl.ExecuteTemplate(w, "base.gohtml", h.Data) +} + +func (h HTMLRender) WriteContentType(w http.ResponseWriter) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") +} + +// ExecuteFragment is for HTMX partials +func (r *Renderer) ExecuteFragment(w io.Writer, name string, block string, data any) error { + tmpl, ok := r.templates[name] + if !ok { + return fmt.Errorf("template %s not found", name) + } + return tmpl.ExecuteTemplate(w, block, data) +} From afdd880d0e35ec885fc72d74d09af1ec8f3b59e9 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Wed, 13 May 2026 10:30:55 +0200 Subject: [PATCH 06/71] feat: switch to fx app in main --- cmd/server/main.go | 88 ++-------------------------------------------- 1 file changed, 3 insertions(+), 85 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index 9712041..8e7d227 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -1,97 +1,15 @@ package main import ( - "context" "log" - "net/http" - "os" - "os/signal" - "syscall" - "time" + "mal/internal/app" "github.com/joho/godotenv" - _ "github.com/mattn/go-sqlite3" - - "mal/api/auth" - "mal/integrations/jikan" - "mal/internal/db" - "mal/internal/server" - "mal/internal/worker" ) func main() { _ = godotenv.Load() - dbConn, err := db.Open(db.GetDBFile()) - if err != nil { - log.Fatalf("failed to open db: %v", err) - } - defer func() { _ = dbConn.Close() }() - - queries, err := db.Init(dbConn) - if err != nil { - log.Fatalf("failed to initialize database: %v", err) - } - - jikanClient := jikan.NewClient(queries) - - authLimiter := server.NewAuthLimiter() - go func() { - for { - time.Sleep(time.Minute) - authLimiter.Cleanup(time.Now()) - } - }() - - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer stop() - - go worker.New(queries, jikanClient).Start(ctx) - - app := server.Config{ - DB: queries, - SQLDB: dbConn, - JikanClient: jikanClient, - AuthService: auth.NewService(queries), - AuthLimiter: authLimiter, - PlaybackProxySecret: playbackSecret(), - } - - port := os.Getenv("PORT") - if port == "" { - port = "3000" - } - - httpServer := &http.Server{ - Addr: ":" + port, - Handler: server.NewRouter(app), - ReadHeaderTimeout: 5 * time.Second, - ReadTimeout: 10 * time.Second, - WriteTimeout: 120 * time.Second, - IdleTimeout: 120 * time.Second, - } - - go gracefulShutdown(httpServer, ctx) - - log.Printf("Server starting on http://localhost:%s", port) - if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Fatalf("Server failed to start: %v", err) - } -} - -func playbackSecret() string { - secret := os.Getenv("PLAYBACK_PROXY_SECRET") - if len(secret) < 32 { - log.Fatal("PLAYBACK_PROXY_SECRET must be set and at least 32 characters") - } - return secret -} - -func gracefulShutdown(srv *http.Server, ctx context.Context) { - <-ctx.Done() - shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - if err := srv.Shutdown(shutdownCtx); err != nil { - log.Printf("server shutdown failed: %v", err) - } + application := app.NewApp() + application.Run() } From 34aeb91252ae95739b2be77a6a0d8db27d6c5028 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Wed, 13 May 2026 10:31:46 +0200 Subject: [PATCH 07/71] feat: migrate auth module to modular domain pattern --- internal/app/app.go | 2 + internal/auth/handler/handler.go | 58 ++++++++++++++++++++++ internal/auth/module.go | 23 +++++++++ internal/auth/repository/repository.go | 69 ++++++++++++++++++++++++++ internal/auth/service/service.go | 57 +++++++++++++++++++++ internal/domain/auth.go | 23 +++++++++ 6 files changed, 232 insertions(+) create mode 100644 internal/auth/handler/handler.go create mode 100644 internal/auth/module.go create mode 100644 internal/auth/repository/repository.go create mode 100644 internal/auth/service/service.go create mode 100644 internal/domain/auth.go diff --git a/internal/app/app.go b/internal/app/app.go index 61bc9e0..1c7bb17 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -2,6 +2,7 @@ package app import ( "mal/internal/database" + "mal/internal/auth" "mal/internal/server" "mal/internal/templates" @@ -14,6 +15,7 @@ func NewApp() *fx.App { return fx.New( database.Module, jikan.Module, + auth.Module, templates.Module, server.Module, fx.Decorate(func(r *templates.Renderer) render.HTMLRender { diff --git a/internal/auth/handler/handler.go b/internal/auth/handler/handler.go new file mode 100644 index 0000000..1474819 --- /dev/null +++ b/internal/auth/handler/handler.go @@ -0,0 +1,58 @@ +package handler + +import ( + "mal/internal/domain" + "mal/internal/server" + "net/http" + "time" + + "github.com/gin-gonic/gin" +) + +type AuthHandler struct { + svc domain.AuthService +} + +func NewAuthHandler(svc domain.AuthService) *AuthHandler { + return &AuthHandler{svc: svc} +} + +func (h *AuthHandler) Register(r *gin.Engine) { + r.GET("/login", h.HandleLoginPage) + r.POST("/login", h.HandleLogin) + r.GET("/logout", h.HandleLogout) +} + +func (h *AuthHandler) HandleLoginPage(c *gin.Context) { + c.HTML(http.StatusOK, "login.gohtml", gin.H{ + "CurrentPath": "/login", + }) +} + +func (h *AuthHandler) HandleLogin(c *gin.Context) { + username := c.PostForm("username") + password := c.PostForm("password") + + session, err := h.svc.Login(c.Request.Context(), username, password) + if err != nil { + c.HTML(http.StatusUnauthorized, "login.gohtml", gin.H{ + "Error": "Invalid username or password", + "CurrentPath": "/login", + }) + return + } + + c.SetCookie("session_id", session.ID, int(24*time.Hour.Seconds()), "/", "", false, true) + c.Header("HX-Redirect", "/") + c.Redirect(http.StatusSeeOther, "/") +} + +func (h *AuthHandler) HandleLogout(c *gin.Context) { + sessionID, err := c.Cookie("session_id") + if err == nil { + _ = h.svc.Logout(c.Request.Context(), sessionID) + } + + c.SetCookie("session_id", "", -1, "/", "", false, true) + c.Redirect(http.StatusSeeOther, "/login") +} diff --git a/internal/auth/module.go b/internal/auth/module.go new file mode 100644 index 0000000..c9ea02c --- /dev/null +++ b/internal/auth/module.go @@ -0,0 +1,23 @@ +package auth + +import ( + "mal/internal/auth/handler" + "mal/internal/auth/repository" + "mal/internal/auth/service" + "mal/internal/server" + + "go.uber.org/fx" +) + +var Module = fx.Options( + fx.Provide( + repository.NewAuthRepository, + service.NewAuthService, + handler.NewAuthHandler, + ), + fx.Provide( + server.AsRouteRegister(func(h *handler.AuthHandler) server.RouteRegister { + return h + }), + ), +) diff --git a/internal/auth/repository/repository.go b/internal/auth/repository/repository.go new file mode 100644 index 0000000..b41f241 --- /dev/null +++ b/internal/auth/repository/repository.go @@ -0,0 +1,69 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "mal/internal/db" + "mal/internal/domain" + "time" + + "github.com/google/uuid" +) + +type authRepository struct { + queries *db.Queries +} + +func NewAuthRepository(queries *db.Queries) domain.AuthRepository { + return &authRepository{queries: queries} +} + +func (r *authRepository) GetUserByUsername(ctx context.Context, username string) (*domain.User, error) { + u, err := r.queries.GetUserByUsername(ctx, username) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + return &u, nil +} + +func (r *authRepository) GetUserByID(ctx context.Context, id string) (*domain.User, error) { + u, err := r.queries.GetUser(ctx, id) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + return &u, nil +} + +func (r *authRepository) CreateSession(ctx context.Context, userID string, sessionID string) (*domain.Session, error) { + s, err := r.queries.CreateSession(ctx, db.CreateSessionParams{ + ID: sessionID, + UserID: userID, + ExpiresAt: time.Now().Add(24 * time.Hour), + }) + if err != nil { + return nil, err + } + return &s, nil +} + +func (r *authRepository) GetSession(ctx context.Context, sessionID string) (*domain.Session, error) { + s, err := r.queries.GetSession(ctx, sessionID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + return &s, nil +} + +func (r *authRepository) DeleteSession(ctx context.Context, sessionID string) error { + return r.queries.DeleteSession(ctx, sessionID) +} diff --git a/internal/auth/service/service.go b/internal/auth/service/service.go new file mode 100644 index 0000000..249140c --- /dev/null +++ b/internal/auth/service/service.go @@ -0,0 +1,57 @@ +package service + +import ( + "context" + "errors" + "mal/internal/domain" + "time" + + "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" +) + +type authService struct { + repo domain.AuthRepository +} + +func NewAuthService(repo domain.AuthRepository) domain.AuthService { + return &authService{repo: repo} +} + +func (s *authService) Login(ctx context.Context, username, password string) (*domain.Session, error) { + user, err := s.repo.GetUserByUsername(ctx, username) + if err != nil { + return nil, err + } + if user == nil { + return nil, errors.New("invalid credentials") + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil { + return nil, errors.New("invalid credentials") + } + + sessionID := uuid.New().String() + return s.repo.CreateSession(ctx, user.ID, sessionID) +} + +func (s *authService) ValidateSession(ctx context.Context, sessionID string) (*domain.User, error) { + session, err := s.repo.GetSession(ctx, sessionID) + if err != nil { + return nil, err + } + if session == nil { + return nil, errors.New("session not found") + } + + if session.ExpiresAt.Before(time.Now()) { + _ = s.repo.DeleteSession(ctx, sessionID) + return nil, errors.New("session expired") + } + + return s.repo.GetUserByID(ctx, session.UserID) +} + +func (s *authService) Logout(ctx context.Context, sessionID string) error { + return s.repo.DeleteSession(ctx, sessionID) +} diff --git a/internal/domain/auth.go b/internal/domain/auth.go new file mode 100644 index 0000000..2f38fbb --- /dev/null +++ b/internal/domain/auth.go @@ -0,0 +1,23 @@ +package domain + +import ( + "context" + "mal/internal/db" +) + +type User = db.User +type Session = db.Session + +type AuthService interface { + Login(ctx context.Context, username, password string) (*Session, error) + ValidateSession(ctx context.Context, sessionID string) (*User, error) + Logout(ctx context.Context, sessionID string) error +} + +type AuthRepository interface { + GetUserByUsername(ctx context.Context, username string) (*User, error) + GetUserByID(ctx context.Context, id string) (*User, error) + CreateSession(ctx context.Context, userID string, sessionID string) (*Session, error) + GetSession(ctx context.Context, sessionID string) (*Session, error) + DeleteSession(ctx context.Context, sessionID string) error +} From c32ffd54de64cbd7744887834aedf4e2e0f0303b Mon Sep 17 00:00:00 2001 From: mkelvers Date: Wed, 13 May 2026 10:32:44 +0200 Subject: [PATCH 08/71] feat: migrate anime module to modular domain pattern --- internal/anime/handler/handler.go | 268 ++++++++++++++++++++++++ internal/anime/module.go | 23 ++ internal/anime/repository/repository.go | 27 +++ internal/anime/service/service.go | 157 ++++++++++++++ internal/app/app.go | 2 + internal/domain/anime.go | 32 +++ internal/templates/renderer.go | 7 + 7 files changed, 516 insertions(+) create mode 100644 internal/anime/handler/handler.go create mode 100644 internal/anime/module.go create mode 100644 internal/anime/repository/repository.go create mode 100644 internal/anime/service/service.go create mode 100644 internal/domain/anime.go diff --git a/internal/anime/handler/handler.go b/internal/anime/handler/handler.go new file mode 100644 index 0000000..78f122b --- /dev/null +++ b/internal/anime/handler/handler.go @@ -0,0 +1,268 @@ +package handler + +import ( + "context" + "fmt" + "log" + "mal/internal/domain" + "mal/internal/server" + "net/http" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" +) + +type AnimeHandler struct { + svc domain.AnimeService +} + +func NewAnimeHandler(svc domain.AnimeService) *AnimeHandler { + return &AnimeHandler{svc: svc} +} + +func (h *AnimeHandler) Register(r *gin.Engine) { + r.GET("/", h.HandleCatalog) + r.GET("/api/catalog/airing", h.HandleCatalogAiring) + r.GET("/api/catalog/popular", h.HandleCatalogPopular) + r.GET("/api/catalog/continue", h.HandleCatalogContinue) + r.GET("/discover", h.HandleDiscover) + r.GET("/api/discover/trending", h.HandleDiscoverTrending) + r.GET("/api/discover/upcoming", h.HandleDiscoverUpcoming) + r.GET("/api/discover/top", h.HandleDiscoverTop) + r.GET("/browse", h.HandleBrowse) + r.GET("/anime/:id", h.HandleAnimeDetails) + r.GET("/api/watch-order", h.HandleHTMLWatchOrder) + r.GET("/api/search-quick", h.HandleQuickSearch) + r.GET("/api/jikan/random/anime", h.HandleRandomAnime) +} + +func (h *AnimeHandler) HandleCatalog(c *gin.Context) { + c.HTML(http.StatusOK, "index.gohtml", gin.H{ + "CurrentPath": "/", + }) +} + +func (h *AnimeHandler) HandleCatalogAiring(c *gin.Context) { + h.renderCatalogSection(c, "Airing") +} + +func (h *AnimeHandler) HandleCatalogPopular(c *gin.Context) { + h.renderCatalogSection(c, "Popular") +} + +func (h *AnimeHandler) HandleCatalogContinue(c *gin.Context) { + h.renderCatalogSection(c, "Continue") +} + +func (h *AnimeHandler) renderCatalogSection(c *gin.Context, section string) { + userID := "" // TODO: get from auth context + data, err := h.svc.GetCatalogSection(c.Request.Context(), userID, section) + if err != nil { + log.Printf("catalog %s error: %v", section, err) + return + } + + data["Section"] = section + data["_fragment"] = "catalog_section" + c.HTML(http.StatusOK, "index.gohtml", data) +} + +func (h *AnimeHandler) HandleDiscover(c *gin.Context) { + c.HTML(http.StatusOK, "discover.gohtml", gin.H{ + "CurrentPath": "/discover", + }) +} + +func (h *AnimeHandler) HandleDiscoverTrending(c *gin.Context) { + h.renderDiscoverSection(c, "Trending") +} + +func (h *AnimeHandler) HandleDiscoverUpcoming(c *gin.Context) { + h.renderDiscoverSection(c, "Upcoming") +} + +func (h *AnimeHandler) HandleDiscoverTop(c *gin.Context) { + h.renderDiscoverSection(c, "Top") +} + +func (h *AnimeHandler) renderDiscoverSection(c *gin.Context, section string) { + userID := "" // TODO: get from auth context + data, err := h.svc.GetDiscoverSection(c.Request.Context(), userID, section) + if err != nil { + log.Printf("discover %s error: %v", section, err) + return + } + + data["Section"] = section + data["_fragment"] = "discover_section" + c.HTML(http.StatusOK, "discover.gohtml", data) +} + +func (h *AnimeHandler) HandleBrowse(c *gin.Context) { + q := c.Query("q") + animeType := c.Query("type") + status := c.Query("status") + orderBy := c.Query("order_by") + sort := c.Query("sort") + sfw := c.Query("sfw") != "false" + + var genres []int + for _, g := range c.QueryArray("genres") { + id, _ := strconv.Atoi(g) + if id > 0 { + genres = append(genres, id) + } + } + + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + if page < 1 { + page = 1 + } + + res, err := h.svc.SearchAdvanced(c.Request.Context(), q, animeType, status, orderBy, sort, genres, sfw, page, 24) + if err != nil { + log.Printf("browse error: %v", err) + } + + if c.GetHeader("HX-Request") == "true" { + c.HTML(http.StatusOK, "browse.gohtml", gin.H{ + "_fragment": "anime_card_scroll", + "Animes": res.Animes, + "NextPage": page + 1, + "HasNextPage": res.HasNextPage, + "Query": q, + "Type": animeType, + "Status": status, + "OrderBy": orderBy, + "Sort": sort, + "Genres": genres, + "SFW": sfw, + }) + return + } + + genresList, _ := h.svc.GetGenres(c.Request.Context()) + + c.HTML(http.StatusOK, "browse.gohtml", gin.H{ + "CurrentPath": "/browse", + "Query": q, + "Type": animeType, + "Status": status, + "OrderBy": orderBy, + "Sort": sort, + "Genres": genres, + "SFW": sfw, + "GenresList": genresList, + "Animes": res.Animes, + "HasNextPage": res.HasNextPage, + "NextPage": page + 1, + }) +} + +func (h *AnimeHandler) HandleAnimeDetails(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + if id <= 0 { + c.Status(http.StatusNotFound) + return + } + + section := c.Query("section") + if section != "" && c.GetHeader("HX-Request") == "true" { + var data any + var tplName string + var err error + switch section { + case "characters": + data, err = h.svc.GetCharacters(c.Request.Context(), id) + tplName = "anime_characters" + case "recommendations": + data, err = h.svc.GetRecommendations(c.Request.Context(), id) + tplName = "anime_recommendations" + } + + if err != nil { + c.Status(http.StatusInternalServerError) + return + } + + c.HTML(http.StatusOK, "anime.gohtml", gin.H{ + "_fragment": tplName, + "Data": data, + }) + return + } + + anime, err := h.svc.GetAnimeByID(c.Request.Context(), id) + if err != nil { + c.Status(http.StatusNotFound) + return + } + + c.HTML(http.StatusOK, "anime.gohtml", gin.H{ + "Anime": anime, + "CurrentPath": fmt.Sprintf("/anime/%d", id), + }) +} + +func (h *AnimeHandler) HandleHTMLWatchOrder(c *gin.Context) { + id, _ := strconv.Atoi(c.Query("animeId")) + if id <= 0 { + c.Status(http.StatusBadRequest) + return + } + + relations, err := h.svc.GetRelations(c.Request.Context(), id) + if err != nil { + c.Status(http.StatusInternalServerError) + return + } + + c.HTML(http.StatusOK, "anime.gohtml", gin.H{ + "_fragment": "watch_order", + "Relations": relations, + "AnimeID": id, + }) +} + +func (h *AnimeHandler) HandleQuickSearch(c *gin.Context) { + query := c.Query("q") + if query == "" { + c.JSON(http.StatusOK, []any{}) + return + } + + res, err := h.svc.SearchAdvanced(c.Request.Context(), query, "", "", "", "", nil, true, 1, 5) + if err != nil { + c.JSON(http.StatusOK, []any{}) + return + } + + type quickSearchResult struct { + ID int `json:"id"` + Title string `json:"title"` + Type string `json:"type"` + Image string `json:"image"` + } + + output := make([]quickSearchResult, len(res.Animes)) + for i, anime := range res.Animes { + output[i] = quickSearchResult{ + ID: anime.MalID, + Title: anime.DisplayTitle(), + Type: anime.Type, + Image: anime.ImageURL(), + } + } + c.JSON(http.StatusOK, output) +} + +func (h *AnimeHandler) HandleRandomAnime(c *gin.Context) { + anime, err := h.svc.GetRandomAnime(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch random anime"}) + return + } + c.JSON(http.StatusOK, gin.H{"data": anime}) +} diff --git a/internal/anime/module.go b/internal/anime/module.go new file mode 100644 index 0000000..9dbe633 --- /dev/null +++ b/internal/anime/module.go @@ -0,0 +1,23 @@ +package anime + +import ( + "mal/internal/anime/handler" + "mal/internal/anime/repository" + "mal/internal/anime/service" + "mal/internal/server" + + "go.uber.org/fx" +) + +var Module = fx.Options( + fx.Provide( + repository.NewAnimeRepository, + service.NewAnimeService, + handler.NewAnimeHandler, + ), + fx.Provide( + server.AsRouteRegister(func(h *handler.AnimeHandler) server.RouteRegister { + return h + }), + ), +) diff --git a/internal/anime/repository/repository.go b/internal/anime/repository/repository.go new file mode 100644 index 0000000..3c54e32 --- /dev/null +++ b/internal/anime/repository/repository.go @@ -0,0 +1,27 @@ +package repository + +import ( + "context" + "mal/internal/db" + "mal/internal/domain" +) + +type animeRepository struct { + queries *db.Queries +} + +func NewAnimeRepository(queries *db.Queries) domain.AnimeRepository { + return &animeRepository{queries: queries} +} + +func (r *animeRepository) GetUserWatchList(ctx context.Context, userID string) ([]db.GetUserWatchListRow, error) { + return r.queries.GetUserWatchList(ctx, userID) +} + +func (r *animeRepository) GetWatchListEntry(ctx context.Context, params db.GetWatchListEntryParams) (db.WatchListEntry, error) { + return r.queries.GetWatchListEntry(ctx, params) +} + +func (r *animeRepository) GetContinueWatchingEntries(ctx context.Context, userID string) ([]db.GetContinueWatchingEntriesRow, error) { + return r.queries.GetContinueWatchingEntries(ctx, userID) +} diff --git a/internal/anime/service/service.go b/internal/anime/service/service.go new file mode 100644 index 0000000..6e754f7 --- /dev/null +++ b/internal/anime/service/service.go @@ -0,0 +1,157 @@ +package service + +import ( + "context" + "mal/integrations/jikan" + "mal/internal/db" + "mal/internal/domain" + + "golang.org/x/sync/errgroup" +) + +type animeService struct { + jikan *jikan.Client + repo domain.AnimeRepository +} + +func NewAnimeService(jikan *jikan.Client, repo domain.AnimeRepository) domain.AnimeService { + return &animeService{jikan: jikan, repo: repo} +} + +func (s *animeService) GetCatalogSection(ctx context.Context, userID string, section string) (map[string]any, error) { + var ( + res jikan.TopAnimeResult + cw []db.GetContinueWatchingEntriesRow + watchlist []db.GetUserWatchListRow + ) + + g, gCtx := errgroup.WithContext(ctx) + + g.Go(func() error { + var err error + switch section { + case "Airing": + res, err = s.jikan.GetSeasonsNow(gCtx, 1) + case "Popular": + res, err = s.jikan.GetTopAnime(gCtx, 1) + } + return err + }) + + if userID != "" { + g.Go(func() error { + if section == "Continue" { + var err error + cw, err = s.repo.GetContinueWatchingEntries(gCtx, userID) + return err + } + return nil + }) + g.Go(func() error { + var err error + watchlist, err = s.repo.GetUserWatchList(gCtx, userID) + return err + }) + } + + if err := g.Wait(); err != nil { + return nil, err + } + + animes := res.Animes + if len(animes) > 6 { + animes = animes[:6] + } + + watchlistMap := make(map[int64]bool) + for _, entry := range watchlist { + watchlistMap[entry.AnimeID] = true + } + + return map[string]any{ + "Animes": animes, + "ContinueWatching": cw, + "WatchlistMap": watchlistMap, + }, nil +} + +func (s *animeService) GetDiscoverSection(ctx context.Context, userID string, section string) (map[string]any, error) { + var ( + res jikan.TopAnimeResult + watchlist []db.GetUserWatchListRow + ) + + g, gCtx := errgroup.WithContext(ctx) + + g.Go(func() error { + var err error + switch section { + case "Trending": + res, err = s.jikan.GetSeasonsNow(gCtx, 1) + case "Upcoming": + res, err = s.jikan.GetSeasonsUpcoming(gCtx, 1) + case "Top": + res, err = s.jikan.GetTopAnime(gCtx, 1) + } + return err + }) + + if userID != "" { + g.Go(func() error { + var err error + watchlist, err = s.repo.GetUserWatchList(gCtx, userID) + return err + }) + } + + if err := g.Wait(); err != nil { + return nil, err + } + + animes := res.Animes + if len(animes) > 8 { + animes = animes[:8] + } + + watchlistMap := make(map[int64]bool) + for _, entry := range watchlist { + watchlistMap[entry.AnimeID] = true + } + + return map[string]any{ + "Animes": animes, + "WatchlistMap": watchlistMap, + }, nil +} + +func (s *animeService) GetAnimeByID(ctx context.Context, id int) (domain.Anime, error) { + return s.jikan.GetAnimeByID(ctx, id) +} + +func (s *animeService) SearchAdvanced(ctx context.Context, q, animeType, status, orderBy, sort string, genres []int, sfw bool, page, limit int) (jikan.SearchResponse, error) { + return s.jikan.SearchAdvanced(ctx, q, animeType, status, orderBy, sort, genres, sfw, page, limit) +} + +func (s *animeService) GetGenres(ctx context.Context) ([]domain.Genre, error) { + return s.jikan.GetAnimeGenres(ctx) +} + +func (s *animeService) GetCharacters(ctx context.Context, id int) ([]domain.Character, error) { + return s.jikan.GetAnimeCharacters(ctx, id) +} + +func (s *animeService) GetRecommendations(ctx context.Context, id int) ([]domain.Recommendation, error) { + return s.jikan.GetAnimeRecommendations(ctx, id) +} + +func (s *animeService) GetRelations(ctx context.Context, id int) ([]jikan.Relation, error) { + return s.jikan.GetFullRelations(ctx, id) +} + +func (s *animeService) GetEpisodes(ctx context.Context, id int, page int) (jikan.EpisodesResponse, error) { + return s.jikan.GetEpisodes(ctx, id, page) +} + +func (s *animeService) GetRandomAnime(ctx context.Context) (domain.Anime, error) { + return s.jikan.GetRandomAnime(ctx) +} diff --git a/internal/app/app.go b/internal/app/app.go index 1c7bb17..214abe4 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -3,6 +3,7 @@ package app import ( "mal/internal/database" "mal/internal/auth" + "mal/internal/anime" "mal/internal/server" "mal/internal/templates" @@ -16,6 +17,7 @@ func NewApp() *fx.App { database.Module, jikan.Module, auth.Module, + anime.Module, templates.Module, server.Module, fx.Decorate(func(r *templates.Renderer) render.HTMLRender { diff --git a/internal/domain/anime.go b/internal/domain/anime.go new file mode 100644 index 0000000..4759d72 --- /dev/null +++ b/internal/domain/anime.go @@ -0,0 +1,32 @@ +package domain + +import ( + "context" + "mal/integrations/jikan" + "mal/internal/db" +) + +type Anime = jikan.Anime +type TopAnimeResult = jikan.TopAnimeResult +type Genre = jikan.Genre +type Character = jikan.Character +type Recommendation = jikan.Recommendation + +type AnimeService interface { + GetCatalogSection(ctx context.Context, userID string, section string) (map[string]any, error) + GetDiscoverSection(ctx context.Context, userID string, section string) (map[string]any, error) + GetAnimeByID(ctx context.Context, id int) (Anime, error) + SearchAdvanced(ctx context.Context, q, animeType, status, orderBy, sort string, genres []int, sfw bool, page, limit int) (jikan.SearchResponse, error) + GetGenres(ctx context.Context) ([]Genre, error) + GetCharacters(ctx context.Context, id int) ([]Character, error) + GetRecommendations(ctx context.Context, id int) ([]Recommendation, error) + GetRelations(ctx context.Context, id int) ([]jikan.Relation, error) + GetEpisodes(ctx context.Context, id int, page int) (jikan.EpisodesResponse, error) + GetRandomAnime(ctx context.Context) (Anime, error) +} + +type AnimeRepository interface { + GetUserWatchList(ctx context.Context, userID string) ([]db.GetUserWatchListRow, error) + GetWatchListEntry(ctx context.Context, params db.GetWatchListEntryParams) (db.WatchListEntry, error) + GetContinueWatchingEntries(ctx context.Context, userID string) ([]db.GetContinueWatchingEntriesRow, error) +} diff --git a/internal/templates/renderer.go b/internal/templates/renderer.go index f634c87..fdc3bb2 100644 --- a/internal/templates/renderer.go +++ b/internal/templates/renderer.go @@ -191,6 +191,13 @@ func (h HTMLRender) Render(w http.ResponseWriter) error { if !ok { return fmt.Errorf("template %s not found", h.Name) } + + if block, ok := h.Data.(map[string]any)["_fragment"]; ok { + if blockStr, ok := block.(string); ok { + return tmpl.ExecuteTemplate(w, blockStr, h.Data) + } + } + return tmpl.ExecuteTemplate(w, "base.gohtml", h.Data) } From c94a2fed0499552ecdf0e602d85cbf16b14c5ce9 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Wed, 13 May 2026 10:33:24 +0200 Subject: [PATCH 09/71] feat: migrate watchlist module to modular domain pattern --- internal/app/app.go | 2 + internal/domain/watchlist.go | 26 ++++++ internal/watchlist/handler/handler.go | 95 +++++++++++++++++++++ internal/watchlist/module.go | 23 +++++ internal/watchlist/repository/repository.go | 43 ++++++++++ internal/watchlist/service/service.go | 67 +++++++++++++++ 6 files changed, 256 insertions(+) create mode 100644 internal/domain/watchlist.go create mode 100644 internal/watchlist/handler/handler.go create mode 100644 internal/watchlist/module.go create mode 100644 internal/watchlist/repository/repository.go create mode 100644 internal/watchlist/service/service.go diff --git a/internal/app/app.go b/internal/app/app.go index 214abe4..d59ee9d 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -4,6 +4,7 @@ import ( "mal/internal/database" "mal/internal/auth" "mal/internal/anime" + "mal/internal/watchlist" "mal/internal/server" "mal/internal/templates" @@ -18,6 +19,7 @@ func NewApp() *fx.App { jikan.Module, auth.Module, anime.Module, + watchlist.Module, templates.Module, server.Module, fx.Decorate(func(r *templates.Renderer) render.HTMLRender { diff --git a/internal/domain/watchlist.go b/internal/domain/watchlist.go new file mode 100644 index 0000000..b7eeed4 --- /dev/null +++ b/internal/domain/watchlist.go @@ -0,0 +1,26 @@ +package domain + +import ( + "context" + "mal/internal/db" +) + +type WatchlistEntry = db.WatchListEntry +type UserWatchListRow = db.GetUserWatchListRow + +type WatchlistService interface { + UpdateEntry(ctx context.Context, userID string, animeID int64, status string) error + RemoveEntry(ctx context.Context, userID string, animeID int64) error + GetWatchlist(ctx context.Context, userID string) ([]UserWatchListRow, error) + DeleteContinueWatching(ctx context.Context, userID string, animeID int64) error +} + +type WatchlistRepository interface { + UpsertAnime(ctx context.Context, arg db.UpsertAnimeParams) (db.Anime, error) + GetAnime(ctx context.Context, id int64) (db.Anime, error) + UpsertWatchListEntry(ctx context.Context, arg db.UpsertWatchListEntryParams) (db.WatchListEntry, error) + DeleteWatchListEntry(ctx context.Context, arg db.DeleteWatchListEntryParams) error + GetUserWatchList(ctx context.Context, userID string) ([]db.GetUserWatchListRow, error) + DeleteContinueWatchingEntry(ctx context.Context, arg db.DeleteContinueWatchingEntryParams) error + SaveWatchProgress(ctx context.Context, arg db.SaveWatchProgressParams) error +} diff --git a/internal/watchlist/handler/handler.go b/internal/watchlist/handler/handler.go new file mode 100644 index 0000000..8293323 --- /dev/null +++ b/internal/watchlist/handler/handler.go @@ -0,0 +1,95 @@ +package handler + +import ( + "mal/internal/domain" + "mal/internal/server" + "net/http" + "strconv" + "strings" + + "github.com/gin-gonic/gin" +) + +type WatchlistHandler struct { + svc domain.WatchlistService +} + +func NewWatchlistHandler(svc domain.WatchlistService) *WatchlistHandler { + return &WatchlistHandler{svc: svc} +} + +func (h *WatchlistHandler) Register(r *gin.Engine) { + r.POST("/api/watchlist", h.HandleUpdateWatchlist) + r.DELETE("/api/watchlist/:id", h.HandleDeleteWatchlist) + r.DELETE("/api/continue-watching/:id", h.HandleDeleteContinueWatching) + r.GET("/watchlist", h.HandleGetWatchlist) +} + +func (h *WatchlistHandler) HandleUpdateWatchlist(c *gin.Context) { + userID := "" // TODO: get from auth context + animeID, _ := strconv.ParseInt(c.PostForm("anime_id"), 10, 64) + status := c.PostForm("status") + + if animeID <= 0 || status == "" { + c.Status(http.StatusBadRequest) + return + } + + err := h.svc.UpdateEntry(c.Request.Context(), userID, animeID, status) + if err != nil { + c.Status(http.StatusInternalServerError) + return + } + + c.Status(http.StatusOK) +} + +func (h *WatchlistHandler) HandleDeleteWatchlist(c *gin.Context) { + userID := "" // TODO: get from auth context + animeID, _ := strconv.ParseInt(c.Param("id"), 10, 64) + + if animeID <= 0 { + c.Status(http.StatusBadRequest) + return + } + + err := h.svc.RemoveEntry(c.Request.Context(), userID, animeID) + if err != nil { + c.Status(http.StatusInternalServerError) + return + } + + c.Status(http.StatusOK) +} + +func (h *WatchlistHandler) HandleDeleteContinueWatching(c *gin.Context) { + userID := "" // TODO: get from auth context + animeID, _ := strconv.ParseInt(c.Param("id"), 10, 64) + + if animeID <= 0 { + c.Status(http.StatusBadRequest) + return + } + + err := h.svc.DeleteContinueWatching(c.Request.Context(), userID, animeID) + if err != nil { + c.Status(http.StatusInternalServerError) + return + } + + c.Status(http.StatusOK) +} + +func (h *WatchlistHandler) HandleGetWatchlist(c *gin.Context) { + userID := "" // TODO: get from auth context + entries, err := h.svc.GetWatchlist(c.Request.Context(), userID) + if err != nil { + c.Status(http.StatusInternalServerError) + return + } + + c.HTML(http.StatusOK, "watchlist.gohtml", gin.H{ + "Entries": entries, + "CurrentPath": "/watchlist", + }) +} diff --git a/internal/watchlist/module.go b/internal/watchlist/module.go new file mode 100644 index 0000000..b798dc4 --- /dev/null +++ b/internal/watchlist/module.go @@ -0,0 +1,23 @@ +package watchlist + +import ( + "mal/internal/server" + "mal/internal/watchlist/handler" + "mal/internal/watchlist/repository" + "mal/internal/watchlist/service" + + "go.uber.org/fx" +) + +var Module = fx.Options( + fx.Provide( + repository.NewWatchlistRepository, + service.NewWatchlistService, + handler.NewWatchlistHandler, + ), + fx.Provide( + server.AsRouteRegister(func(h *handler.WatchlistHandler) server.RouteRegister { + return h + }), + ), +) diff --git a/internal/watchlist/repository/repository.go b/internal/watchlist/repository/repository.go new file mode 100644 index 0000000..86cfb42 --- /dev/null +++ b/internal/watchlist/repository/repository.go @@ -0,0 +1,43 @@ +package repository + +import ( + "context" + "mal/internal/db" + "mal/internal/domain" +) + +type watchlistRepository struct { + queries *db.Queries +} + +func NewWatchlistRepository(queries *db.Queries) domain.WatchlistRepository { + return &watchlistRepository{queries: queries} +} + +func (r *watchlistRepository) UpsertAnime(ctx context.Context, arg db.UpsertAnimeParams) (db.Anime, error) { + return r.queries.UpsertAnime(ctx, arg) +} + +func (r *watchlistRepository) GetAnime(ctx context.Context, id int64) (db.Anime, error) { + return r.queries.GetAnime(ctx, id) +} + +func (r *watchlistRepository) UpsertWatchListEntry(ctx context.Context, arg db.UpsertWatchListEntryParams) (db.WatchListEntry, error) { + return r.queries.UpsertWatchListEntry(ctx, arg) +} + +func (r *watchlistRepository) DeleteWatchListEntry(ctx context.Context, arg db.DeleteWatchListEntryParams) error { + return r.queries.DeleteWatchListEntry(ctx, arg) +} + +func (r *watchlistRepository) GetUserWatchList(ctx context.Context, userID string) ([]db.GetUserWatchListRow, error) { + return r.queries.GetUserWatchList(ctx, userID) +} + +func (r *watchlistRepository) DeleteContinueWatchingEntry(ctx context.Context, arg db.DeleteContinueWatchingEntryParams) error { + return r.queries.DeleteContinueWatchingEntry(ctx, arg) +} + +func (r *watchlistRepository) SaveWatchProgress(ctx context.Context, arg db.SaveWatchProgressParams) error { + return r.queries.SaveWatchProgress(ctx, arg) +} diff --git a/internal/watchlist/service/service.go b/internal/watchlist/service/service.go new file mode 100644 index 0000000..09a0c03 --- /dev/null +++ b/internal/watchlist/service/service.go @@ -0,0 +1,67 @@ +package service + +import ( + "context" + "database/sql" + "fmt" + "mal/integrations/jikan" + "mal/internal/db" + "mal/internal/domain" +) + +type watchlistService struct { + repo domain.WatchlistRepository + jikan *jikan.Client +} + +func NewWatchlistService(repo domain.WatchlistRepository, jikan *jikan.Client) domain.WatchlistService { + return &watchlistService{repo: repo, jikan: jikan} +} + +func (s *watchlistService) UpdateEntry(ctx context.Context, userID string, animeID int64, status string) error { + _, err := s.repo.GetAnime(ctx, animeID) + if err != nil { + anime, err := s.jikan.GetAnimeByID(ctx, int(animeID)) + if err == nil { + _, _ = s.repo.UpsertAnime(ctx, db.UpsertAnimeParams{ + ID: int64(anime.MalID), + TitleOriginal: anime.Title, + TitleEnglish: sql.NullString{String: anime.TitleEnglish, Valid: anime.TitleEnglish != ""}, + TitleJapanese: sql.NullString{String: anime.TitleJapanese, Valid: anime.TitleJapanese != ""}, + ImageUrl: anime.ImageURL(), + Airing: sql.NullBool{Bool: anime.Airing, Valid: true}, + }) + } + } + + _, err = s.repo.UpsertWatchListEntry(ctx, db.UpsertWatchListEntryParams{ + UserID: userID, + AnimeID: animeID, + Status: status, + }) + return err +} + +func (s *watchlistService) RemoveEntry(ctx context.Context, userID string, animeID int64) error { + return s.repo.DeleteWatchListEntry(ctx, db.DeleteWatchListEntryParams{ + UserID: userID, + AnimeID: animeID, + }) +} + +func (s *watchlistService) GetWatchlist(ctx context.Context, userID string) ([]domain.UserWatchListRow, error) { + return s.repo.GetUserWatchList(ctx, userID) +} + +func (s *watchlistService) DeleteContinueWatching(ctx context.Context, userID string, animeID int64) error { + _ = s.repo.DeleteContinueWatchingEntry(ctx, db.DeleteContinueWatchingEntryParams{ + UserID: userID, + AnimeID: animeID, + }) + return s.repo.SaveWatchProgress(ctx, db.SaveWatchProgressParams{ + UserID: userID, + AnimeID: animeID, + CurrentEpisode: sql.NullInt64{Valid: false}, + CurrentTimeSeconds: 0, + }) +} From 0d6c7613a974b48f0e6b0dd14a3fd083678ae52e Mon Sep 17 00:00:00 2001 From: mkelvers Date: Wed, 13 May 2026 10:34:01 +0200 Subject: [PATCH 10/71] feat: migrate playback module to modular domain pattern --- internal/app/app.go | 2 + internal/domain/playback.go | 18 ++++++ internal/domain/provider.go | 27 +++++++++ internal/playback/handler/handler.go | 63 +++++++++++++++++++++ internal/playback/module.go | 24 ++++++++ internal/playback/repository/repository.go | 31 ++++++++++ internal/playback/service/service.go | 66 ++++++++++++++++++++++ 7 files changed, 231 insertions(+) create mode 100644 internal/domain/playback.go create mode 100644 internal/domain/provider.go create mode 100644 internal/playback/handler/handler.go create mode 100644 internal/playback/module.go create mode 100644 internal/playback/repository/repository.go create mode 100644 internal/playback/service/service.go diff --git a/internal/app/app.go b/internal/app/app.go index d59ee9d..4672d5b 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -5,6 +5,7 @@ import ( "mal/internal/auth" "mal/internal/anime" "mal/internal/watchlist" + "mal/internal/playback" "mal/internal/server" "mal/internal/templates" @@ -20,6 +21,7 @@ func NewApp() *fx.App { auth.Module, anime.Module, watchlist.Module, + playback.Module, templates.Module, server.Module, fx.Decorate(func(r *templates.Renderer) render.HTMLRender { diff --git a/internal/domain/playback.go b/internal/domain/playback.go new file mode 100644 index 0000000..db47947 --- /dev/null +++ b/internal/domain/playback.go @@ -0,0 +1,18 @@ +package domain + +import ( + "context" + "mal/internal/db" +) + +type PlaybackService interface { + BuildWatchData(ctx context.Context, animeID int, titleCandidates []string, episode string, mode string, userID string) (map[string]any, error) + SaveProgress(ctx context.Context, userID string, animeID int64, episode int, timeSeconds float64) error +} + +type PlaybackRepository interface { + GetWatchListEntry(ctx context.Context, params db.GetWatchListEntryParams) (db.WatchListEntry, error) + GetContinueWatchingEntry(ctx context.Context, params db.GetContinueWatchingEntryParams) (db.GetContinueWatchingEntryRow, error) + SaveWatchProgress(ctx context.Context, params db.SaveWatchProgressParams) error + UpsertContinueWatchingEntry(ctx context.Context, params db.UpsertContinueWatchingEntryParams) (db.ContinueWatchingEntry, error) +} diff --git a/internal/domain/provider.go b/internal/domain/provider.go new file mode 100644 index 0000000..c29b19c --- /dev/null +++ b/internal/domain/provider.go @@ -0,0 +1,27 @@ +package domain + +import ( + "context" +) + +type StreamSource struct { + URL string + Quality string +} + +type StreamResult struct { + URL string + Referer string + Subtitles []Subtitle + Qualities []StreamSource +} + +type Subtitle struct { + URL string + Label string +} + +type Provider interface { + Name() string + GetStreams(ctx context.Context, animeID int, episode string, mode string) (*StreamResult, error) +} diff --git a/internal/playback/handler/handler.go b/internal/playback/handler/handler.go new file mode 100644 index 0000000..d483dfa --- /dev/null +++ b/internal/playback/handler/handler.go @@ -0,0 +1,63 @@ +package handler + +import ( + "mal/internal/domain" + "mal/internal/server" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" +) + +type PlaybackHandler struct { + svc domain.PlaybackService +} + +func NewPlaybackHandler(svc domain.PlaybackService) *PlaybackHandler { + return &PlaybackHandler{svc: svc} +} + +func (h *PlaybackHandler) Register(r *gin.Engine) { + r.GET("/watch/:id", h.HandleWatchPage) + r.POST("/api/watch-progress", h.HandleSaveProgress) +} + +func (h *PlaybackHandler) HandleWatchPage(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + ep := c.DefaultQuery("ep", "1") + mode := c.DefaultQuery("mode", "sub") + + userID := "" // TODO: get from auth context + data, err := h.svc.BuildWatchData(c.Request.Context(), id, []string{}, ep, mode, userID) + if err != nil { + c.Status(http.StatusNotFound) + return + } + + c.HTML(http.StatusOK, "watch.gohtml", gin.H{ + "WatchData": data, + "CurrentPath": c.Request.URL.Path, + }) +} + +func (h *PlaybackHandler) HandleSaveProgress(c *gin.Context) { + userID := "" // TODO: get from auth context + var req struct { + MalID int64 `json:"mal_id"` + Episode int `json:"episode"` + TimeSeconds float64 `json:"time_seconds"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.Status(http.StatusBadRequest) + return + } + + err := h.svc.SaveProgress(c.Request.Context(), userID, req.MalID, req.Episode, req.TimeSeconds) + if err != nil { + c.Status(http.StatusInternalServerError) + return + } + + c.Status(http.StatusOK) +} diff --git a/internal/playback/module.go b/internal/playback/module.go new file mode 100644 index 0000000..00efb69 --- /dev/null +++ b/internal/playback/module.go @@ -0,0 +1,24 @@ +package playback + +import ( + "mal/internal/domain" + "mal/internal/playback/handler" + "mal/internal/playback/repository" + "mal/internal/playback/service" + "mal/internal/server" + + "go.uber.org/fx" +) + +var Module = fx.Options( + fx.Provide( + repository.NewPlaybackRepository, + service.NewPlaybackService, + handler.NewPlaybackHandler, + ), + fx.Provide( + server.AsRouteRegister(func(h *handler.PlaybackHandler) server.RouteRegister { + return h + }), + ), +) diff --git a/internal/playback/repository/repository.go b/internal/playback/repository/repository.go new file mode 100644 index 0000000..80c2798 --- /dev/null +++ b/internal/playback/repository/repository.go @@ -0,0 +1,31 @@ +package repository + +import ( + "context" + "mal/internal/db" + "mal/internal/domain" +) + +type playbackRepository struct { + queries *db.Queries +} + +func NewPlaybackRepository(queries *db.Queries) domain.PlaybackRepository { + return &playbackRepository{queries: queries} +} + +func (r *playbackRepository) GetWatchListEntry(ctx context.Context, params db.GetWatchListEntryParams) (db.WatchListEntry, error) { + return r.queries.GetWatchListEntry(ctx, params) +} + +func (r *playbackRepository) GetContinueWatchingEntry(ctx context.Context, params db.GetContinueWatchingEntryParams) (db.GetContinueWatchingEntryRow, error) { + return r.queries.GetContinueWatchingEntry(ctx, params) +} + +func (r *playbackRepository) SaveWatchProgress(ctx context.Context, params db.SaveWatchProgressParams) error { + return r.queries.SaveWatchProgress(ctx, params) +} + +func (r *playbackRepository) UpsertContinueWatchingEntry(ctx context.Context, params db.UpsertContinueWatchingEntryParams) (db.ContinueWatchingEntry, error) { + return r.queries.UpsertContinueWatchingEntry(ctx, params) +} diff --git a/internal/playback/service/service.go b/internal/playback/service/service.go new file mode 100644 index 0000000..283ba91 --- /dev/null +++ b/internal/playback/service/service.go @@ -0,0 +1,66 @@ +package service + +import ( + "context" + "fmt" + "mal/internal/db" + "mal/internal/domain" + "strconv" +) + +type playbackService struct { + repo domain.PlaybackRepository + providers []domain.Provider +} + +func NewPlaybackService(repo domain.PlaybackRepository, providers []domain.Provider) domain.PlaybackService { + return &playbackService{repo: repo, providers: providers} +} + +func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, titleCandidates []string, episode string, mode string, userID string) (map[string]any, error) { + // Minimal implementation for now to show the pattern + var result *domain.StreamResult + var err error + + for _, p := range s.providers { + result, err = p.GetStreams(ctx, animeID, episode, mode) + if err == nil && result != nil { + break + } + } + + if result == nil { + return nil, fmt.Errorf("no streams found") + } + + startTime := 0.0 + if userID != "" { + entry, err := s.repo.GetWatchListEntry(ctx, db.GetWatchListEntryParams{ + UserID: userID, + AnimeID: int64(animeID), + }) + if err == nil { + if entry.CurrentEpisode.Valid && strconv.FormatInt(entry.CurrentEpisode.Int64, 10) == episode { + startTime = entry.CurrentTimeSeconds + } + } + } + + return map[string]any{ + "URL": result.URL, + "Referer": result.Referer, + "StartTime": startTime, + "Subtitles": result.Subtitles, + "Qualities": result.Qualities, + }, nil +} + +func (s *playbackService) SaveProgress(ctx context.Context, userID string, animeID int64, episode int, timeSeconds float64) error { + params := db.SaveWatchProgressParams{ + UserID: userID, + AnimeID: animeID, + CurrentEpisode: sql.NullInt64{Int64: int64(episode), Valid: true}, + CurrentTimeSeconds: timeSeconds, + } + return s.repo.SaveWatchProgress(ctx, params) +} From ab31cf4c4cd33bcb57066d242e5f6d05c515052e Mon Sep 17 00:00:00 2001 From: mkelvers Date: Wed, 13 May 2026 10:34:16 +0200 Subject: [PATCH 11/71] cleanup: remove redundant and old architectural files --- api/anime/handler.go | 539 --------------------- api/anime/service.go | 130 ----- api/auth/auth.go | 127 ----- api/auth/handler.go | 90 ---- api/playback/allanime_client.go | 695 --------------------------- api/playback/allanime_client_test.go | 454 ----------------- api/playback/handler.go | 481 ------------------ api/playback/http_utils.go | 26 - api/playback/progress.go | 144 ------ api/playback/provider_extractor.go | 221 --------- api/playback/proxy_security.go | 356 -------------- api/playback/proxy_security_test.go | 48 -- api/playback/service_base.go | 392 --------------- api/playback/service_http.go | 74 --- api/playback/service_proxy.go | 119 ----- api/playback/service_ranking.go | 188 -------- api/playback/service_ranking_test.go | 491 ------------------- api/playback/service_resolution.go | 171 ------- api/playback/service_sources.go | 224 --------- api/playback/service_utils.go | 24 - api/playback/types.go | 52 -- api/watchlist/handler.go | 174 ------- api/watchlist/service.go | 184 ------- api/watchlist/service_test.go | 72 --- internal/db/migrate.go | 118 ----- internal/server/routes.go | 162 ------- templates/renderer.go | 198 -------- 27 files changed, 5954 deletions(-) delete mode 100644 api/anime/handler.go delete mode 100644 api/anime/service.go delete mode 100644 api/auth/auth.go delete mode 100644 api/auth/handler.go delete mode 100644 api/playback/allanime_client.go delete mode 100644 api/playback/allanime_client_test.go delete mode 100644 api/playback/handler.go delete mode 100644 api/playback/http_utils.go delete mode 100644 api/playback/progress.go delete mode 100644 api/playback/provider_extractor.go delete mode 100644 api/playback/proxy_security.go delete mode 100644 api/playback/proxy_security_test.go delete mode 100644 api/playback/service_base.go delete mode 100644 api/playback/service_http.go delete mode 100644 api/playback/service_proxy.go delete mode 100644 api/playback/service_ranking.go delete mode 100644 api/playback/service_ranking_test.go delete mode 100644 api/playback/service_resolution.go delete mode 100644 api/playback/service_sources.go delete mode 100644 api/playback/service_utils.go delete mode 100644 api/playback/types.go delete mode 100644 api/watchlist/handler.go delete mode 100644 api/watchlist/service.go delete mode 100644 api/watchlist/service_test.go delete mode 100644 internal/db/migrate.go delete mode 100644 internal/server/routes.go delete mode 100644 templates/renderer.go diff --git a/api/anime/handler.go b/api/anime/handler.go deleted file mode 100644 index cc3d57d..0000000 --- a/api/anime/handler.go +++ /dev/null @@ -1,539 +0,0 @@ -package anime - -import ( - "context" - "encoding/json" - "errors" - "html" - "log" - "net/http" - "strconv" - "strings" - "time" - - "mal/integrations/jikan" - "mal/internal/db" - "mal/internal/middleware" - "mal/templates" - - "golang.org/x/sync/errgroup" -) - -type Handler struct { - service *Service -} - -func NewHandler(service *Service) *Handler { - return &Handler{service: service} -} - -type quickSearchResult struct { - ID int `json:"id"` // anime mal id - Title string `json:"title"` // display title - Type string `json:"type"` // anime type (tv, movie, etc) - Image string `json:"image"` // cover image url -} - -func (h *Handler) HandleCatalog(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/" { - renderNotFoundPage(r, w) - return - } - - user := middleware.GetUser(r.Context()) - - if err := templates.GetRenderer().ExecuteTemplate(r.Context(), w, "index.gohtml", map[string]any{ - "User": user, - "CurrentPath": r.URL.Path, - }); err != nil { - if !errors.Is(err, context.Canceled) { - log.Printf("render error: %v", err) - } - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - } -} - -func (h *Handler) HandleCatalogAiring(w http.ResponseWriter, r *http.Request) { - h.renderCatalogSection(w, r, "Airing") -} - -func (h *Handler) HandleCatalogPopular(w http.ResponseWriter, r *http.Request) { - h.renderCatalogSection(w, r, "Popular") -} - -func (h *Handler) HandleCatalogContinue(w http.ResponseWriter, r *http.Request) { - h.renderCatalogSection(w, r, "Continue") -} - -// renderCatalogSection fetches catalog data (airing/popular/continue) and renders as htmx fragment -func (h *Handler) renderCatalogSection(w http.ResponseWriter, r *http.Request, section string) { - user := middleware.GetUser(r.Context()) - userID := "" - if user != nil { - userID = user.ID - } - - data, err := h.service.GetCatalogSection(r.Context(), userID, section) - if err != nil { - if !errors.Is(err, context.Canceled) { - log.Printf("catalog %s error: %v", section, err) - } - if section != "Continue" { - writeInlineLoadError(w, "Failed to load "+section) - } - return - } - - data["User"] = user - data["Section"] = section - - // render section as htmx partial, not full page - if err := templates.GetRenderer().ExecuteFragment(r.Context(), w, "index.gohtml", "catalog_section", data); err != nil { - if !errors.Is(err, context.Canceled) { - log.Printf("fragment render error: %v", err) - } - } -} - -func (h *Handler) HandleDiscover(w http.ResponseWriter, r *http.Request) { - user := middleware.GetUser(r.Context()) - - if err := templates.GetRenderer().ExecuteTemplate(r.Context(), w, "discover.gohtml", map[string]any{ - "User": user, - "CurrentPath": r.URL.Path, - }); err != nil { - if !errors.Is(err, context.Canceled) { - log.Printf("render error: %v", err) - } - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - } -} - -func (h *Handler) HandleDiscoverTrending(w http.ResponseWriter, r *http.Request) { - h.renderDiscoverSection(w, r, "Trending") -} - -func (h *Handler) HandleDiscoverUpcoming(w http.ResponseWriter, r *http.Request) { - h.renderDiscoverSection(w, r, "Upcoming") -} - -func (h *Handler) HandleDiscoverTop(w http.ResponseWriter, r *http.Request) { - h.renderDiscoverSection(w, r, "Top") -} - -func (h *Handler) renderDiscoverSection(w http.ResponseWriter, r *http.Request, section string) { - user := middleware.GetUser(r.Context()) - userID := "" - if user != nil { - userID = user.ID - } - - data, err := h.service.GetDiscoverSection(r.Context(), userID, section) - if err != nil { - if !errors.Is(err, context.Canceled) { - log.Printf("discover %s error: %v", section, err) - } - writeInlineLoadError(w, "Failed to load "+section) - return - } - - data["User"] = user - data["Section"] = section - - if err := templates.GetRenderer().ExecuteFragment(r.Context(), w, "discover.gohtml", "discover_section", data); err != nil { - if !errors.Is(err, context.Canceled) { - log.Printf("fragment render error: %v", err) - } - } -} - -// HandleBrowse handles anime search/browse with filters. supports htmx partial loading. -func (h *Handler) HandleBrowse(w http.ResponseWriter, r *http.Request) { - user := middleware.GetUser(r.Context()) - - // parse query params for search/filter - q := r.URL.Query().Get("q") - animeType := r.URL.Query().Get("type") - status := r.URL.Query().Get("status") - orderBy := r.URL.Query().Get("order_by") - sort := r.URL.Query().Get("sort") - sfw := r.URL.Query().Get("sfw") != "false" // default to safe - - var genres []int - for _, g := range r.URL.Query()["genres"] { - id, err := strconv.Atoi(g) - if err == nil { - genres = append(genres, id) - } - } - - page := parsePageParam(r) - - ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) - defer cancel() - - res, err := h.service.jikanClient.SearchAdvanced(ctx, q, animeType, status, orderBy, sort, genres, sfw, page, 24) - if err != nil { - if errors.Is(err, context.Canceled) { - return - } - log.Printf("browse error: %v", err) - } - - if r.Header.Get("HX-Request") == "true" { - // htmx: return just the card scroll fragment with watchlist state - watchlistMap := make(map[int]bool) - if user != nil { - watchlist, _ := h.service.db.GetUserWatchList(ctx, user.ID) - for _, entry := range watchlist { - watchlistMap[int(entry.AnimeID)] = true - } - } - - w.Header().Set("Content-Type", "text/html") - err := templates.GetRenderer().ExecuteFragment(ctx, w, "browse.gohtml", "anime_card_scroll", map[string]any{ - "Animes": res.Animes, - "NextPage": page + 1, - "HasNextPage": res.HasNextPage, - "Query": q, - "Type": animeType, - "Status": status, - "OrderBy": orderBy, - "Sort": sort, - "Genres": genres, - "SFW": sfw, - "WatchlistMap": watchlistMap, - }) - if err != nil { - if !errors.Is(err, context.Canceled) { - log.Printf("fragment render error: %v", err) - } - } - return - } - - // full page load: fetch genres list and full watchlist - genresList, err := h.service.jikanClient.GetAnimeGenres(ctx) - if err != nil { - if !errors.Is(err, context.Canceled) { - log.Printf("genres error: %v", err) - } - } - - watchlistMap := make(map[int]bool) - var watchlistIDs []int64 - if user != nil { - watchlist, _ := h.service.db.GetUserWatchList(ctx, user.ID) - watchlistIDs = make([]int64, len(watchlist)) - for i, entry := range watchlist { - watchlistMap[int(entry.AnimeID)] = true - watchlistIDs[i] = entry.AnimeID - } - } - - if err := templates.GetRenderer().ExecuteTemplate(ctx, w, "browse.gohtml", map[string]any{ - "User": user, - "CurrentPath": r.URL.Path, - "Query": q, - "Type": animeType, - "Status": status, - "OrderBy": orderBy, - "Sort": sort, - "Genres": genres, - "SFW": sfw, - "GenresList": genresList, - "Animes": res.Animes, - "HasNextPage": res.HasNextPage, - "NextPage": page + 1, - "WatchlistMap": watchlistMap, - "WatchlistIDs": watchlistIDs, - }); err != nil { - if !errors.Is(err, context.Canceled) { - log.Printf("render error: %v", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - } - } -} - -// HandleAnimeDetails renders anime detail page. handles htmx requests for characters/recommendations sections. -func (h *Handler) HandleAnimeDetails(w http.ResponseWriter, r *http.Request) { - idStr := strings.TrimPrefix(r.URL.Path, "/anime/") - idStr = strings.TrimSuffix(idStr, "/") - id, err := strconv.Atoi(idStr) - if err != nil { - renderNotFoundPage(r, w) - return - } - - user := middleware.GetUser(r.Context()) - - // htmx: return just the section (characters or recommendations) - section := r.URL.Query().Get("section") - if section != "" && r.Header.Get("HX-Request") == "true" { - h.renderAnimeDetailsSection(w, r, id, section) - return - } - - var ( - anime jikan.Anime - status string - episodesCount int - watchlistIDs []int64 - ) - - g, gCtx := errgroup.WithContext(r.Context()) - - // fetch anime details + episode count if airing - g.Go(func() error { - var err error - anime, err = h.service.jikanClient.GetAnimeByID(gCtx, id) - if err == nil && anime.Airing { - // get episode count for airing anime (may span multiple pages) - eps, err := h.service.jikanClient.GetEpisodes(gCtx, id, 1) - if err == nil { - if eps.Pagination.LastVisiblePage > 1 { - lastEps, err := h.service.jikanClient.GetEpisodes(gCtx, id, eps.Pagination.LastVisiblePage) - if err == nil && len(lastEps.Data) > 0 { - lastEp := lastEps.Data[len(lastEps.Data)-1] - count, _ := strconv.Atoi(lastEp.Episode) - episodesCount = count - } - } else if len(eps.Data) > 0 { - lastEp := eps.Data[len(eps.Data)-1] - count, _ := strconv.Atoi(lastEp.Episode) - episodesCount = count - } - } - } - return err - }) - - if user != nil { - // fetch user's watchlist status for this anime - g.Go(func() error { - entry, err := h.service.db.GetWatchListEntry(gCtx, db.GetWatchListEntryParams{ - UserID: user.ID, - AnimeID: int64(id), - }) - if err == nil { - status = entry.Status - } - return nil - }) - // fetch all watchlist ids for nav state - g.Go(func() error { - watchlist, err := h.service.db.GetUserWatchList(gCtx, user.ID) - if err == nil { - watchlistIDs = make([]int64, len(watchlist)) - for i, e := range watchlist { - watchlistIDs[i] = e.AnimeID - } - } - return nil - }) - } - - if err := g.Wait(); err != nil { - if !errors.Is(err, context.Canceled) { - log.Printf("anime details fetch error: %v", err) - } - renderNotFoundPage(r, w) - return - } - - if err := templates.GetRenderer().ExecuteTemplate(r.Context(), w, "anime.gohtml", map[string]any{ - "Anime": anime, - "User": user, - "Status": status, - "CurrentPath": r.URL.Path, - "WatchlistIDs": watchlistIDs, - "EpisodesCount": episodesCount, - }); err != nil { - if !errors.Is(err, context.Canceled) { - log.Printf("render error: %v", err) - } - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - } -} - -// renderAnimeDetailsSection fetches and renders htmx partial for character/recommendation sections -func (h *Handler) renderAnimeDetailsSection(w http.ResponseWriter, r *http.Request, id int, section string) { - ctx := r.Context() - var data any - var err error - - switch section { - case "characters": - data, err = h.service.jikanClient.GetAnimeCharacters(ctx, id) - case "recommendations": - data, err = h.service.jikanClient.GetAnimeRecommendations(ctx, id) - default: - http.Error(w, "Invalid section", http.StatusBadRequest) - return - } - - if err != nil { - if !errors.Is(err, context.Canceled) { - log.Printf("anime details %s error: %v", section, err) - } - writeInlineLoadError(w, "Failed to load "+section) - return - } - - tplName := "anime_characters" - if section == "recommendations" { - tplName = "anime_recommendations" - } - - // render htmx partial for the section - if err := templates.GetRenderer().ExecuteFragment(ctx, w, "anime.gohtml", tplName, data); err != nil { - if !errors.Is(err, context.Canceled) { - log.Printf("fragment render error: %v", err) - } - } -} - -func (h *Handler) HandleHTMLWatchOrder(w http.ResponseWriter, r *http.Request) { - animeIdStr := r.URL.Query().Get("animeId") - id, err := strconv.Atoi(animeIdStr) - if err != nil { - http.Error(w, `
Invalid anime ID.
`, http.StatusBadRequest) - return - } - - relations, err := h.service.jikanClient.GetFullRelations(r.Context(), id) - if err != nil { - if !errors.Is(err, context.Canceled) { - log.Printf("watch order error: %v", err) - } - http.Error(w, `
Failed to load watch order.
`, http.StatusInternalServerError) - return - } - - user := middleware.GetUser(r.Context()) - watchlistMap := make(map[int64]bool) - if user != nil { - watchlist, _ := h.service.db.GetUserWatchList(r.Context(), user.ID) - for _, entry := range watchlist { - watchlistMap[entry.AnimeID] = true - } - } - - if err := templates.GetRenderer().ExecuteFragment(r.Context(), w, "anime.gohtml", "watch_order", map[string]any{ - "Relations": relations, - "AnimeID": id, - "WatchlistMap": watchlistMap, - }); err != nil { - if !errors.Is(err, context.Canceled) { - log.Printf("render error: %v", err) - } - } -} - -func (h *Handler) HandleQuickSearch(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - query := r.URL.Query().Get("q") - if query == "" { - w.WriteHeader(http.StatusOK) - if err := writeJSON(w, []quickSearchResult{}); err != nil { - log.Printf("quick search encode error: %v", err) - } - return - } - res, err := h.service.jikanClient.SearchAdvanced(r.Context(), query, "", "", "", "", nil, true, 1, 5) - if err != nil { - if !errors.Is(err, context.Canceled) { - log.Printf("quick search error: %v", err) - } - w.WriteHeader(http.StatusOK) - if err := writeJSON(w, []quickSearchResult{}); err != nil { - if !errors.Is(err, context.Canceled) { - log.Printf("quick search encode error: %v", err) - } - } - return - } - output := make([]quickSearchResult, len(res.Animes)) - for i, anime := range res.Animes { - output[i] = quickSearchResult{ - ID: anime.MalID, - Title: anime.DisplayTitle(), - Type: anime.Type, - Image: anime.ImageURL(), - } - } - w.WriteHeader(http.StatusOK) - if err := writeJSON(w, output); err != nil { - if !errors.Is(err, context.Canceled) { - log.Printf("quick search encode error: %v", err) - } - } -} - -func (h *Handler) HandleRandomAnime(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - - anime, err := h.service.jikanClient.GetRandomAnime(r.Context()) - if err != nil { - if !errors.Is(err, context.Canceled) { - log.Printf("random anime error: %v", err) - } - w.WriteHeader(http.StatusInternalServerError) - if err := writeJSON(w, map[string]string{"error": "Failed to fetch random anime"}); err != nil { - if !errors.Is(err, context.Canceled) { - log.Printf("random anime encode error: %v", err) - } - } - return - } - - if anime.MalID == 0 { - w.WriteHeader(http.StatusNotFound) - if err := writeJSON(w, map[string]string{"error": "No anime found"}); err != nil { - if !errors.Is(err, context.Canceled) { - log.Printf("random anime encode error: %v", err) - } - } - return - } - - w.WriteHeader(http.StatusOK) - if err := writeJSON(w, map[string]any{"data": anime}); err != nil { - if !errors.Is(err, context.Canceled) { - log.Printf("random anime encode error: %v", err) - } - } -} - -func (h *Handler) HandleSearch(w http.ResponseWriter, r *http.Request) { - renderNotFoundPage(r, w) -} - -func renderNotFoundPage(r *http.Request, w http.ResponseWriter) { - w.WriteHeader(http.StatusNotFound) - if err := templates.GetRenderer().ExecuteTemplate(r.Context(), w, "not_found.gohtml", map[string]any{ - "CurrentPath": r.URL.Path, - }); err != nil { - if !errors.Is(err, context.Canceled) { - log.Printf("render error: %v", err) - } - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - } -} - -func writeInlineLoadError(w http.ResponseWriter, message string) { - w.Header().Set("Content-Type", "text/html") - _, _ = w.Write([]byte(`

` + html.EscapeString(message) + `

`)) -} - -func writeJSON(w http.ResponseWriter, v any) error { - return json.NewEncoder(w).Encode(v) -} - -func parsePageParam(r *http.Request) int { - page, _ := strconv.Atoi(r.URL.Query().Get("page")) - if page < 1 { - return 1 - } - return page -} diff --git a/api/anime/service.go b/api/anime/service.go deleted file mode 100644 index f5da2ca..0000000 --- a/api/anime/service.go +++ /dev/null @@ -1,130 +0,0 @@ -package anime - -import ( - "context" - "mal/integrations/jikan" - "mal/internal/db" - - "golang.org/x/sync/errgroup" -) - -type Service struct { - jikanClient *jikan.Client - db db.Querier -} - -func NewService(jikanClient *jikan.Client, db db.Querier) *Service { - return &Service{jikanClient: jikanClient, db: db} -} - -// GetCatalogSection fetches homepage catalog sections (Airing, Popular, Continue) from jikan and db. -func (s *Service) GetCatalogSection(ctx context.Context, userID string, section string) (map[string]any, error) { - var ( - res jikan.TopAnimeResult - cw []db.GetContinueWatchingEntriesRow - watchlist []db.GetUserWatchListRow - err error - ) - - g, gCtx := errgroup.WithContext(ctx) - - // fetch jikan data (season now or top anime) - g.Go(func() error { - switch section { - case "Airing": - res, err = s.jikanClient.GetSeasonsNow(gCtx, 1) - case "Popular": - res, err = s.jikanClient.GetTopAnime(gCtx, 1) - } - return err - }) - - // fetch user-specific data if logged in - if userID != "" { - g.Go(func() error { - if section == "Continue" { - var err error - cw, err = s.db.GetContinueWatchingEntries(gCtx, userID) - return err - } - return nil - }) - g.Go(func() error { - var err error - watchlist, err = s.db.GetUserWatchList(gCtx, userID) - return err - }) - } - - if err := g.Wait(); err != nil { - return nil, err - } - - // limit to 6 items for homepage grid - animes := res.Animes - if len(animes) > 6 { - animes = animes[:6] - } - - watchlistMap := make(map[int64]bool) - for _, entry := range watchlist { - watchlistMap[entry.AnimeID] = true - } - - return map[string]any{ - "Animes": animes, - "ContinueWatching": cw, - "WatchlistMap": watchlistMap, - }, nil -} - -// GetDiscoverSection fetches discover page sections (Trending, Upcoming, Top) from jikan. -func (s *Service) GetDiscoverSection(ctx context.Context, userID string, section string) (map[string]any, error) { - var ( - res jikan.TopAnimeResult - watchlist []db.GetUserWatchListRow - err error - ) - - g, gCtx := errgroup.WithContext(ctx) - - g.Go(func() error { - switch section { - case "Trending": - res, err = s.jikanClient.GetSeasonsNow(gCtx, 1) - case "Upcoming": - res, err = s.jikanClient.GetSeasonsUpcoming(gCtx, 1) - case "Top": - res, err = s.jikanClient.GetTopAnime(gCtx, 1) - } - return err - }) - - if userID != "" { - g.Go(func() error { - var err error - watchlist, err = s.db.GetUserWatchList(gCtx, userID) - return err - }) - } - - if err := g.Wait(); err != nil { - return nil, err - } - - // limit to 8 items for discover grid - animes := res.Animes - if len(animes) > 8 { - animes = animes[:8] - } - - watchlistMap := make(map[int64]bool) - for _, entry := range watchlist { - watchlistMap[entry.AnimeID] = true - } - - return map[string]any{ - "Animes": animes, - "WatchlistMap": watchlistMap, - }, nil -} diff --git a/api/auth/auth.go b/api/auth/auth.go deleted file mode 100644 index 03a5524..0000000 --- a/api/auth/auth.go +++ /dev/null @@ -1,127 +0,0 @@ -package auth - -import ( - "context" - "crypto/rand" - "database/sql" - "encoding/base64" - "errors" - "fmt" - "net/http" - "os" - "time" - - "golang.org/x/crypto/bcrypt" - - "mal/internal/db" -) - -var ( - ErrInvalidCredentials = errors.New("invalid username or password") - ErrNotAuthenticated = errors.New("not authenticated") -) - -type Service struct { - db db.Querier -} - -func NewService(db db.Querier) *Service { - return &Service{db: db} -} - -// generateToken creates a cryptographically random base64-encoded token -func generateToken(size int) (string, error) { - b := make([]byte, size) - if _, err := rand.Read(b); err != nil { - return "", err - } - return base64.URLEncoding.EncodeToString(b), nil -} - -// generateSessionToken creates a 32-byte session token -func generateSessionToken() (string, error) { - return generateToken(32) -} - -func (s *Service) Login(ctx context.Context, username, password string) (*db.Session, error) { - user, err := s.db.GetUserByUsername(ctx, username) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, ErrInvalidCredentials - } - return nil, fmt.Errorf("failed to lookup user: %w", err) - } - - if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil { - return nil, ErrInvalidCredentials - } - - token, err := generateSessionToken() - if err != nil { - return nil, fmt.Errorf("failed to generate session token: %w", err) - } - - expiresAt := time.Now().Add(30 * 24 * time.Hour) // 30 days - session, err := s.db.CreateSession(ctx, db.CreateSessionParams{ - ID: token, - UserID: user.ID, - ExpiresAt: expiresAt, - }) - if err != nil { - return nil, fmt.Errorf("failed to create session: %w", err) - } - - return &session, nil -} - -func (s *Service) ValidateSession(ctx context.Context, sessionID string) (*db.User, error) { - session, err := s.db.GetSession(ctx, sessionID) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, ErrNotAuthenticated - } - return nil, fmt.Errorf("failed to get session: %w", err) - } - - if time.Now().After(session.ExpiresAt) { - _ = s.db.DeleteSession(ctx, sessionID) // clean up expired session - return nil, ErrNotAuthenticated - } - - user, err := s.db.GetUser(ctx, session.UserID) - if err != nil { - return nil, fmt.Errorf("failed to get user for session: %w", err) - } - - return &user, nil -} - -// SetSessionCookie sets an http-only, secure session cookie -func SetSessionCookie(w http.ResponseWriter, sessionID string, expiresAt time.Time) { - secure := os.Getenv("ENV") == "production" || os.Getenv("FORCE_SECURE_COOKIES") == "true" - http.SetCookie(w, &http.Cookie{ - Name: "session_id", - Value: sessionID, - Expires: expiresAt, - HttpOnly: true, - Secure: secure, - SameSite: http.SameSiteStrictMode, - Path: "/", - }) -} - -func (s *Service) Logout(ctx context.Context, sessionID string) error { - return s.db.DeleteSession(ctx, sessionID) -} - -// ClearSessionCookie invalidates the session cookie -func ClearSessionCookie(w http.ResponseWriter) { - http.SetCookie(w, &http.Cookie{ - Name: "session_id", - Value: "", - Expires: time.Unix(0, 0), // epoch to expire immediately - MaxAge: -1, - HttpOnly: true, - Path: "/", - }) -} diff --git a/api/auth/handler.go b/api/auth/handler.go deleted file mode 100644 index fbc8d85..0000000 --- a/api/auth/handler.go +++ /dev/null @@ -1,90 +0,0 @@ -package auth - -import ( - "context" - "errors" - "log" - "net/http" - - "mal/templates" -) - -type Handler struct { - authService *Service -} - -func NewHandler(authService *Service) *Handler { - return &Handler{authService: authService} -} - -// HandleLoginPage renders the login form -func (h *Handler) HandleLoginPage(w http.ResponseWriter, r *http.Request) { - if err := templates.GetRenderer().ExecuteTemplate(r.Context(), w, "login.gohtml", map[string]any{ - "CurrentPath": r.URL.Path, - }); err != nil { - if !errors.Is(err, context.Canceled) { - log.Printf("render error: %v", err) - } - } -} - -// HandleLogin validates credentials and creates a session on success -func (h *Handler) HandleLogin(w http.ResponseWriter, r *http.Request) { - if err := r.ParseForm(); err != nil { - if err := templates.GetRenderer().ExecuteTemplate(r.Context(), w, "login.gohtml", map[string]any{ - "Error": "Something went wrong. Please try again.", - "Username": "", - "CurrentPath": r.URL.Path, - }); err != nil { - if !errors.Is(err, context.Canceled) { - log.Printf("render error: %v", err) - } - } - return - } - - username := r.FormValue("username") - password := r.FormValue("password") - - if username == "" || password == "" { - if err := templates.GetRenderer().ExecuteTemplate(r.Context(), w, "login.gohtml", map[string]any{ - "Error": "The email or password is wrong.", - "Username": username, - "CurrentPath": r.URL.Path, - }); err != nil { - if !errors.Is(err, context.Canceled) { - log.Printf("render error: %v", err) - } - } - return - } - - session, err := h.authService.Login(r.Context(), username, password) - if err != nil { - if err := templates.GetRenderer().ExecuteTemplate(r.Context(), w, "login.gohtml", map[string]any{ - "Error": "The email or password is wrong.", - "Username": username, - "CurrentPath": r.URL.Path, - }); err != nil { - if !errors.Is(err, context.Canceled) { - log.Printf("render error: %v", err) - } - } - return - } - - SetSessionCookie(w, session.ID, session.ExpiresAt) - - http.Redirect(w, r, "/", http.StatusSeeOther) -} - -// HandleLogout destroys the session and clears the cookie -func (h *Handler) HandleLogout(w http.ResponseWriter, r *http.Request) { - cookie, err := r.Cookie("session_id") - if err == nil { - _ = h.authService.Logout(r.Context(), cookie.Value) - } - - ClearSessionCookie(w) - http.Redirect(w, r, "/", http.StatusSeeOther) -} diff --git a/api/playback/allanime_client.go b/api/playback/allanime_client.go deleted file mode 100644 index 9cb173b..0000000 --- a/api/playback/allanime_client.go +++ /dev/null @@ -1,695 +0,0 @@ -package playback - -import ( - "bytes" - "context" - "crypto/aes" - "crypto/cipher" - "crypto/sha256" - "encoding/base64" - "encoding/json" - "fmt" - "io" - "mal/pkg/net/utls" - "net/http" - "net/url" - "strconv" - "strings" - "time" -) - -const ( - allAnimeBaseURL = "https://api.allanime.day" - allAnimeReferer = "https://allmanga.to/" - allAnimeOrigin = "https://youtu-chan.com" - defaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/121.0" -) - -var ( - aesKeys = []string{"Xot36i3lK3:v1", "SimtVuagFbGR2K7P"} -) - -var allAnimeUTLSClient = &http.Client{ - Transport: &utls.UtlsRoundTripper{}, - Timeout: 30 * time.Second, -} - -type searchResult struct { - ID string - MalID string - Name string -} - -type AvailableEpisodes struct { - Sub []string - Dub []string - Raw []string -} - -type allAnimeClient struct { - httpClient *http.Client - extractor *providerExtractor -} - -func newAllAnimeClient() *allAnimeClient { - return &allAnimeClient{ - httpClient: &http.Client{ - Timeout: 30 * time.Second, - }, - extractor: newProviderExtractor(), - } -} - -func (c *allAnimeClient) graphqlRequest(ctx context.Context, query string, variables map[string]any) (map[string]any, error) { - if mode, ok := variables["translationType"].(string); ok { - variables["translationType"] = strings.ToLower(mode) - } - - payload := map[string]any{ - "query": query, - "variables": variables, - } - - body, err := json.Marshal(payload) - if err != nil { - return nil, fmt.Errorf("marshal graphql payload: %w", err) - } - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, allAnimeBaseURL+"/api", bytes.NewReader(body)) - if err != nil { - return nil, fmt.Errorf("create graphql request: %w", err) - } - - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Referer", allAnimeReferer) - req.Header.Set("User-Agent", defaultUserAgent) - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("execute graphql request: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - respBody, err := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024)) - if err != nil { - return nil, fmt.Errorf("read graphql response: %w", err) - } - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("graphql status %d", resp.StatusCode) - } - - var parsed map[string]any - if err := json.Unmarshal(respBody, &parsed); err != nil { - return nil, fmt.Errorf("decode graphql response: %w", err) - } - - if errs, ok := parsed["errors"].([]any); ok && len(errs) > 0 { - return nil, fmt.Errorf("graphql error: %v", errs[0]) - } - - return parsed, nil -} - -const episodeQueryHash = "d405d0edd690624b66baba3068e0edc3ac90f1597d898a1ec8db4e5c43c00fec" - -func (c *allAnimeClient) graphqlRequestWithHash(ctx context.Context, showID, episode, mode string) (map[string]any, error) { - mode = strings.ToLower(mode) - - varsJSON := fmt.Sprintf(`{"showId":"%s","translationType":"%s","episodeString":"%s"}`, showID, mode, episode) - extJSON := fmt.Sprintf(`{"persistedQuery":{"version":1,"sha256Hash":"%s"}}`, episodeQueryHash) - - apiURL := fmt.Sprintf("%s/api?variables=%s&extensions=%s", - allAnimeBaseURL, - url.QueryEscape(varsJSON), - url.QueryEscape(extJSON)) - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) - if err != nil { - return nil, fmt.Errorf("create GET request: %w", err) - } - - req.Header.Set("User-Agent", defaultUserAgent) - req.Header.Set("Accept", "*/*") - req.Header.Set("Accept-Language", "en-US,en;q=0.5") - req.Header.Set("Accept-Encoding", "identity") - req.Header.Set("Referer", allAnimeReferer) - req.Header.Set("Origin", allAnimeOrigin) - req.Header.Set("Sec-Fetch-Dest", "empty") - req.Header.Set("Sec-Fetch-Mode", "cors") - req.Header.Set("Sec-Fetch-Site", "cross-site") - - resp, err := allAnimeUTLSClient.Do(req) - if err != nil { - return nil, fmt.Errorf("execute GET request: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - respBody, err := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1022)) - if err != nil { - return nil, fmt.Errorf("read response: %w", err) - } - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("GET status %d: %s", resp.StatusCode, string(respBody)) - } - - var parsed map[string]any - if err := json.Unmarshal(respBody, &parsed); err != nil { - return nil, fmt.Errorf("decode response: %w", err) - } - - if errs, ok := parsed["errors"].([]any); ok && len(errs) > 0 { - return nil, fmt.Errorf("graphql error: %v", errs[0]) - } - - data, ok := parsed["data"].(map[string]any) - if !ok { - return nil, fmt.Errorf("no data in response") - } - - var toBeParsed string - if s, ok := data["tobeparsed"].(string); ok && s != "" { - toBeParsed = s - } else if episodeData, ok := data["episode"].(map[string]any); ok { - if s, ok := episodeData["tobeparsed"].(string); ok { - toBeParsed = s - } - } - - if toBeParsed != "" { - decrypted, err := decryptTobeparsed(toBeParsed) - if err != nil { - return nil, fmt.Errorf("decrypt tobeparsed: %w", err) - } - - var ep map[string]any - if jerr := json.Unmarshal(decrypted, &ep); jerr != nil { - return nil, fmt.Errorf("unmarshal decrypted: %w", jerr) - } - - var sourceURLs []any - if srcs, ok := ep["sourceUrls"].([]any); ok { - sourceURLs = srcs - } else if epInner, ok := ep["episode"].(map[string]any); ok { - if srcs, ok := epInner["sourceUrls"].([]any); ok { - sourceURLs = srcs - } - } - - if len(sourceURLs) > 0 { - return map[string]any{ - "episode": map[string]any{ - "sourceUrls": sourceURLs, - }, - }, nil - } - } - - if episodeData, ok := data["episode"].(map[string]any); ok { - if srcs, ok := episodeData["sourceUrls"].([]any); ok && len(srcs) > 0 { - return parsed, nil - } - } - - return nil, fmt.Errorf("no usable data in response") -} - -// GetEpisodeSources fetches stream URLs for a given show, episode, and mode (dub/sub). -func (c *allAnimeClient) GetEpisodeSources(ctx context.Context, showID string, episode string, mode string) ([]StreamSource, error) { - episodeQuery := `query($showId: String!, $translationType: VaildTranslationTypeEnumType!, $episodeString: String!) { - episode(showId: $showId, translationType: $translationType, episodeString: $episodeString) { - sourceUrls - } - }` - - result, err := c.graphqlRequestWithHash(ctx, showID, episode, mode) - if err == nil { - sources := c.extractSourceURLsFromData(ctx, result) - if len(sources) > 0 { - return sources, nil - } - } - - result, err = c.graphqlRequest(ctx, episodeQuery, map[string]any{ - "showId": showID, - "translationType": mode, - "episodeString": episode, - }) - if err != nil { - return nil, err - } - - data, ok := result["data"].(map[string]any) - if !ok { - return nil, fmt.Errorf("invalid source response") - } - - rawSourceURLs, ok := data["episode"].(map[string]any) - if !ok { - return nil, fmt.Errorf("invalid episode response") - } - - sourceURLs, ok := rawSourceURLs["sourceUrls"].([]any) - if !ok || len(sourceURLs) == 0 { - return nil, fmt.Errorf("no source urls") - } - - references := buildSourceReferences(sourceURLs) - if len(references) == 0 { - return nil, fmt.Errorf("no source references") - } - - out := make([]StreamSource, 0, len(references)) - for _, ref := range references { - target := strings.TrimSpace(ref.URL) - if target == "" { - continue - } - - if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") { - sourceType := detectStreamType(target) - if sourceType == "unknown" { - sourceType = detectEmbedType(target) - } - - out = append(out, buildStreamSource(target, sourceType, ref.Name)) - continue - } - - decoded := decodeSourceURL(target) - if decoded == "" { - continue - } - - if strings.HasPrefix(decoded, "http://") || strings.HasPrefix(decoded, "https://") { - sourceType := detectStreamType(decoded) - if sourceType == "unknown" { - sourceType = detectEmbedType(decoded) - } - - out = append(out, buildStreamSource(decoded, sourceType, ref.Name)) - continue - } - - if !strings.HasPrefix(decoded, "/") { - decoded = "/" + decoded - } - - extracted, err := c.extractor.ExtractVideoLinks(ctx, decoded) - if err != nil { - continue - } - - out = append(out, extracted...) - } - - if len(out) == 0 { - return nil, fmt.Errorf("no playable sources extracted") - } - - return out, nil -} - -func (c *allAnimeClient) extractSourceURLsFromData(ctx context.Context, data map[string]any) []StreamSource { - episodeData, ok := data["episode"].(map[string]any) - if !ok { - return nil - } - - sourceURLs, ok := episodeData["sourceUrls"].([]any) - if !ok || len(sourceURLs) == 0 { - return nil - } - - references := buildSourceReferences(sourceURLs) - if len(references) == 0 { - return nil - } - - out := make([]StreamSource, 0, len(references)) - for _, ref := range references { - target := strings.TrimSpace(ref.URL) - if target == "" { - continue - } - - if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") { - sourceType := detectStreamType(target) - if sourceType == "unknown" { - sourceType = detectEmbedType(target) - } - - out = append(out, buildStreamSource(target, sourceType, ref.Name)) - continue - } - - decoded := decodeSourceURL(target) - if decoded == "" { - continue - } - - if strings.HasPrefix(decoded, "http://") || strings.HasPrefix(decoded, "https://") { - sourceType := detectStreamType(decoded) - if sourceType == "unknown" { - sourceType = detectEmbedType(decoded) - } - - out = append(out, buildStreamSource(decoded, sourceType, ref.Name)) - continue - } - - if !strings.HasPrefix(decoded, "/") { - decoded = "/" + decoded - } - - extracted, err := c.extractor.ExtractVideoLinks(ctx, decoded) - if err != nil { - continue - } - - out = append(out, extracted...) - } - - return out -} - -func buildStreamSource(url, sourceType, provider string) StreamSource { - return StreamSource{ - URL: url, - Provider: provider, - Type: sourceType, - Referer: allAnimeReferer, - } -} - -type sourceReference struct { - URL string - Name string -} - -// buildSourceReferences orders source URLs by provider priority, deduplicating entries. -func buildSourceReferences(rawSourceURLs []any) []sourceReference { - priorityOrder := []string{"default", "yt-mp4", "s-mp4", "luf-mp4"} - prioritySet := map[string]struct{}{"default": {}, "yt-mp4": {}, "s-mp4": {}, "luf-mp4": {}} - - prioritized := make(map[string]sourceReference) - fallback := make([]sourceReference, 0, len(rawSourceURLs)) - seen := make(map[string]struct{}) - - for _, source := range rawSourceURLs { - item, ok := source.(map[string]any) - if !ok { - continue - } - - sourceURL, _ := item["sourceUrl"].(string) - sourceName, _ := item["sourceName"].(string) - sourceURL = strings.TrimSpace(sourceURL) - sourceName = strings.TrimSpace(sourceName) - if sourceURL == "" { - continue - } - - if _, exists := seen[sourceURL]; exists { - continue - } - seen[sourceURL] = struct{}{} - - ref := sourceReference{URL: sourceURL, Name: sourceName} - normalized := strings.ToLower(sourceName) - // separate prioritized providers from fallback - if _, prioritizedProvider := prioritySet[normalized]; prioritizedProvider { - if _, exists := prioritized[normalized]; !exists { - prioritized[normalized] = ref - } - continue - } - - fallback = append(fallback, ref) - } - - // output: prioritized in order, then fallback - ordered := make([]sourceReference, 0, len(prioritized)+len(fallback)) - for _, provider := range priorityOrder { - if ref, ok := prioritized[provider]; ok { - ordered = append(ordered, ref) - } - } - - ordered = append(ordered, fallback...) - return ordered -} - -func decryptTobeparsed(encoded string) ([]byte, error) { - raw, err := base64.StdEncoding.DecodeString(encoded) - if err != nil { - return nil, fmt.Errorf("base64 decode failed: %w", err) - } - - if len(raw) < 29 { - return nil, fmt.Errorf("encrypted payload too short") - } - - version := raw[0] - iv := raw[1:13] - cipherText := raw[13 : len(raw)-16] - - for _, keyStr := range aesKeys { - key := sha256.Sum256([]byte(keyStr)) - - block, err := aes.NewCipher(key[:]) - if err != nil { - continue - } - - if version == 1 { - plainText := tryDecryptCTR(block, iv, cipherText) - if json.Valid(plainText) { - return plainText, nil - } - } - - gcm, err := cipher.NewGCM(block) - if err == nil { - tag := raw[len(raw)-16:] - combined := append(append([]byte{}, cipherText...), tag...) - plainText, openErr := gcm.Open(nil, iv, combined, nil) - if openErr == nil && json.Valid(plainText) { - return plainText, nil - } - } - } - - return nil, fmt.Errorf("decryption failed") -} - -func tryDecryptCTR(block cipher.Block, iv []byte, cipherText []byte) []byte { - ctrIV := append([]byte{}, iv...) - ctrIV = append(ctrIV, 0x00, 0x00, 0x00, 0x02) - ctr := cipher.NewCTR(block, ctrIV) - plainText := make([]byte, len(cipherText)) - ctr.XORKeyStream(plainText, cipherText) - return plainText -} - -// Search queries AllAnime for shows matching the given search term. -func (c *allAnimeClient) Search(ctx context.Context, query string, mode string) ([]searchResult, error) { - graphqlQuery := `query($search: SearchInput, $limit: Int, $page: Int, $translationType: VaildTranslationTypeEnumType, $countryOrigin: VaildCountryOriginEnumType) { - shows(search: $search, limit: $limit, page: $page, translationType: $translationType, countryOrigin: $countryOrigin) { - edges { - _id - malId - name - } - } - }` - - variables := map[string]any{ - "search": map[string]any{ - "allowAdult": false, - "allowUnknown": false, - "query": query, - }, - "limit": 40, - "page": 1, - "translationType": mode, - "countryOrigin": "ALL", - } - - result, err := c.graphqlRequest(ctx, graphqlQuery, variables) - if err != nil { - return nil, err - } - - data, ok := result["data"].(map[string]any) - if !ok { - return nil, fmt.Errorf("invalid search response") - } - - shows, ok := data["shows"].(map[string]any) - if !ok { - return nil, fmt.Errorf("invalid shows payload") - } - - edges, ok := shows["edges"].([]any) - if !ok { - return nil, fmt.Errorf("invalid search edges") - } - - out := make([]searchResult, 0, len(edges)) - for _, edge := range edges { - item, ok := edge.(map[string]any) - if !ok { - continue - } - - id, _ := item["_id"].(string) - malID, _ := item["malId"].(string) - name, _ := item["name"].(string) - if unquoted, err := strconv.Unquote("\"" + name + "\""); err == nil { - name = unquoted - } - name = strings.TrimSpace(name) - - if id == "" { - continue - } - - out = append(out, searchResult{ID: id, MalID: malID, Name: name}) - } - - return out, nil -} - -// GetAvailableEpisodes returns the count of sub/dub/raw episodes available for a show. -func (c *allAnimeClient) GetAvailableEpisodes(ctx context.Context, showID string) (AvailableEpisodes, error) { - graphqlQuery := `query($showId: String!) { - show(_id: $showId) { - availableEpisodesDetail - lastEpisodeInfo - } - }` - - result, err := c.graphqlRequest(ctx, graphqlQuery, map[string]any{"showId": showID}) - if err != nil { - return AvailableEpisodes{}, err - } - - data, ok := result["data"].(map[string]any) - if !ok { - return AvailableEpisodes{}, fmt.Errorf("invalid response") - } - - show, ok := data["show"].(map[string]any) - if !ok || show == nil { - return AvailableEpisodes{}, fmt.Errorf("show not found") - } - - detail, ok := show["availableEpisodesDetail"].(map[string]any) - if !ok { - return AvailableEpisodes{}, fmt.Errorf("invalid detail") - } - - var count AvailableEpisodes - if sub, ok := detail["sub"].([]any); ok { - for _, s := range sub { - if str, ok := s.(string); ok { - count.Sub = append(count.Sub, str) - } - } - } - if dub, ok := detail["dub"].([]any); ok { - for _, s := range dub { - if str, ok := s.(string); ok { - count.Dub = append(count.Dub, str) - } - } - } - if raw, ok := detail["raw"].([]any); ok { - for _, s := range raw { - if str, ok := s.(string); ok { - count.Raw = append(count.Raw, str) - } - } - } - - return count, nil -} - -func decodeSourceURL(encoded string) string { - if encoded == "" { - return "" - } - - encoded = strings.TrimPrefix(encoded, "--") - - substitutions := map[string]string{ - "79": "A", "7a": "B", "7b": "C", "7c": "D", "7d": "E", - "7e": "F", "7f": "G", "70": "H", "71": "I", "72": "J", - "73": "K", "74": "L", "75": "M", "76": "N", "77": "O", - "68": "P", "69": "Q", "6a": "R", "6b": "S", "6c": "T", - "6d": "U", "6e": "V", "6f": "W", "60": "X", "61": "Y", - "62": "Z", - "59": "a", "5a": "b", "5b": "c", "5c": "d", "5d": "e", - "5e": "f", "5f": "g", "50": "h", "51": "i", "52": "j", - "53": "k", "54": "l", "55": "m", "56": "n", "57": "o", - "48": "p", "49": "q", "4a": "r", "4b": "s", "4c": "t", - "4d": "u", "4e": "v", "4f": "w", "40": "x", "41": "y", - "42": "z", - "08": "0", "09": "1", "0a": "2", "0b": "3", "0c": "4", - "0d": "5", "0e": "6", "0f": "7", "00": "8", "01": "9", - "15": "-", "16": ".", "67": "_", "46": "~", "02": ":", - "17": "/", "07": "?", "1b": "#", "63": "[", "65": "]", - "78": "@", "19": "!", "1c": "$", "1e": "&", "10": "(", - "11": ")", "12": "*", "13": "+", "14": ",", "03": ";", - "05": "=", "1d": "%", - } - - var result strings.Builder - for idx := 0; idx < len(encoded); { - if idx+2 <= len(encoded) { - pair := encoded[idx : idx+2] - if sub, ok := substitutions[pair]; ok { - result.WriteString(sub) - idx += 2 - continue - } - } - - result.WriteByte(encoded[idx]) - idx++ - } - - decoded := result.String() - if strings.Contains(decoded, "/clock") && !strings.Contains(decoded, "/clock.json") { - decoded = strings.Replace(decoded, "/clock", "/clock.json", 1) - } - - return decoded -} - -func detectStreamType(sourceURL string) string { - lower := strings.ToLower(sourceURL) - if strings.Contains(lower, ".m3u8") || strings.Contains(lower, "master.m3u8") { - return "m3u8" - } - - if strings.Contains(lower, ".mp4") { - return "mp4" - } - - return "unknown" -} - -func detectEmbedType(rawURL string) string { - lower := strings.ToLower(rawURL) - embedHosts := []string{"streamwish", "streamsb", "mp4upload", "ok.ru", "gogoplay", "streamlare"} - for _, host := range embedHosts { - if strings.Contains(lower, host) { - return "embed" - } - } - - return "unknown" -} diff --git a/api/playback/allanime_client_test.go b/api/playback/allanime_client_test.go deleted file mode 100644 index 7888ef3..0000000 --- a/api/playback/allanime_client_test.go +++ /dev/null @@ -1,454 +0,0 @@ -package playback - -import ( - "context" - "crypto/aes" - "encoding/json" - "testing" -) - -func TestDecodeSourceURL(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - encoded string - want string - }{ - { - name: "empty returns empty", - encoded: "", - want: "", - }, - { - name: "with double prefix stripped", - encoded: "--example.com/video.mp4", - want: "example.com/video.mp4", - }, - { - name: "hex substitution", - encoded: "7aexample", - want: "Bexample", - }, - { - name: "mixed substitution", - encoded: "79url7a01", - want: "AurlB9", - }, - { - name: "clock replacement", - encoded: "/clock", - want: "/clock.json", - }, - { - name: "no clock replacement if already json", - encoded: "/clock.json", - want: "/clock.json", - }, - { - name: "complex url", - encoded: "--79stream7acom", - want: "AstreamBcom", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - got := decodeSourceURL(tt.encoded) - if got != tt.want { - t.Errorf("decodeSourceURL(%q) = %q, want %q", tt.encoded, got, tt.want) - } - }) - } -} - -func TestDetectStreamType(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - url string - wantType string - }{ - { - name: "m3u8 extension", - url: "https://example.com/video.m3u8", - wantType: "m3u8", - }, - { - name: "master m3u8", - url: "https://example.com/master.m3u8", - wantType: "m3u8", - }, - { - name: "mp4 extension", - url: "https://example.com/video.mp4", - wantType: "mp4", - }, - { - name: "unknown", - url: "https://example.com/video.avi", - wantType: "unknown", - }, - { - name: "empty returns unknown", - url: "", - wantType: "unknown", - }, - { - name: "case insensitive - M3U8", - url: "https://example.com/MASTER.M3U8", - wantType: "m3u8", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - got := detectStreamType(tt.url) - if got != tt.wantType { - t.Errorf("detectStreamType(%q) = %q, want %q", tt.url, got, tt.wantType) - } - }) - } -} - -func TestDetectEmbedType(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - url string - wantType string - }{ - { - name: "streamwish", - url: "https://streamwish.com/e/abc123", - wantType: "embed", - }, - { - name: "streamsb", - url: "https://streamsb.com/e/abc123", - wantType: "embed", - }, - { - name: "mp4upload", - url: "https://mp4upload.com/e/abc123", - wantType: "embed", - }, - { - name: "ok.ru", - url: "https://ok.ru/video/123", - wantType: "embed", - }, - { - name: "gogoplay", - url: "https://gogoplay.io/embed/123", - wantType: "embed", - }, - { - name: "streamlare", - url: "https://streamlare.com/e/abc", - wantType: "embed", - }, - { - name: "unknown host", - url: "https://unknown.com/video", - wantType: "unknown", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - got := detectEmbedType(tt.url) - if got != tt.wantType { - t.Errorf("detectEmbedType(%q) = %q, want %q", tt.url, got, tt.wantType) - } - }) - } -} - -func TestBuildStreamSource(t *testing.T) { - t.Parallel() - - t.Run("constructs with correct defaults", func(t *testing.T) { - got := buildStreamSource("https://example.com/video.mp4", "mp4", "test-provider") - - if got.URL != "https://example.com/video.mp4" { - t.Errorf("URL = %q, want %q", got.URL, "https://example.com/video.mp4") - } - if got.Provider != "test-provider" { - t.Errorf("Provider = %q, want %q", got.Provider, "test-provider") - } - if got.Type != "mp4" { - t.Errorf("Type = %q, want %q", got.Type, "mp4") - } - if got.Referer != allAnimeReferer { - t.Errorf("Referer = %q, want %q", got.Referer, allAnimeReferer) - } - }) -} - -func TestBuildSourceReferences(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - rawURLs []any - wantRefs []sourceReference - }{ - { - name: "empty returns empty", - rawURLs: nil, - wantRefs: nil, - }, - { - name: "filters empty URLs", - rawURLs: []any{ - map[string]any{"sourceUrl": "", "sourceName": "test"}, - map[string]any{"sourceUrl": "https://example.com/v.mp4", "sourceName": "default"}, - }, - wantRefs: []sourceReference{ - {URL: "https://example.com/v.mp4", Name: "default"}, - }, - }, - { - name: "deduplicates URLs", - rawURLs: []any{ - map[string]any{"sourceUrl": "https://example.com/v.mp4", "sourceName": "test"}, - map[string]any{"sourceUrl": "https://example.com/v.mp4", "sourceName": "test2"}, - }, - wantRefs: []sourceReference{ - {URL: "https://example.com/v.mp4", Name: "test"}, - }, - }, - { - name: "prioritizes default provider", - rawURLs: []any{ - map[string]any{"sourceUrl": "https://a.com/v.mp4", "sourceName": "fallback"}, - map[string]any{"sourceUrl": "https://b.com/v.mp4", "sourceName": "default"}, - map[string]any{"sourceUrl": "https://c.com/v.mp4", "sourceName": "yt-mp4"}, - }, - wantRefs: []sourceReference{ - {URL: "https://b.com/v.mp4", Name: "default"}, - {URL: "https://c.com/v.mp4", Name: "yt-mp4"}, - {URL: "https://a.com/v.mp4", Name: "fallback"}, - }, - }, - { - name: "skips invalid map entries", - rawURLs: []any{ - "invalid", - 123, - map[string]any{"sourceUrl": "https://example.com/v.mp4"}, - }, - wantRefs: []sourceReference{ - {URL: "https://example.com/v.mp4", Name: ""}, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - got := buildSourceReferences(tt.rawURLs) - - if len(got) != len(tt.wantRefs) { - t.Errorf("got %d refs, want %d", len(got), len(tt.wantRefs)) - return - } - - for i, want := range tt.wantRefs { - if got[i].URL != want.URL { - t.Errorf("ref[%d].URL = %q, want %q", i, got[i].URL, want.URL) - } - if got[i].Name != want.Name { - t.Errorf("ref[%d].Name = %q, want %q", i, got[i].Name, want.Name) - } - } - }) - } -} - -func TestBuildSourceReferencesOrder(t *testing.T) { - t.Parallel() - - rawURLs := []any{ - map[string]any{"sourceUrl": "https://s.com/v.mp4", "sourceName": "s-mp4"}, - map[string]any{"sourceUrl": "https://default.com/v.mp4", "sourceName": "default"}, - map[string]any{"sourceUrl": "https://luf.com/v.mp4", "sourceName": "luf-mp4"}, - map[string]any{"sourceUrl": "https://yt.com/v.mp4", "sourceName": "yt-mp4"}, - } - - got := buildSourceReferences(rawURLs) - - wantOrder := []string{"default", "yt-mp4", "s-mp4", "luf-mp4"} - if len(got) != len(wantOrder) { - t.Fatalf("got %d refs, want %d", len(got), len(wantOrder)) - } - - for i, wantName := range wantOrder { - if got[i].Name != wantName { - t.Errorf("ref[%d].Name = %q, want %q (priority order: default > yt-mp4 > s-mp4 > luf-mp4)", i, got[i].Name, wantName) - } - } -} - -func TestIsLikelyM3U8(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - input []byte - want bool - }{ - { - name: "valid m3u8", - input: []byte("#EXTM3U\n#EXT-X-VERSION:3"), - want: true, - }, - { - name: "with leading spaces", - input: []byte(" #EXTM3U\n"), - want: true, - }, - { - name: "empty", - input: []byte{}, - want: false, - }, - { - name: "not m3u8", - input: []byte(" saved progress > first episode - currentEpID := r.URL.Query().Get("ep") - if currentEpID == "" { - if user != nil { - entry, err := h.svc.db.GetWatchListEntry(r.Context(), db.GetWatchListEntryParams{ - UserID: user.ID, - AnimeID: int64(id), - }) - if err == nil && entry.CurrentEpisode.Valid { - currentEpID = strconv.FormatInt(entry.CurrentEpisode.Int64, 10) - // redirect to include ep param for consistent URLs - http.Redirect(w, r, fmt.Sprintf("/anime/%d/watch?ep=%s", id, currentEpID), http.StatusFound) - return - } - } - currentEpID = "1" - } - - mode := r.URL.Query().Get("mode") - userID := "" - if user != nil { - userID = user.ID - } - - titleCandidates := []string{anime.Title} - if anime.TitleEnglish != "" && anime.TitleEnglish != anime.Title { - titleCandidates = append(titleCandidates, anime.TitleEnglish) - } - if anime.TitleJapanese != "" { - titleCandidates = append(titleCandidates, anime.TitleJapanese) - } - - watchData, err := h.svc.BuildWatchPageData(r.Context(), id, titleCandidates, currentEpID, mode, userID) - if err != nil { - log.Printf("watch data error: %v", err) - } - - // Fill gaps with placeholder episodes if fallback has more - if watchData.FallbackEpisodes != nil { - maxCount := 0 - for _, count := range watchData.FallbackEpisodes { - if count > maxCount { - maxCount = count - } - } - - epMap := make(map[int]jikan.Episode) - for _, ep := range allEpisodes { - epMap[ep.MalID] = ep - } - - if maxCount > 0 { - var filled []jikan.Episode - for i := 1; i <= maxCount; i++ { - if ep, ok := epMap[i]; ok { - filled = append(filled, ep) - } else { - filled = append(filled, jikan.Episode{ - MalID: i, - Episode: fmt.Sprintf("Episode %d", i), - Title: fmt.Sprintf("Episode %d", i), - }) - } - } - allEpisodes = filled - } - } - - sort.Slice(allEpisodes, func(i, j int) bool { - return allEpisodes[i].MalID < allEpisodes[j].MalID - }) - - // fetch relations to build season/movie list - relations, err := h.jikanClient.GetFullRelations(r.Context(), id) - if err != nil { - log.Printf("failed to fetch relations: %v", err) - } - - type SeasonEntry struct { - MalID int - Title string - Prefix string - IsCurrent bool - } - - var tvSeasons []SeasonEntry - var movies []SeasonEntry - counter := 1 - - for _, rel := range relations { - if strings.ToLower(rel.Anime.Type) == "tv" { - tvSeasons = append(tvSeasons, SeasonEntry{ - MalID: rel.Anime.MalID, - Title: rel.Anime.DisplayTitle(), - Prefix: fmt.Sprintf("%02d", counter), - IsCurrent: rel.IsCurrent, - }) - counter++ - } - } - - for _, rel := range relations { - if strings.ToLower(rel.Anime.Type) == "movie" { - movies = append(movies, SeasonEntry{ - MalID: rel.Anime.MalID, - Title: rel.Anime.DisplayTitle(), - Prefix: "Mov", - IsCurrent: rel.IsCurrent, - }) - } - } - - allSeasons := append(tvSeasons, movies...) - - if err := templates.GetRenderer().ExecuteTemplate(r.Context(), w, "watch.gohtml", map[string]any{ - "Anime": anime, - "Episodes": allEpisodes, - "WatchData": watchData, - "User": user, - "CurrentPath": r.URL.Path, - "CurrentEpID": currentEpID, - "WatchlistIDs": watchlistIDs, - "WatchlistStatus": watchlistStatus, - "Seasons": allSeasons, - }); err != nil { - if !errors.Is(err, context.Canceled) { - log.Printf("render error: %v", err) - } - } -} - -// HandleProxy proxies media requests through the backend to avoid CORS and hide source URLs. -func (h *Handler) HandleProxy(w http.ResponseWriter, r *http.Request) { - token := r.URL.Query().Get("token") - if token == "" { - http.Error(w, "missing token", http.StatusBadRequest) - return - } - - // determine proxy scope based on URL suffix - scope := proxyScopeStream - if strings.HasSuffix(r.URL.Path, "/segment") { - scope = proxyScopeSegment - } else if strings.HasSuffix(r.URL.Path, "/subtitle") { - scope = proxyScopeSubtitle - } - - targetURL, referer, err := h.svc.resolveProxyToken(r.Context(), token, scope) - if err != nil { - http.Error(w, "invalid token", http.StatusForbidden) - return - } - - rangeHeader := r.Header.Get("Range") - - statusCode, headers, content, bodyReader, err := h.svc.ProxyStream(r.Context(), targetURL, referer, rangeHeader) - if err != nil { - log.Printf("proxy error for %s: %v", targetURL, err) - http.Error(w, "proxy failed", http.StatusBadGateway) - return - } - - maps.Copy(w.Header(), headers) - w.WriteHeader(statusCode) - - if bodyReader != nil { - defer func() { _ = bodyReader.Close() }() - _, _ = io.Copy(w, bodyReader) - } else { - _, _ = w.Write(content) - } -} - -// HandleSaveProgress saves playback progress for a user. -func (h *Handler) HandleSaveProgress(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - user := middleware.GetUser(r.Context()) - if user == nil { - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - - var req struct { - MalID int64 `json:"mal_id"` - Episode int `json:"episode"` - TimeSeconds float64 `json:"time_seconds"` - } - - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid request", http.StatusBadRequest) - return - } - - // We fetch the anime info to seed the DB if it's the first time saving progress for this show - anime, err := h.jikanClient.GetAnimeByID(r.Context(), int(req.MalID)) - var seed *db.UpsertAnimeParams - if err == nil { - seed = &db.UpsertAnimeParams{ - ID: int64(anime.MalID), - TitleOriginal: anime.Title, - TitleEnglish: sql.NullString{String: anime.TitleEnglish, Valid: anime.TitleEnglish != ""}, - TitleJapanese: sql.NullString{String: anime.TitleJapanese, Valid: anime.TitleJapanese != ""}, - ImageUrl: anime.ImageURL(), - Airing: sql.NullBool{Bool: anime.Airing, Valid: true}, - DurationSeconds: sql.NullFloat64{Float64: anime.DurationSeconds(), Valid: anime.DurationSeconds() > 0}, - } - } - - if err := h.svc.SaveProgress(r.Context(), user.ID, req.MalID, req.Episode, req.TimeSeconds, seed); err != nil { - log.Printf("failed to save progress: %v", err) - http.Error(w, "Internal server error", http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusOK) -} - -// HandleCompleteAnime marks an anime as completed for a user. -func (h *Handler) HandleCompleteAnime(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - user := middleware.GetUser(r.Context()) - if user == nil { - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - - var req struct { - MalID int64 `json:"mal_id"` - Episode int `json:"episode"` - } - - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid request", http.StatusBadRequest) - return - } - - // Seed anime info if needed - anime, err := h.jikanClient.GetAnimeByID(r.Context(), int(req.MalID)) - var seed *db.UpsertAnimeParams - if err == nil { - seed = &db.UpsertAnimeParams{ - ID: int64(anime.MalID), - TitleOriginal: anime.Title, - TitleEnglish: sql.NullString{String: anime.TitleEnglish, Valid: anime.TitleEnglish != ""}, - TitleJapanese: sql.NullString{String: anime.TitleJapanese, Valid: anime.TitleJapanese != ""}, - ImageUrl: anime.ImageURL(), - Airing: sql.NullBool{Bool: anime.Airing, Valid: true}, - DurationSeconds: sql.NullFloat64{Float64: anime.DurationSeconds(), Valid: anime.DurationSeconds() > 0}, - } - } - - if err := h.svc.CompleteAnime(r.Context(), user.ID, req.MalID, req.Episode, seed); err != nil { - log.Printf("failed to complete anime: %v", err) - http.Error(w, "Internal server error", http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusOK) -} - -// HandleEpisodeData returns episode streaming data for the player. -func (h *Handler) HandleEpisodeData(w http.ResponseWriter, r *http.Request) { - // path: /api/watch/episode/{animeId}/{episodeId} - parts := strings.Split(r.URL.Path, "/") - if len(parts) < 6 { - http.Error(w, "invalid path", http.StatusBadRequest) - return - } - - animeID, err := strconv.Atoi(parts[4]) - if err != nil { - http.Error(w, "invalid animeId", http.StatusBadRequest) - return - } - - episodeID := parts[5] - - user := middleware.GetUser(r.Context()) - userID := "" - if user != nil { - userID = user.ID - } - - anime, err := h.jikanClient.GetAnimeByID(r.Context(), animeID) - if err != nil { - http.Error(w, "anime not found", http.StatusNotFound) - return - } - - titleCandidates := []string{anime.Title} - if anime.TitleEnglish != "" && anime.TitleEnglish != anime.Title { - titleCandidates = append(titleCandidates, anime.TitleEnglish) - } - if anime.TitleJapanese != "" { - titleCandidates = append(titleCandidates, anime.TitleJapanese) - } - - watchData, err := h.svc.BuildWatchPageData(r.Context(), animeID, titleCandidates, episodeID, "", userID) - if err != nil { - http.Error(w, "failed to build watch data", http.StatusBadGateway) - return - } - - w.Header().Set("Content-Type", "application/json") - if err := writeJSON(w, map[string]any{ - "mal_id": watchData.MalID, - "title": watchData.Title, - "current_episode": watchData.CurrentEpisode, - "total_episodes": anime.Episodes, - "initial_mode": watchData.InitialMode, - "token": "", // The token might be per-source, wait, in Go it was per-mode? - "available_modes": watchData.AvailableModes, - "mode_sources": watchData.ModeSources, - "segments": watchData.Segments, - "episode_title": "", // Find episode title if possible - }); err != nil { - if !errors.Is(err, context.Canceled) { - log.Printf("watch page encode error: %v", err) - } - } -} - -// HandleEpisodeThumbnails returns episode list for the thumbnail strip. -func (h *Handler) HandleEpisodeThumbnails(w http.ResponseWriter, r *http.Request) { - // path: /api/watch/thumbnails/{animeId} - parts := strings.Split(r.URL.Path, "/") - if len(parts) < 5 { - http.Error(w, "invalid path", http.StatusBadRequest) - return - } - - id, err := strconv.Atoi(parts[4]) - if err != nil { - http.Error(w, "invalid animeId", http.StatusBadRequest) - return - } - - allEpisodes, err := h.jikanClient.GetAllEpisodes(r.Context(), id) - if err != nil { - log.Printf("failed to fetch thumbnails/episodes: %v", err) - } - - // Fill gaps if anime has known total - anime, _ := h.jikanClient.GetAnimeByID(r.Context(), id) - if anime.Episodes > 0 && anime.Episodes > len(allEpisodes) { - epMap := make(map[int]jikan.Episode) - for _, ep := range allEpisodes { - epMap[ep.MalID] = ep - } - var filled []jikan.Episode - for i := 1; i <= anime.Episodes; i++ { - if ep, ok := epMap[i]; ok { - filled = append(filled, ep) - } else { - filled = append(filled, jikan.Episode{ - MalID: i, - Episode: fmt.Sprintf("Episode %d", i), - Title: fmt.Sprintf("Episode %d", i), - }) - } - } - allEpisodes = filled - } - - type Result struct { - MalID int `json:"mal_id"` - Title string `json:"title"` - } - - results := make([]Result, len(allEpisodes)) - for i, ep := range allEpisodes { - results[i] = Result{ - MalID: ep.MalID, - Title: ep.Title, - } - } - - w.Header().Set("Content-Type", "application/json") - if err := writeJSON(w, results); err != nil { - if !errors.Is(err, context.Canceled) { - log.Printf("thumbnails encode error: %v", err) - } - } -} - -func writeJSON(w http.ResponseWriter, v any) error { - return json.NewEncoder(w).Encode(v) -} diff --git a/api/playback/http_utils.go b/api/playback/http_utils.go deleted file mode 100644 index ae771cc..0000000 --- a/api/playback/http_utils.go +++ /dev/null @@ -1,26 +0,0 @@ -package playback - -import ( - "context" - "net/http" -) - -// doProxiedRequest performs an HTTP GET with standard playback headers. -func doProxiedRequest(ctx context.Context, client *http.Client, url string, referer string) (*http.Response, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return nil, err - } - - req.Header.Set("User-Agent", defaultUserAgent) - if referer != "" { - req.Header.Set("Referer", referer) - } - - resp, err := client.Do(req) - if err != nil { - return nil, err - } - - return resp, nil -} diff --git a/api/playback/progress.go b/api/playback/progress.go deleted file mode 100644 index 99896ed..0000000 --- a/api/playback/progress.go +++ /dev/null @@ -1,144 +0,0 @@ -package playback - -import ( - "context" - "database/sql" - "errors" - "fmt" - "strings" - - "github.com/google/uuid" - - "mal/internal/db" -) - -// SaveProgress updates watch progress and continue-watching state in a transaction. -func (s *Service) SaveProgress(ctx context.Context, userID string, animeID int64, episode int, timeSeconds float64, animeSeed *db.UpsertAnimeParams) error { - if strings.TrimSpace(userID) == "" || animeID <= 0 || episode <= 0 { - return errors.New("invalid save progress input") - } - - txQueries, tx, err := db.BeginTx(ctx, s.sqlDB) - if err != nil { - return err - } - - defer func() { _ = tx.Rollback() }() - - if animeSeed != nil { - if _, err := txQueries.UpsertAnime(ctx, *animeSeed); err != nil { - return fmt.Errorf("failed to save anime reference: %w", err) - } - } - - watchListEntry, watchListErr := txQueries.GetWatchListEntry(ctx, db.GetWatchListEntryParams{ - UserID: userID, - AnimeID: animeID, - }) - if watchListErr != nil && !errors.Is(watchListErr, sql.ErrNoRows) { - return fmt.Errorf("failed to load watchlist entry: %w", watchListErr) - } - - isCompleted := watchListErr == nil && watchListEntry.Status == "completed" - if !isCompleted { - if err := txQueries.SaveWatchProgress(ctx, db.SaveWatchProgressParams{ - CurrentEpisode: sql.NullInt64{Int64: int64(episode), Valid: true}, - CurrentTimeSeconds: timeSeconds, - UserID: userID, - AnimeID: animeID, - }); err != nil && !errors.Is(err, sql.ErrNoRows) { - return fmt.Errorf("failed to save watchlist progress: %w", err) - } - } - - if isCompleted { - return tx.Commit() - } - - var durationSeconds sql.NullFloat64 - if animeSeed != nil { - durationSeconds = animeSeed.DurationSeconds - } - - if _, err := txQueries.UpsertContinueWatchingEntry(ctx, db.UpsertContinueWatchingEntryParams{ - ID: uuid.New().String(), - UserID: userID, - AnimeID: animeID, - CurrentEpisode: sql.NullInt64{Int64: int64(episode), Valid: true}, - CurrentTimeSeconds: timeSeconds, - DurationSeconds: durationSeconds, - }); err != nil { - return fmt.Errorf("failed to upsert continue entry: %w", err) - } - - if err := tx.Commit(); err != nil { - return fmt.Errorf("failed to commit save progress transaction: %w", err) - } - - return nil -} - -// CompleteAnime marks an anime as completed in the watchlist and clears continue-watching. -func (s *Service) CompleteAnime(ctx context.Context, userID string, animeID int64, episode int, animeSeed *db.UpsertAnimeParams) error { - if strings.TrimSpace(userID) == "" || animeID <= 0 || episode <= 0 { - return errors.New("invalid complete anime input") - } - - txQueries, tx, err := db.BeginTx(ctx, s.sqlDB) - if err != nil { - return err - } - - defer func() { _ = tx.Rollback() }() - - watchListEntry, watchListErr := txQueries.GetWatchListEntry(ctx, db.GetWatchListEntryParams{ - UserID: userID, - AnimeID: animeID, - }) - if watchListErr != nil && !errors.Is(watchListErr, sql.ErrNoRows) { - return fmt.Errorf("failed to load watchlist entry: %w", watchListErr) - } - - alreadyCompleted := watchListErr == nil && watchListEntry.Status == "completed" - - if !alreadyCompleted { - if animeSeed != nil { - if _, err := txQueries.UpsertAnime(ctx, *animeSeed); err != nil { - return fmt.Errorf("failed to save anime reference: %w", err) - } - } - - if _, err := txQueries.UpsertWatchListEntry(ctx, db.UpsertWatchListEntryParams{ - ID: uuid.New().String(), - UserID: userID, - AnimeID: animeID, - Status: "completed", - CurrentEpisode: sql.NullInt64{Int64: 0, Valid: false}, - CurrentTimeSeconds: 0, - }); err != nil { - return fmt.Errorf("failed to mark watchlist as completed: %w", err) - } - - if err := txQueries.SaveWatchProgress(ctx, db.SaveWatchProgressParams{ - CurrentEpisode: sql.NullInt64{Int64: 0, Valid: false}, - CurrentTimeSeconds: 0, - UserID: userID, - AnimeID: animeID, - }); err != nil && !errors.Is(err, sql.ErrNoRows) { - return fmt.Errorf("failed to reset watch progress: %w", err) - } - } - - if err := txQueries.DeleteContinueWatchingEntry(ctx, db.DeleteContinueWatchingEntryParams{ - UserID: userID, - AnimeID: animeID, - }); err != nil { - return fmt.Errorf("failed to clear continue entry: %w", err) - } - - if err := tx.Commit(); err != nil { - return fmt.Errorf("failed to commit complete anime transaction: %w", err) - } - - return nil -} diff --git a/api/playback/provider_extractor.go b/api/playback/provider_extractor.go deleted file mode 100644 index 0757c2c..0000000 --- a/api/playback/provider_extractor.go +++ /dev/null @@ -1,221 +0,0 @@ -package playback - -import ( - "context" - "fmt" - "io" - "net/http" - "regexp" - "strconv" - "strings" - "time" -) - -type providerExtractor struct { - httpClient *http.Client - baseURL string - referer string -} - -func newProviderExtractor() *providerExtractor { - return &providerExtractor{ - httpClient: &http.Client{Timeout: 30 * time.Second}, - baseURL: allAnimeBaseURL, - referer: allAnimeReferer, - } -} - -// ExtractVideoLinks fetches provider page and returns stream sources. -func (e *providerExtractor) ExtractVideoLinks(ctx context.Context, providerPath string) ([]StreamSource, error) { - endpoint := e.baseURL + providerPath - - var resp *http.Response - var err error - - for attempt := range 3 { - if attempt > 0 { - select { - case <-ctx.Done(): - return nil, ctx.Err() - case <-time.After(time.Duration(attempt) * 2 * time.Second): - } - } - - resp, err = doProxiedRequest(ctx, e.httpClient, endpoint, e.referer) - if err == nil { - break - } - - if attempt == 2 { - return nil, fmt.Errorf("fetch provider response: %w", err) - } - } - - defer func() { _ = resp.Body.Close() }() - - body, err := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024)) // 2MB limit - if err != nil { - return nil, fmt.Errorf("read provider response: %w", err) - } - - return e.parseProviderResponse(ctx, string(body)), nil -} - -// parseProviderResponse extracts stream sources from provider JSON response. -func (e *providerExtractor) parseProviderResponse(ctx context.Context, response string) []StreamSource { - sources := make([]StreamSource, 0) - providerReferer := e.referer - - // extract per-source referer if present - refererPattern := regexp.MustCompile(`"Referer":"([^"]+)"`) - if match := refererPattern.FindStringSubmatch(response); len(match) >= 2 { - providerReferer = strings.ReplaceAll(match[1], `\/`, "/") - } - if providerReferer == "" { - providerReferer = e.referer - } - - // extract direct link sources (mp4/embed) - linkPattern := regexp.MustCompile(`"link":"([^"]+)","resolutionStr":"([^"]+)"`) - for _, match := range linkPattern.FindAllStringSubmatch(response, -1) { - if len(match) < 3 { - continue - } - - link := strings.ReplaceAll(match[1], `\/`, "/") - quality := strings.TrimSpace(match[2]) - sourceType := detectStreamType(link) - if sourceType == "unknown" { - sourceType = detectEmbedType(link) - } - - sources = append(sources, StreamSource{ - URL: link, - Quality: quality, - Provider: "wixmp", - Type: sourceType, - Referer: providerReferer, - }) - } - - // extract HLS playlist sources - hlsPattern := regexp.MustCompile(`"url":"([^"]+)","hardsub_lang":"en-US"`) - for _, match := range hlsPattern.FindAllStringSubmatch(response, -1) { - if len(match) < 2 { - continue - } - - playlistURL := strings.ReplaceAll(match[1], `\/`, "/") - if strings.Contains(playlistURL, "master.m3u8") { - parsed, err := e.parseM3U8(ctx, playlistURL, providerReferer) - if err == nil { - sources = append(sources, parsed...) - } - continue - } - - sources = append(sources, StreamSource{ - URL: playlistURL, - Quality: "auto", - Provider: "hls", - Type: "m3u8", - Referer: providerReferer, - }) - } - - // extract subtitles and attach to all sources - subtitlePattern := regexp.MustCompile(`"subtitles":\[(.*?)\]`) - if subtitleMatch := subtitlePattern.FindStringSubmatch(response); len(subtitleMatch) >= 2 { - subtitles := make([]Subtitle, 0) - subtitleEntryPattern := regexp.MustCompile(`"lang":"([^"]+)".*?"src":"([^"]+)"`) - for _, entry := range subtitleEntryPattern.FindAllStringSubmatch(subtitleMatch[1], -1) { - if len(entry) < 3 { - continue - } - - subtitles = append(subtitles, Subtitle{ - Lang: strings.TrimSpace(entry[1]), - URL: strings.ReplaceAll(entry[2], `\/`, "/"), - }) - } - - if len(subtitles) > 0 { - for idx := range sources { - sources[idx].Subtitles = subtitles - } - } - } - - return sources -} - -// parseM3U8 fetches a master playlist and extracts individual stream URLs with bandwidth-derived quality. -func (e *providerExtractor) parseM3U8(ctx context.Context, masterURL string, referer string) ([]StreamSource, error) { - resp, err := doProxiedRequest(ctx, e.httpClient, masterURL, referer) - if err != nil { - return nil, err - } - defer func() { _ = resp.Body.Close() }() - - body, err := io.ReadAll(io.LimitReader(resp.Body, 512*1024)) // 512KB limit - if err != nil { - return nil, err - } - - lines := strings.Split(string(body), "\n") - baseURL := masterURL - if idx := strings.LastIndex(masterURL, "/"); idx >= 0 { - baseURL = masterURL[:idx+1] - } - - currentBandwidth := 0 - sources := make([]StreamSource, 0) - bwPattern := regexp.MustCompile(`BANDWIDTH=(\d+)`) - - for _, line := range lines { - trimmed := strings.TrimSpace(line) - if strings.HasPrefix(trimmed, "#EXT-X-STREAM-INF") { - match := bwPattern.FindStringSubmatch(trimmed) - if len(match) >= 2 { - value, convErr := strconv.Atoi(match[1]) - if convErr == nil { - currentBandwidth = value - } - } - continue - } - - // skip empty lines and non-stream lines - if trimmed == "" || strings.HasPrefix(trimmed, "#") { - continue - } - - streamURL := trimmed - if !strings.HasPrefix(streamURL, "http://") && !strings.HasPrefix(streamURL, "https://") { - streamURL = baseURL + streamURL - } - - quality := "auto" - kbps := currentBandwidth / 1000 - switch { - case kbps >= 8000: - quality = "1080p" - case kbps >= 5000: - quality = "720p" - case kbps >= 2500: - quality = "480p" - case kbps > 0: - quality = "360p" - } - - sources = append(sources, StreamSource{ - URL: streamURL, - Quality: quality, - Provider: "hls", - Type: "m3u8", - Referer: referer, - }) - } - - return sources, nil -} diff --git a/api/playback/proxy_security.go b/api/playback/proxy_security.go deleted file mode 100644 index 07980a1..0000000 --- a/api/playback/proxy_security.go +++ /dev/null @@ -1,356 +0,0 @@ -package playback - -import ( - "bufio" - "context" - "crypto/hmac" - "crypto/sha256" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "net" - "net/url" - "strings" - "time" -) - -const ( - proxyStreamTokenTTL = 2 * time.Hour - proxySegmentTokenTTL = 6 * time.Hour - proxySubtitleTokenTTL = 6 * time.Hour -) - -type proxyScope string - -const ( - proxyScopeStream proxyScope = "stream" - proxyScopeSegment proxyScope = "segment" - proxyScopeSubtitle proxyScope = "subtitle" -) - -type proxyTokenPayload struct { - TargetURL string `json:"u"` - Referer string `json:"r,omitempty"` - Scope string `json:"s"` - ExpiresAt int64 `json:"exp"` -} - -type proxyTokenSigner struct { - secret []byte -} - -func newProxyTokenSigner(secret string) (*proxyTokenSigner, error) { - trimmed := strings.TrimSpace(secret) - if trimmed == "" { - return nil, errors.New("proxy token secret is required") - } - - if len(trimmed) < 32 { - return nil, errors.New("proxy token secret must be at least 32 characters") - } - - return &proxyTokenSigner{secret: []byte(trimmed)}, nil -} - -func (s *proxyTokenSigner) Sign(payload proxyTokenPayload) (string, error) { - body, err := json.Marshal(payload) - if err != nil { - return "", fmt.Errorf("marshal proxy token payload: %w", err) - } - - mac := hmac.New(sha256.New, s.secret) - mac.Write(body) - signature := mac.Sum(nil) - - // format: payload.signature (both base64url encoded) - encodedBody := base64.RawURLEncoding.EncodeToString(body) - encodedSignature := base64.RawURLEncoding.EncodeToString(signature) - return encodedBody + "." + encodedSignature, nil -} - -func (s *proxyTokenSigner) Verify(token string) (proxyTokenPayload, error) { - parts := strings.Split(token, ".") - if len(parts) != 2 { - return proxyTokenPayload{}, errors.New("invalid proxy token format") - } - - body, err := base64.RawURLEncoding.DecodeString(parts[0]) - if err != nil { - return proxyTokenPayload{}, errors.New("invalid proxy token payload") - } - - signature, err := base64.RawURLEncoding.DecodeString(parts[1]) - if err != nil { - return proxyTokenPayload{}, errors.New("invalid proxy token signature") - } - - mac := hmac.New(sha256.New, s.secret) - mac.Write(body) - expected := mac.Sum(nil) - if !hmac.Equal(signature, expected) { // constant-time comparison - return proxyTokenPayload{}, errors.New("invalid proxy token signature") - } - - var payload proxyTokenPayload - if err := json.Unmarshal(body, &payload); err != nil { - return proxyTokenPayload{}, errors.New("invalid proxy token payload") - } - - if payload.ExpiresAt <= time.Now().Unix() { - return proxyTokenPayload{}, errors.New("proxy token expired") - } - - return payload, nil -} - -func (s *Service) buildClientModeSources(modeSources map[string]ModeSource) (map[string]ModeSource, error) { - clientModeSources := make(map[string]ModeSource, len(modeSources)) - - for mode, source := range modeSources { - // wrap stream url with proxy token - streamToken, err := s.issueProxyToken(source.URL, source.Referer, proxyScopeStream) - if err != nil { - return nil, err - } - - subtitles := make([]SubtitleItem, 0, len(source.Subtitles)) - for _, subtitle := range source.Subtitles { - targetURL := strings.TrimSpace(subtitle.URL) - if targetURL == "" { - continue - } - - token, err := s.issueProxyToken(targetURL, source.Referer, proxyScopeSubtitle) - if err != nil { - return nil, err - } - - subtitles = append(subtitles, SubtitleItem{ - Lang: subtitle.Lang, - Token: token, - }) - } - - clientModeSources[mode] = ModeSource{ - Token: streamToken, - Subtitles: subtitles, - Qualities: source.Qualities, - } - } - - return clientModeSources, nil -} - -func (s *Service) issueProxyToken(targetURL string, referer string, scope proxyScope) (string, error) { - normalizedTarget, err := normalizeProxyURL(targetURL) - if err != nil { - return "", err - } - - normalizedReferer := "" - if strings.TrimSpace(referer) != "" { - refererURL, refererErr := normalizeProxyURL(referer) - if refererErr == nil { - normalizedReferer = refererURL - } - } - - return s.proxyTokens.Sign(proxyTokenPayload{ - TargetURL: normalizedTarget, - Referer: normalizedReferer, - Scope: string(scope), - ExpiresAt: time.Now().Add(proxyTokenTTL(scope)).Unix(), - }) -} - -// proxyTokenTTLs defines ttl per scope type. -var proxyTokenTTLs = map[proxyScope]time.Duration{ - proxyScopeStream: proxyStreamTokenTTL, - proxyScopeSegment: proxySegmentTokenTTL, - proxyScopeSubtitle: proxySubtitleTokenTTL, -} - -func proxyTokenTTL(scope proxyScope) time.Duration { - if ttl, ok := proxyTokenTTLs[scope]; ok { - return ttl - } - return proxyStreamTokenTTL -} - -func (s *Service) resolveProxyToken(ctx context.Context, token string, scope proxyScope) (string, string, error) { - payload, err := s.proxyTokens.Verify(token) - if err != nil { - return "", "", err - } - - if payload.Scope != string(scope) { - return "", "", errors.New("proxy token scope mismatch") - } - - normalizedTarget, err := normalizeProxyURL(payload.TargetURL) - if err != nil { - return "", "", err - } - - if err := s.ensurePublicProxyTarget(ctx, normalizedTarget); err != nil { - return "", "", err - } - - // resolve referer only if it passes public target check - normalizedReferer := "" - if strings.TrimSpace(payload.Referer) != "" { - refererURL, refererErr := normalizeProxyURL(payload.Referer) - if refererErr == nil { - if ensureErr := s.ensurePublicProxyTarget(ctx, refererURL); ensureErr == nil { - normalizedReferer = refererURL - } - } - } - - return normalizedTarget, normalizedReferer, nil -} - -// normalizeProxyURL validates and canonicalizes a proxy target URL. -func normalizeProxyURL(rawURL string) (string, error) { - parsed, err := url.Parse(strings.TrimSpace(rawURL)) - if err != nil { - return "", errors.New("invalid proxy target") - } - - if parsed.Scheme != "http" && parsed.Scheme != "https" { - return "", errors.New("invalid proxy target scheme") - } - - host := strings.ToLower(strings.TrimSpace(parsed.Hostname())) - if host == "" { - return "", errors.New("invalid proxy target host") - } - - // block localhost and .local TLD - if host == "localhost" || strings.HasSuffix(host, ".localhost") || strings.HasSuffix(host, ".local") { - return "", errors.New("localhost targets are not allowed") - } - - ip := net.ParseIP(host) - if ip != nil && isBlockedProxyIP(ip) { - return "", errors.New("private proxy targets are not allowed") - } - - return parsed.String(), nil -} - -// isBlockedProxyIP checks for loopback, private, multicast, and unspecified addresses. -func isBlockedProxyIP(ip net.IP) bool { - return ip.IsLoopback() || - ip.IsPrivate() || - ip.IsMulticast() || - ip.IsLinkLocalMulticast() || - ip.IsLinkLocalUnicast() || - ip.IsUnspecified() -} - -// ensurePublicProxyTarget validates that the target host resolves to a public IP. -// results are cached to avoid repeated DNS lookups. -func (s *Service) ensurePublicProxyTarget(ctx context.Context, rawURL string) error { - parsed, err := url.Parse(rawURL) - if err != nil { - return errors.New("invalid proxy target") - } - - host := strings.TrimSpace(parsed.Hostname()) - if host == "" { - return errors.New("invalid proxy target host") - } - - // direct IP already checked by normalizeProxyURL - if ip := net.ParseIP(host); ip != nil { - if isBlockedProxyIP(ip) { - return errors.New("private proxy targets are not allowed") - } - return nil - } - - // check cache first - cached, ok := s.proxyHostCache.Get(host) - if ok { - if cached.Allowed { - return nil - } - return errors.New("private proxy targets are not allowed") - } - - // DNS resolution for hostname - resolvedIPs, err := net.DefaultResolver.LookupIPAddr(ctx, host) - if err != nil || len(resolvedIPs) == 0 { - return errors.New("proxy target lookup failed") - } - - allowed := true - for _, resolved := range resolvedIPs { - if isBlockedProxyIP(resolved.IP) { - allowed = false - break - } - } - - s.proxyHostCache.Add(host, proxyHostCacheItem{ - Allowed: allowed, - }) - - if !allowed { - return errors.New("private proxy targets are not allowed") - } - - return nil -} - -// rewritePlaylistWithTokens replaces segment URLs with proxy tokens for HLS playlists. -func (s *Service) rewritePlaylistWithTokens(ctx context.Context, content string, baseURL string, referer string) (string, error) { - base, err := url.Parse(baseURL) - if err != nil { - return "", err - } - - var out strings.Builder - scanner := bufio.NewScanner(strings.NewReader(content)) - for scanner.Scan() { - select { - case <-ctx.Done(): - return "", ctx.Err() - default: - } - - line := scanner.Text() - trimmed := strings.TrimSpace(line) - // preserve comments and empty lines - if trimmed == "" || strings.HasPrefix(trimmed, "#") { - out.WriteString(line) - out.WriteString("\n") - continue - } - - relativeURL, parseErr := url.Parse(trimmed) - if parseErr != nil { - out.WriteString(line) - out.WriteString("\n") - continue - } - - absoluteURL := base.ResolveReference(relativeURL).String() - token, tokenErr := s.issueProxyToken(absoluteURL, referer, proxyScopeSegment) - if tokenErr != nil { - return "", tokenErr - } - - proxied := "/watch/proxy/segment?token=" + url.QueryEscape(token) - out.WriteString(proxied) - out.WriteString("\n") - } - - if err := scanner.Err(); err != nil { - return "", err - } - - return out.String(), nil -} diff --git a/api/playback/proxy_security_test.go b/api/playback/proxy_security_test.go deleted file mode 100644 index 84e8a0a..0000000 --- a/api/playback/proxy_security_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package playback - -import ( - "context" - "testing" - - "mal/internal/db" -) - -func TestNormalizeProxyURLRejectsLocalhost(t *testing.T) { - t.Parallel() - - _, err := normalizeProxyURL("http://localhost:8080/private") - if err == nil { - t.Fatal("expected localhost URL to be rejected") - } -} - -func TestNormalizeProxyURLRejectsPrivateIP(t *testing.T) { - t.Parallel() - - _, err := normalizeProxyURL("http://192.168.1.10/stream") - if err == nil { - t.Fatal("expected private IP URL to be rejected") - } -} - -func TestProxyTokenScopeValidation(t *testing.T) { - t.Parallel() - - service, err := NewService(&fakeProxyQuerier{}, nil, Config{ProxyTokenSecret: "0123456789abcdef0123456789abcdef"}) - if err != nil { - t.Fatalf("failed to create service: %v", err) - } - token, err := service.issueProxyToken("https://example.com/playlist.m3u8", "", proxyScopeStream) - if err != nil { - t.Fatalf("failed to issue token: %v", err) - } - - _, _, err = service.resolveProxyToken(context.Background(), token, proxyScopeSegment) - if err == nil { - t.Fatal("expected scope mismatch error") - } -} - -type fakeProxyQuerier struct { - db.Querier -} diff --git a/api/playback/service_base.go b/api/playback/service_base.go deleted file mode 100644 index a6e2131..0000000 --- a/api/playback/service_base.go +++ /dev/null @@ -1,392 +0,0 @@ -package playback - -import ( - "context" - "database/sql" - "errors" - "fmt" - "log" - "mal/internal/db" - "net/http" - "strconv" - "strings" - "sync" - "time" - - "github.com/hashicorp/golang-lru/v2" -) - -const ( - providerProbeTimeout = 3 * time.Second -) - -type Service struct { - allAnimeClient *allAnimeClient - httpClient *http.Client - sqlDB *sql.DB - db db.Querier - proxyTokens *proxyTokenSigner - proxyHostCache *lru.Cache[string, proxyHostCacheItem] - showResolution *lru.Cache[int, showResolutionCacheItem] - playbackDataCache *lru.Cache[string, playbackDataCacheItem] -} - -type Config struct { - ProxyTokenSecret string -} - -type sourceScore struct { - source StreamSource - total int - typeScore int - providerScore int - qualityScore int - refererScore int -} - -type showResolutionCacheItem struct { - ShowID string - Title string -} - -type playbackDataCacheItem struct { - Data playbackBaseData -} - -type playbackBaseData struct { - Title string - AvailableModes []string - ModeSources map[string]ModeSource - Segments []SkipSegment - FallbackEpisodes map[string]int -} - -type modeSourceResult struct { - Mode string - Source ModeSource - OK bool -} - -type searchModeResult struct { - Mode string - Results []searchResult - Err error -} - -type directProbeResult struct { - Playable bool - ContentType string -} - -type proxyHostCacheItem struct { - Allowed bool -} - -type userPlaybackState struct { - CurrentStatus string - StartTimeSeconds float64 -} - -// NewService initializes the playback service with db and sql connections. -func NewService(db db.Querier, sqlDB *sql.DB, cfg Config) (*Service, error) { - proxyTokens, err := newProxyTokenSigner(cfg.ProxyTokenSecret) - if err != nil { - return nil, fmt.Errorf("failed to initialize proxy token signer: %w", err) - } - - showResolution, err := lru.New[int, showResolutionCacheItem](5000) - if err != nil { - return nil, err - } - playbackDataCache, err := lru.New[string, playbackDataCacheItem](500) - if err != nil { - return nil, err - } - proxyHostCache, err := lru.New[string, proxyHostCacheItem](1000) - if err != nil { - return nil, err - } - - return &Service{ - allAnimeClient: newAllAnimeClient(), - httpClient: &http.Client{Timeout: 12 * time.Second}, - sqlDB: sqlDB, - db: db, - proxyTokens: proxyTokens, - proxyHostCache: proxyHostCache, - showResolution: showResolution, - playbackDataCache: playbackDataCache, - }, nil -} - -// BuildWatchPageData resolves show metadata and sources for a given MAL ID and episode. -func (s *Service) BuildWatchPageData(ctx context.Context, malID int, titleCandidates []string, episode string, mode string, userID string) (WatchPageData, error) { - if malID <= 0 { - return WatchPageData{}, errors.New("invalid mal id") - } - - normalizedMode := normalizeMode(mode) - if normalizedMode == "" { - normalizedMode = "dub" - } - - normalizedEpisode := strings.TrimSpace(episode) - if normalizedEpisode == "" { - normalizedEpisode = "1" - } - - userStateCh := s.fetchUserPlaybackStateAsync(ctx, userID, malID, normalizedEpisode) - - cacheKey := playbackDataCacheKey(malID, normalizedEpisode) - baseData, cacheHit := s.getPlaybackBaseDataCache(cacheKey) - if !cacheHit { - showID, resolvedTitle, err := s.resolveShowCached(ctx, malID, titleCandidates) - if err != nil { - return WatchPageData{}, err - } - - modeSources, segments := s.fetchPlaybackSourcesAndSegments(ctx, showID, malID, normalizedEpisode) - if len(modeSources) == 0 { - return WatchPageData{}, errors.New("no direct playable sources available") - } - - fallbackEpisodes := make(map[string]int) - if counts, err := s.allAnimeClient.GetAvailableEpisodes(ctx, showID); err == nil { - fallbackEpisodes["sub"] = len(counts.Sub) - fallbackEpisodes["dub"] = len(counts.Dub) - fallbackEpisodes["raw"] = len(counts.Raw) - } - - watchTitle := strings.TrimSpace(resolvedTitle) - if watchTitle == "" { - watchTitle = firstNonEmptyTitle(titleCandidates) - } - if watchTitle == "" { - watchTitle = fmt.Sprintf("MAL #%d", malID) - } - - baseData = playbackBaseData{ - Title: watchTitle, - AvailableModes: availableModes(modeSources), - ModeSources: modeSources, - Segments: segments, - FallbackEpisodes: fallbackEpisodes, - } - - s.setPlaybackBaseDataCache(cacheKey, baseData) - } - - initialMode := selectInitialMode(normalizedMode, baseData.ModeSources) - - clientModeSources, err := s.buildClientModeSources(baseData.ModeSources) - if err != nil { - return WatchPageData{}, err - } - - if _, ok := clientModeSources[initialMode]; !ok { - return WatchPageData{}, errors.New("stream mode unavailable") - } - - segments := baseData.Segments - if segments == nil { - segments = []SkipSegment{} - } - - userState := userPlaybackState{} - if userStateCh != nil { - userState = <-userStateCh - } - - return WatchPageData{ - MalID: malID, - Title: baseData.Title, - CurrentEpisode: normalizedEpisode, - StartTimeSeconds: userState.StartTimeSeconds, - CurrentStatus: userState.CurrentStatus, - InitialMode: initialMode, - AvailableModes: cloneSlice(baseData.AvailableModes), - ModeSources: clientModeSources, - Segments: cloneSlice(segments), - FallbackEpisodes: baseData.FallbackEpisodes, - }, nil -} - -func playbackDataCacheKey(malID int, episode string) string { - return fmt.Sprintf("%d:%s", malID, episode) -} - -func (s *Service) fetchUserPlaybackStateAsync(ctx context.Context, userID string, malID int, episode string) <-chan userPlaybackState { - if userID == "" || s.db == nil { - return nil - } - - resultCh := make(chan userPlaybackState, 1) - go func() { - state := userPlaybackState{} - - entry, err := s.db.GetWatchListEntry(ctx, db.GetWatchListEntryParams{ - UserID: userID, - AnimeID: int64(malID), - }) - if err == nil { - state.CurrentStatus = entry.Status - if entry.CurrentEpisode.Valid && strconv.FormatInt(entry.CurrentEpisode.Int64, 10) == episode && entry.CurrentTimeSeconds > 0 { - state.StartTimeSeconds = entry.CurrentTimeSeconds - } - } - - if state.StartTimeSeconds <= 0 { - continueEntry, continueErr := s.db.GetContinueWatchingEntry(ctx, db.GetContinueWatchingEntryParams{ - UserID: userID, - AnimeID: int64(malID), - }) - if continueErr == nil && continueEntry.CurrentEpisode.Valid && strconv.FormatInt(continueEntry.CurrentEpisode.Int64, 10) == episode && continueEntry.CurrentTimeSeconds > 0 { - state.StartTimeSeconds = continueEntry.CurrentTimeSeconds - } - } - - resultCh <- state - }() - - return resultCh -} - -func (s *Service) getPlaybackBaseDataCache(key string) (playbackBaseData, bool) { - item, ok := s.playbackDataCache.Get(key) - if !ok { - return playbackBaseData{}, false - } - return clonePlaybackBaseData(item.Data), true -} - -func (s *Service) setPlaybackBaseDataCache(key string, data playbackBaseData) { - s.playbackDataCache.Add(key, playbackDataCacheItem{ - Data: clonePlaybackBaseData(data), - }) -} - -func (s *Service) resolveShowCached(ctx context.Context, malID int, titleCandidates []string) (string, string, error) { - if item, ok := s.showResolution.Get(malID); ok && strings.TrimSpace(item.ShowID) != "" { - return item.ShowID, item.Title, nil - } - - showID, resolvedTitle, err := s.resolveShow(ctx, malID, titleCandidates) - if err != nil { - return "", "", err - } - - s.showResolution.Add(malID, showResolutionCacheItem{ - ShowID: showID, - Title: resolvedTitle, - }) - - return showID, resolvedTitle, nil -} - -// fetchPlaybackSourcesAndSegments resolves sources for both dub and sub modes concurrently. -func (s *Service) fetchPlaybackSourcesAndSegments(ctx context.Context, showID string, malID int, episode string) (map[string]ModeSource, []SkipSegment) { - modeCh := make(chan modeSourceResult, 2) - probeCache := make(map[string]directProbeResult) - probeCacheMu := sync.Mutex{} - - // parallel fetch for both modes - for _, mode := range []string{"dub", "sub"} { - modeValue := mode - go func() { - resolved, err := s.resolveModeSourceWithCache(ctx, showID, episode, modeValue, "best", probeCache, &probeCacheMu) - if err != nil { - log.Printf("playback source resolution failed for mode=%s showID=%s episode=%s: %v", modeValue, showID, episode, err) - modeCh <- modeSourceResult{Mode: modeValue, OK: false} - return - } - - if strings.ToLower(resolved.Type) == "embed" { - modeCh <- modeSourceResult{Mode: modeValue, OK: false} - return - } - - modeCh <- modeSourceResult{ - Mode: modeValue, - Source: ModeSource{ - URL: resolved.URL, - Referer: resolved.Referer, - Subtitles: toSubtitleItems(resolved), - Qualities: toQualities(resolved.AvailableQualities), - }, - OK: true, - } - }() - } - - segmentsCh := make(chan []SkipSegment, 1) - go func() { - segmentsCh <- s.fetchSkipSegments(ctx, malID, episode) - }() - - modeSources := make(map[string]ModeSource) - // collect results from both mode goroutines - for range 2 { - result := <-modeCh - if !result.OK { - continue - } - modeSources[result.Mode] = result.Source - } - - segments := <-segmentsCh - return modeSources, segments -} - -func clonePlaybackBaseData(data playbackBaseData) playbackBaseData { - return playbackBaseData{ - Title: data.Title, - AvailableModes: cloneSlice(data.AvailableModes), - ModeSources: cloneModeSources(data.ModeSources), - Segments: cloneSlice(data.Segments), - FallbackEpisodes: data.FallbackEpisodes, - } -} - -func toQualities(sources []StreamSource) []string { - seen := make(map[string]struct{}) - var qualities []string - for _, s := range sources { - q := strings.TrimSpace(s.Quality) - if q == "" || q == "auto" { - continue - } - if _, ok := seen[q]; !ok { - seen[q] = struct{}{} - qualities = append(qualities, q) - } - } - return qualities -} - -func cloneSlice[T any](items []T) []T { - if items == nil { - return []T{} - } - if len(items) == 0 { - return []T{} - } - cloned := make([]T, len(items)) - copy(cloned, items) - return cloned -} - -func cloneModeSources(modeSources map[string]ModeSource) map[string]ModeSource { - if len(modeSources) == 0 { - return nil - } - cloned := make(map[string]ModeSource, len(modeSources)) - for mode, source := range modeSources { - cloned[mode] = ModeSource{ - URL: source.URL, - Referer: source.Referer, - Subtitles: cloneSlice(source.Subtitles), - Qualities: cloneSlice(source.Qualities), - } - } - return cloned -} diff --git a/api/playback/service_http.go b/api/playback/service_http.go deleted file mode 100644 index 9eefec3..0000000 --- a/api/playback/service_http.go +++ /dev/null @@ -1,74 +0,0 @@ -package playback - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "strconv" - "strings" -) - -// fetchSkipSegments queries aniskip API for OP/ED skip times. -// returns nil if the API is unavailable or has no data. -func (s *Service) fetchSkipSegments(ctx context.Context, malID int, episode string) []SkipSegment { - if malID <= 0 || strings.TrimSpace(episode) == "" { - return nil - } - - endpoint := fmt.Sprintf("https://api.aniskip.com/v1/skip-times/%s/%s?types=op&types=ed", url.PathEscape(strconv.Itoa(malID)), url.PathEscape(episode)) - resp, err := doProxiedRequest(ctx, s.httpClient, endpoint, "") - if err != nil { - return nil - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil - } - - body, err := io.ReadAll(io.LimitReader(resp.Body, 512*1024)) - if err != nil { - return nil - } - - type resultItem struct { - SkipType string `json:"skip_type"` - Interval struct { - StartTime float64 `json:"start_time"` - EndTime float64 `json:"end_time"` - } `json:"interval"` - } - type apiResponse struct { - Found bool `json:"found"` - Result []resultItem `json:"results"` - } - - var parsed apiResponse - if err := json.Unmarshal(body, &parsed); err != nil { - return nil - } - - // filter to valid OP/ED segments - segments := make([]SkipSegment, 0, len(parsed.Result)) - for _, item := range parsed.Result { - if item.Interval.EndTime <= item.Interval.StartTime { - continue - } - - t := strings.ToLower(item.SkipType) - if t != "op" && t != "ed" { - continue - } - - segments = append(segments, SkipSegment{ - Type: t, - Start: item.Interval.StartTime, - End: item.Interval.EndTime, - }) - } - - return segments -} diff --git a/api/playback/service_proxy.go b/api/playback/service_proxy.go deleted file mode 100644 index ea61f1c..0000000 --- a/api/playback/service_proxy.go +++ /dev/null @@ -1,119 +0,0 @@ -package playback - -import ( - "context" - "fmt" - "io" - "log" - "net/http" - "strconv" - "strings" - "time" -) - -// ProxyStream fetches a stream URL and returns the response. -// retries on failure, rewrites m3u8 playlists to include auth tokens. -func (s *Service) ProxyStream(ctx context.Context, targetURL string, referer string, rangeHeader string) (int, http.Header, []byte, io.ReadCloser, error) { - const maxRetries = 2 - const retryDelay = 500 * time.Millisecond - - var lastErr error - for attempt := 0; attempt <= maxRetries; attempt++ { - if attempt > 0 { - select { - case <-ctx.Done(): - return 0, nil, nil, nil, ctx.Err() - case <-time.After(retryDelay): - } - log.Printf("retrying proxy request for %s (attempt %d/%d)", targetURL, attempt, maxRetries) - } - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, targetURL, nil) - if err != nil { - return 0, nil, nil, nil, fmt.Errorf("invalid upstream url: %w", err) - } - - if referer != "" { - req.Header.Set("Referer", referer) - } - req.Header.Set("User-Agent", defaultUserAgent) - if rangeHeader != "" { - req.Header.Set("Range", rangeHeader) - } - - resp, err := s.httpClient.Do(req) - if err != nil { - lastErr = err - continue - } - - return s.handleProxyResponse(ctx, resp, targetURL, referer) - } - - return 0, nil, nil, nil, fmt.Errorf("upstream request failed after %d retries: %w", maxRetries+1, lastErr) -} - -// handleProxyResponse processes the upstream response. -// rewrites m3u8 playlists to proxy through our backend. -func (s *Service) handleProxyResponse(ctx context.Context, resp *http.Response, targetURL string, referer string) (int, http.Header, []byte, io.ReadCloser, error) { - - // check if response is an m3u8 playlist that needs rewriting - if isM3U8(targetURL, resp.Header.Get("Content-Type")) { - defer func() { _ = resp.Body.Close() }() - body, readErr := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024)) - if readErr != nil { - return 0, nil, nil, nil, fmt.Errorf("read playlist failed: %w", readErr) - } - - rewritten, rewriteErr := s.rewritePlaylistWithTokens(ctx, string(body), targetURL, referer) - if rewriteErr != nil { - return 0, nil, nil, nil, fmt.Errorf("rewrite playlist failed: %w", rewriteErr) - } - - headers := cloneHeaders(resp.Header) - headers.Del("Content-Length") - headers.Del("Transfer-Encoding") - headers.Set("Content-Type", "application/vnd.apple.mpegurl") - headers.Set("Content-Length", strconv.Itoa(len(rewritten))) - return resp.StatusCode, headers, []byte(rewritten), nil, nil - } - - // for binary streams, remove chunked encoding and return body reader - headers := cloneHeaders(resp.Header) - headers.Del("Transfer-Encoding") - return resp.StatusCode, headers, nil, resp.Body, nil -} - -// isM3U8 checks if the response is an m3u8 playlist by URL or content-type. -func isM3U8(targetURL string, contentType string) bool { - if strings.Contains(strings.ToLower(targetURL), ".m3u8") { - return true - } - lowerType := strings.ToLower(contentType) - return strings.Contains(lowerType, "application/vnd.apple.mpegurl") || strings.Contains(lowerType, "application/x-mpegurl") -} - -var hopHeaders = map[string]struct{}{ - "connection": {}, - "keep-alive": {}, - "proxy-authenticate": {}, - "proxy-authorization": {}, - "te": {}, - "trailers": {}, - "upgrade": {}, -} - -// cloneHeaders copies headers, filtering out hop-by-hop headers. -// hop-by-hop headers are specific to a single transport connection. -func cloneHeaders(src http.Header) http.Header { - dst := make(http.Header) - for key, values := range src { - if _, ok := hopHeaders[strings.ToLower(key)]; ok { - continue - } - for _, value := range values { - dst.Add(key, value) - } - } - return dst -} diff --git a/api/playback/service_ranking.go b/api/playback/service_ranking.go deleted file mode 100644 index 07e985c..0000000 --- a/api/playback/service_ranking.go +++ /dev/null @@ -1,188 +0,0 @@ -package playback - -import ( - "bytes" - "errors" - "sort" - "strconv" - "strings" -) - -func rankSources(sources []StreamSource, quality string) ([]sourceScore, error) { - filtered := make([]StreamSource, 0, len(sources)) - seen := make(map[string]struct{}) - - for _, source := range sources { - if source.URL == "" { - continue - } - if _, exists := seen[source.URL]; exists { - continue - } - seen[source.URL] = struct{}{} - filtered = append(filtered, source) - } - - if len(filtered) == 0 { - return nil, errors.New("no playable sources available") - } - - targetQuality := normalizeQuality(quality) - scored := make([]sourceScore, 0, len(filtered)) - for _, source := range filtered { - typeScore := lookupPriority(sourceTypePriority, source.Type, 200) - providerScore := lookupPriority(providerPriority, source.Provider, 60) - qualityScore := sourceQualityPriority(source.Quality, targetQuality) - refererScore := 0 - if source.Referer != "" { - refererScore = 20 - } - - total := typeScore + providerScore + qualityScore + refererScore - scored = append(scored, sourceScore{ - source: source, - total: total, - typeScore: typeScore, - providerScore: providerScore, - qualityScore: qualityScore, - refererScore: refererScore, - }) - } - - // stable sort to preserve insertion order for equal scores - sort.SliceStable(scored, func(i int, j int) bool { - return scored[i].total > scored[j].total - }) - - return scored, nil -} - -func normalizeQuality(quality string) string { - lower := strings.ToLower(strings.TrimSpace(quality)) - if lower == "" { - return "best" - } - - return lower -} - -var sourceTypePriority = map[string]int{ - "mp4": 500, - "m3u8": 450, - "unknown": 300, - "embed": 100, -} - -var providerPriority = map[string]int{ - "s-mp4": 120, - "default": 115, - "luf-mp4": 110, - "vid-mp4": 105, - "yt-mp4": 100, - "mp4": 95, - "uv-mp4": 90, - "hls": 80, - "sw": 40, - "ok": 35, - "ss-hls": 30, -} - -func lookupPriority(m map[string]int, key string, fallback int) int { - if p, ok := m[strings.ToLower(key)]; ok { - return p - } - return fallback -} - -// sourceQualityPriority scores quality match: exact match gets boost, mismatch gets penalty. -func sourceQualityPriority(sourceQuality string, targetQuality string) int { - qualityValue := parseQualityValue(sourceQuality) - - switch targetQuality { - case "best": - return qualityValue - case "worst": - return -qualityValue - default: - if qualityMatches(sourceQuality, targetQuality) { - return 2000 + qualityValue - } - - return -300 + qualityValue - } -} - -// qualityMatches checks if source matches target by substring or extracted digits. -func qualityMatches(sourceQuality string, targetQuality string) bool { - sourceLower := strings.ToLower(sourceQuality) - targetLower := strings.ToLower(targetQuality) - - if sourceLower == "" { - return false - } - - if strings.Contains(sourceLower, targetLower) { - return true - } - - return extractDigits(sourceLower) == extractDigits(targetLower) -} - -// parseQualityValue extracts numeric value from quality string. -func parseQualityValue(rawQuality string) int { - lower := strings.ToLower(rawQuality) - if lower == "auto" { - return 240 - } - - digits := extractDigits(lower) - if digits == "" { - return 0 - } - - value, err := strconv.Atoi(digits) - if err != nil { - return 0 - } - return value -} - -// extractDigits reads leading digits until a non-digit or break condition. -func extractDigits(value string) string { - var digits []byte - for _, char := range value { - if char >= '0' && char <= '9' { - digits = append(digits, byte(char)) - } else if len(digits) > 0 { - break - } - } - return string(digits) -} - -// normalizeSourceTypeFromProbe overrides source type based on Content-Type header. -func normalizeSourceTypeFromProbe(source StreamSource, contentType string) StreamSource { - lower := strings.ToLower(contentType) - switch { - case strings.Contains(lower, "video/mp4"): - source.Type = "mp4" - case strings.Contains(lower, "mpegurl"): - source.Type = "m3u8" - } - return source -} - -// isLikelyMP4 checks ftyp box header (bytes 4-8 of mp4 files). -func isLikelyMP4(payload []byte) bool { - if len(payload) < 12 { - return false - } - - return bytes.Equal(payload[4:8], []byte("ftyp")) -} - -// isLikelyM3U8 checks for m3u8 file header. -func isLikelyM3U8(payload []byte) bool { - trimmed := strings.TrimSpace(string(payload)) - return strings.HasPrefix(trimmed, "#EXTM3U") -} diff --git a/api/playback/service_ranking_test.go b/api/playback/service_ranking_test.go deleted file mode 100644 index 82b20b9..0000000 --- a/api/playback/service_ranking_test.go +++ /dev/null @@ -1,491 +0,0 @@ -package playback - -import ( - "testing" -) - -func TestRankSources(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - sources []StreamSource - quality string - wantErr bool - }{ - { - name: "empty sources returns error", - sources: nil, - quality: "best", - wantErr: true, - }, - { - name: "filters empty URLs", - sources: []StreamSource{ - {URL: "", Type: "mp4"}, - {URL: "https://example.com/v.mp4", Type: "mp4"}, - }, - quality: "best", - wantErr: false, - }, - { - name: "deduplicates URLs", - sources: []StreamSource{ - {URL: "https://a.com/v.mp4", Type: "mp4"}, - {URL: "https://b.com/v.mp4", Type: "m3u8"}, - {URL: "https://a.com/v.mp4", Type: "mp4"}, - }, - quality: "best", - wantErr: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - _, err := rankSources(tt.sources, tt.quality) - if (err != nil) != tt.wantErr { - t.Errorf("rankSources() error = %v, wantErr %v", err, tt.wantErr) - } - }) - } -} - -func TestRankSourcesOrdering(t *testing.T) { - t.Parallel() - - sources := []StreamSource{ - {URL: "https://embed.com/v.mp4", Type: "embed", Provider: "streamwish"}, - {URL: "https://mp4.com/v.mp4", Type: "mp4", Provider: "s-mp4"}, - {URL: "https://m3u8.com/v.m3u8", Type: "m3u8", Provider: "default"}, - {URL: "https://unknown.com/v.mp4", Type: "unknown", Provider: "other"}, - } - - ranked, err := rankSources(sources, "best") - if err != nil { - t.Fatalf("rankSources() error = %v", err) - } - - if len(ranked) != 4 { - t.Fatalf("got %d sources, want 4", len(ranked)) - } - - if ranked[0].source.Type != "mp4" { - t.Errorf("ranked[0] = %q, want mp4 (type priority: mp4 > m3u8 > unknown > embed)", ranked[0].source.Type) - } - if ranked[1].source.Type != "m3u8" { - t.Errorf("ranked[1] = %q, want m3u8", ranked[1].source.Type) - } -} - -func TestRankSourcesWithQuality(t *testing.T) { - t.Parallel() - - sources := []StreamSource{ - {URL: "https://a.com/v.mp4", Quality: "1080p", Type: "mp4"}, - {URL: "https://b.com/v.mp4", Quality: "720p", Type: "mp4"}, - {URL: "https://c.com/v.mp4", Quality: "480p", Type: "mp4"}, - } - - ranked, err := rankSources(sources, "1080p") - if err != nil { - t.Fatalf("rankSources() error = %v", err) - } - - if ranked[0].source.Quality != "1080p" { - t.Errorf("ranked[0].Quality = %q, want 1080p", ranked[0].source.Quality) - } -} - -func TestNormalizeQuality(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - quality string - wantNorm string - }{ - { - name: "empty returns best", - quality: "", - wantNorm: "best", - }, - { - name: "lowercase best", - quality: "BEST", - wantNorm: "best", - }, - { - name: "with spaces", - quality: " 720p ", - wantNorm: "720p", - }, - { - name: "worst", - quality: "worst", - wantNorm: "worst", - }, - { - name: "specific quality", - quality: "1080p", - wantNorm: "1080p", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - got := normalizeQuality(tt.quality) - if got != tt.wantNorm { - t.Errorf("normalizeQuality(%q) = %q, want %q", tt.quality, got, tt.wantNorm) - } - }) - } -} - -func TestParseQualityValue(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - quality string - want int - }{ - { - name: "auto returns 240", - quality: "auto", - want: 240, - }, - { - name: "1080p extracts 1080", - quality: "1080p", - want: 1080, - }, - { - name: "720 extracts 720", - quality: "720", - want: 720, - }, - { - name: "fhd is treated as fhd", - quality: "fhd", - want: 0, - }, - { - name: "empty returns 0", - quality: "", - want: 0, - }, - { - name: "multiple digits stops at first non-digit", - quality: "1080p60fps", - want: 1080, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - got := parseQualityValue(tt.quality) - if got != tt.want { - t.Errorf("parseQualityValue(%q) = %d, want %d", tt.quality, got, tt.want) - } - }) - } -} - -func TestQualityMatches(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - source string - target string - want bool - }{ - { - name: "exact match", - source: "1080p", - target: "1080p", - want: true, - }, - { - name: "target in source", - source: "1920x1080", - target: "1080", - want: true, - }, - { - name: "digit match", - source: "1080p", - target: "1080", - want: true, - }, - { - name: "no match", - source: "720p", - target: "1080", - want: false, - }, - { - name: "empty source returns false", - source: "", - target: "1080", - want: false, - }, - { - name: "empty target returns true (empty always contained)", - source: "1080p", - target: "", - want: true, - }, - { - name: "auto doesn't match specific", - source: "auto", - target: "1080", - want: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - got := qualityMatches(tt.source, tt.target) - if got != tt.want { - t.Errorf("qualityMatches(%q, %q) = %v, want %v", tt.source, tt.target, got, tt.want) - } - }) - } -} - -func TestSourceQualityPriority(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - source string - target string - wantMin int - }{ - { - name: "best mode favors higher quality", - source: "1080p", - target: "best", - wantMin: 1080, - }, - { - name: "worst mode penalizes higher quality", - source: "1080p", - target: "worst", - wantMin: -2000, - }, - { - name: "exact match gets bonus", - source: "1080p", - target: "1080p", - wantMin: 2000, - }, - { - name: "close match gets penalty but positive score", - source: "1080p", - target: "720p", - wantMin: 500, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - got := sourceQualityPriority(tt.source, tt.target) - - if tt.wantMin != 0 && got < tt.wantMin { - t.Errorf("sourceQualityPriority(%q, %q) = %d, want >= %d", tt.source, tt.target, got, tt.wantMin) - } - }) - } -} - -func TestSourceTypePriorityLookup(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - sourceType string - want int - }{ - { - name: "mp4 priority", - sourceType: "mp4", - want: 500, - }, - { - name: "m3u8 priority", - sourceType: "m3u8", - want: 450, - }, - { - name: "unknown uses fallback", - sourceType: "unknown", - want: 300, - }, - { - name: "embed fallback", - sourceType: "embed", - want: 100, - }, - { - name: "unrecognized uses fallback", - sourceType: "video", - want: 200, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - got := lookupPriority(sourceTypePriority, tt.sourceType, 200) - if got != tt.want { - t.Errorf("lookupPriority(sourceTypePriority, %q, 200) = %d, want %d", tt.sourceType, got, tt.want) - } - }) - } -} - -func TestProviderPriorityLookup(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - provider string - want int - }{ - { - name: "s-mp4", - provider: "s-mp4", - want: 120, - }, - { - name: "default", - provider: "default", - want: 115, - }, - { - name: "yt-mp4", - provider: "yt-mp4", - want: 100, - }, - { - name: "unknown uses fallback", - provider: "unknown", - want: 60, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - got := lookupPriority(providerPriority, tt.provider, 60) - if got != tt.want { - t.Errorf("lookupPriority(providerPriority, %q, 60) = %d, want %d", tt.provider, got, tt.want) - } - }) - } -} - -func TestNormalizeSourceTypeFromProbe(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - source StreamSource - contentType string - wantType string - }{ - { - name: "video/mp4 normalizes to mp4", - source: StreamSource{Type: "unknown"}, - contentType: "video/mp4", - wantType: "mp4", - }, - { - name: "application/octet-stream unchanged", - source: StreamSource{Type: "mp4"}, - contentType: "application/octet-stream", - wantType: "mp4", - }, - { - name: "mpegurl normalizes to m3u8", - source: StreamSource{Type: "unknown"}, - contentType: "application/vnd.apple.mpegurl", - wantType: "m3u8", - }, - { - name: "video/mpegurl", - source: StreamSource{Type: "unknown"}, - contentType: "video/mpegurl", - wantType: "m3u8", - }, - { - name: "case insensitive", - source: StreamSource{Type: "unknown"}, - contentType: "VIDEO/MP4", - wantType: "mp4", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - got := normalizeSourceTypeFromProbe(tt.source, tt.contentType) - if got.Type != tt.wantType { - t.Errorf("normalizeSourceTypeFromProbe().Type = %q, want %q", got.Type, tt.wantType) - } - }) - } -} - -func TestExtractDigits(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - value string - want string - }{ - { - name: "extracts digits", - value: "1080p", - want: "1080", - }, - { - name: "empty if no digits", - value: "p", - want: "", - }, - { - name: "stops at non-digit after digits", - value: "720p60", - want: "720", - }, - { - name: "multiple non-digit does not break", - value: "abc123def", - want: "123", - }, - { - name: "all digits", - value: "1080", - want: "1080", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - got := extractDigits(tt.value) - if got != tt.want { - t.Errorf("extractDigits(%q) = %q, want %q", tt.value, got, tt.want) - } - }) - } -} diff --git a/api/playback/service_resolution.go b/api/playback/service_resolution.go deleted file mode 100644 index f8b25e2..0000000 --- a/api/playback/service_resolution.go +++ /dev/null @@ -1,171 +0,0 @@ -package playback - -import ( - "context" - "errors" - "sort" - "strconv" - "strings" - "sync" -) - -func (s *Service) resolveShow(ctx context.Context, malID int, titleCandidates []string) (string, string, error) { - malText := strconv.Itoa(malID) - modeCandidates := []string{"sub", "dub"} - queries := buildTitleSearchQueries(titleCandidates) - - for _, query := range queries { - resultsByMode := s.searchShowResultsByMode(ctx, query, modeCandidates) - - for _, mode := range modeCandidates { - for _, result := range resultsByMode[mode] { - // exact mal id match - if strings.TrimSpace(result.MalID) == malText && strings.TrimSpace(result.ID) != "" { - return result.ID, result.Name, nil - } - } - } - - for _, mode := range modeCandidates { - results := resultsByMode[mode] - if len(results) == 0 { - continue - } - - // fallback to first result if no exact match - best := results[0] - if strings.TrimSpace(best.ID) != "" { - return best.ID, best.Name, nil - } - } - } - - return "", "", errors.New("unable to resolve allanime show") -} - -func (s *Service) searchShowResultsByMode(ctx context.Context, query string, modeCandidates []string) map[string][]searchResult { - resultsByMode := make(map[string][]searchResult, len(modeCandidates)) - searchCh := make(chan searchModeResult, len(modeCandidates)) - - var wg sync.WaitGroup - for _, mode := range modeCandidates { - modeValue := mode // capture loop variable - wg.Go(func() { - results, err := s.allAnimeClient.Search(ctx, query, modeValue) - searchCh <- searchModeResult{Mode: modeValue, Results: results, Err: err} - }) - } - - wg.Wait() - close(searchCh) - - for result := range searchCh { - if result.Err != nil { - continue - } - - resultsByMode[result.Mode] = result.Results - } - - return resultsByMode -} - -func buildTitleSearchQueries(titleCandidates []string) []string { - queries := make([]string, 0, len(titleCandidates)*4) - seen := make(map[string]struct{}) - - add := func(raw string) { - normalized := normalizeSearchQuery(raw) - if normalized == "" { - return - } - - key := strings.ToLower(normalized) - if _, exists := seen[key]; exists { - return - } - - seen[key] = struct{}{} - queries = append(queries, normalized) - } - - for _, candidate := range titleCandidates { - normalized := normalizeSearchQuery(candidate) - if normalized == "" { - continue - } - - add(normalized) - add(strings.ReplaceAll(normalized, "+", " ")) - - // strip apostrophes to improve match rate - withoutApostrophes := strings.NewReplacer("'", "", "’", "", "`", "").Replace(normalized) - add(withoutApostrophes) - add(strings.ReplaceAll(withoutApostrophes, "+", " ")) - } - - return queries -} - -func normalizeSearchQuery(raw string) string { - return strings.Join(strings.Fields(strings.TrimSpace(raw)), " ") -} - -func firstNonEmptyTitle(values []string) string { - for _, value := range values { - normalized := strings.TrimSpace(value) - if normalized != "" { - return normalized - } - } - - return "" -} - -func normalizeMode(raw string) string { - return strings.ToLower(strings.TrimSpace(raw)) -} - -func availableModes(modeSources map[string]ModeSource) []string { - preferred := []string{"dub", "sub"} - ordered := make([]string, 0, len(modeSources)) - for _, mode := range preferred { - if _, ok := modeSources[mode]; ok { - ordered = append(ordered, mode) - } - } - - extra := make([]string, 0) - for mode := range modeSources { - if mode == "dub" || mode == "sub" { - continue - } - extra = append(extra, mode) - } - sort.Strings(extra) - - return append(ordered, extra...) -} - -// selectInitialMode picks a mode prioritizing: requested mode > dub > sub > first available. -func selectInitialMode(requestedMode string, modeSources map[string]ModeSource) string { - normalizedRequested := normalizeMode(requestedMode) - if normalizedRequested != "" { - if _, ok := modeSources[normalizedRequested]; ok { - return normalizedRequested - } - } - - if _, ok := modeSources["dub"]; ok { - return "dub" - } - if _, ok := modeSources["sub"]; ok { - return "sub" - } - - for mode := range modeSources { - return mode - } - - return "dub" -} diff --git a/api/playback/service_sources.go b/api/playback/service_sources.go deleted file mode 100644 index cefef71..0000000 --- a/api/playback/service_sources.go +++ /dev/null @@ -1,224 +0,0 @@ -package playback - -import ( - "context" - "errors" - "io" - "net/http" - "strings" - "sync" -) - -// resolveModeSourceWithCache is like resolveModeSource but caches probe results. -func (s *Service) resolveModeSourceWithCache( - ctx context.Context, - showID string, - episode string, - mode string, - quality string, - probeCache map[string]directProbeResult, - probeCacheMu *sync.Mutex, -) (StreamSource, error) { - sources, err := s.allAnimeClient.GetEpisodeSources(ctx, showID, episode, mode) - if err != nil { - return StreamSource{}, err - } - - ranked, err := rankSources(sources, quality) - if err != nil { - return StreamSource{}, err - } - - selected, _, err := s.choosePlaybackSourceWithCache(ctx, ranked, probeCache, probeCacheMu) - if err != nil { - return StreamSource{}, err - } - - selected.AvailableQualities = sources - return selected, nil -} - -// choosePlaybackSource selects the best playable source from ranked candidates. -// priority: direct media > probed media > embed sources > ranked fallback. -func (s *Service) choosePlaybackSource( - ctx context.Context, - ranked []sourceScore, - probeFn func(context.Context, StreamSource) (bool, string), -) (StreamSource, string, error) { - if len(ranked) == 0 { - return StreamSource{}, "", errors.New("no ranked sources available") - } - - embedCandidates := make([]StreamSource, 0, len(ranked)) - for _, candidate := range ranked { - source := candidate.source - switch strings.ToLower(source.Type) { - case "mp4", "m3u8": - return source, "direct-media", nil // known playable types - case "embed": - embedCandidates = append(embedCandidates, source) // need probing - default: - // probe unknown types - if playable, contentType := probeFn(ctx, source); playable { - return normalizeSourceTypeFromProbe(source, contentType), "probed-media", nil - } - } - } - - // check embed sources for playability - for _, embed := range embedCandidates { - if s.probeEmbedSource(ctx, embed) { - return embed, "embed-probed", nil - } - } - - // fallback to first embed or first ranked - if len(embedCandidates) > 0 { - return embedCandidates[0], "embed-fallback", nil - } - - return ranked[0].source, "ranked-fallback", nil -} - -// choosePlaybackSourceWithCache wraps choosePlaybackSource with cached probing. -func (s *Service) choosePlaybackSourceWithCache( - ctx context.Context, - ranked []sourceScore, - probeCache map[string]directProbeResult, - probeCacheMu *sync.Mutex, -) (StreamSource, string, error) { - return s.choosePlaybackSource(ctx, ranked, func(ctx context.Context, source StreamSource) (bool, string) { - return s.probeDirectMediaCached(ctx, source, probeCache, probeCacheMu) - }) -} - -func (s *Service) probeDirectMediaCached( - ctx context.Context, - source StreamSource, - probeCache map[string]directProbeResult, - probeCacheMu *sync.Mutex, -) (bool, string) { - cacheKey := strings.TrimSpace(source.URL) - if cacheKey == "" { - return s.probeDirectMedia(ctx, source) - } - - probeCacheMu.Lock() - cached, ok := probeCache[cacheKey] - probeCacheMu.Unlock() - if ok { - return cached.Playable, cached.ContentType - } - - playable, contentType := s.probeDirectMedia(ctx, source) - - probeCacheMu.Lock() - probeCache[cacheKey] = directProbeResult{Playable: playable, ContentType: contentType} - probeCacheMu.Unlock() - - return playable, contentType -} - -// probeDirectMedia checks if a direct media URL is playable. -// checks content-type header, reads prefix for magic bytes, falls back to URL extension. -func (s *Service) probeDirectMedia(ctx context.Context, source StreamSource) (bool, string) { - probeCtx, cancel := context.WithTimeout(ctx, providerProbeTimeout) - defer cancel() - - req, err := http.NewRequestWithContext(probeCtx, http.MethodGet, source.URL, nil) - if err != nil { - return false, "" - } - - if source.Referer != "" { - req.Header.Set("Referer", source.Referer) - } - req.Header.Set("User-Agent", defaultUserAgent) - req.Header.Set("Range", "bytes=0-4095") // small range to detect playable content - - resp, err := s.httpClient.Do(req) - if err != nil { - return false, "" - } - defer func() { _ = resp.Body.Close() }() - - // check content-type header first - contentType := strings.ToLower(resp.Header.Get("Content-Type")) - if strings.Contains(contentType, "video/") || strings.Contains(contentType, "mpegurl") { - return true, contentType - } - - // check magic bytes in prefix - prefix, err := io.ReadAll(io.LimitReader(resp.Body, 4096)) - if err == nil { - if isLikelyM3U8(prefix) { - return true, "application/vnd.apple.mpegurl" - } - if isLikelyMP4(prefix) { - return true, "video/mp4" - } - } - - // fallback to URL extension - finalURL := "" - if resp.Request != nil && resp.Request.URL != nil { - finalURL = strings.ToLower(resp.Request.URL.String()) - } - - if strings.Contains(finalURL, ".mp4") || strings.Contains(finalURL, ".m3u8") { - return true, contentType - } - - return false, contentType -} - -// probeEmbedSource checks if an embed page is still available. -// returns false if the page contains deletion markers. -func (s *Service) probeEmbedSource(ctx context.Context, source StreamSource) bool { - ctx, cancel := context.WithTimeout(ctx, providerProbeTimeout) - defer cancel() - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, source.URL, nil) - if err != nil { - return false - } - - if source.Referer != "" { - req.Header.Set("Referer", source.Referer) - } - req.Header.Set("User-Agent", defaultUserAgent) - - resp, err := s.httpClient.Do(req) - if err != nil { - return false - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode >= http.StatusBadRequest { - return false - } - - // check for common deletion messages - body, err := io.ReadAll(io.LimitReader(resp.Body, 512*1024)) - if err != nil { - return false - } - - content := strings.ToLower(string(body)) - for _, marker := range []string{ - "file was deleted", - "file has been deleted", - "video was deleted", - "video has been deleted", - "video unavailable", - "file not found", - "this file does not exist", - "resource unavailable", - } { - if strings.Contains(content, marker) { - return false - } - } - - return true -} diff --git a/api/playback/service_utils.go b/api/playback/service_utils.go deleted file mode 100644 index 43c022a..0000000 --- a/api/playback/service_utils.go +++ /dev/null @@ -1,24 +0,0 @@ -package playback - -import ( - "strings" -) - -// toSubtitleItems converts raw subtitle entries into client-safe items. -func toSubtitleItems(source StreamSource) []SubtitleItem { - items := make([]SubtitleItem, 0, len(source.Subtitles)) - for _, subtitle := range source.Subtitles { - targetURL := strings.TrimSpace(subtitle.URL) - if targetURL == "" { - continue - } - - items = append(items, SubtitleItem{ - Lang: strings.TrimSpace(subtitle.Lang), - URL: targetURL, - Referer: source.Referer, - }) - } - - return items -} diff --git a/api/playback/types.go b/api/playback/types.go deleted file mode 100644 index 92d6d5b..0000000 --- a/api/playback/types.go +++ /dev/null @@ -1,52 +0,0 @@ -package playback - -// StreamSource represents a video stream from a provider. -type StreamSource struct { - URL string - Quality string - Provider string - Type string // m3u8, mp4, embed, unknown - Referer string - Subtitles []Subtitle - AvailableQualities []StreamSource -} - -type Subtitle struct { - Lang string - URL string -} - -type ModeSource struct { - URL string `json:"url,omitempty"` - Referer string `json:"referer,omitempty"` - Token string `json:"token"` - Subtitles []SubtitleItem `json:"subtitles"` - Qualities []string `json:"qualities,omitempty"` -} - -type SubtitleItem struct { - Lang string `json:"lang"` - URL string `json:"url,omitempty"` - Referer string `json:"referer,omitempty"` - Token string `json:"token"` -} - -type SkipSegment struct { - Type string `json:"type"` - Start float64 `json:"start"` - End float64 `json:"end"` -} - -// WatchPageData is the response payload for the watch page frontend. -type WatchPageData struct { - MalID int - Title string - CurrentEpisode string - StartTimeSeconds float64 - CurrentStatus string - InitialMode string - AvailableModes []string - ModeSources map[string]ModeSource - Segments []SkipSegment - FallbackEpisodes map[string]int -} diff --git a/api/watchlist/handler.go b/api/watchlist/handler.go deleted file mode 100644 index f060cb3..0000000 --- a/api/watchlist/handler.go +++ /dev/null @@ -1,174 +0,0 @@ -package watchlist - -import ( - "context" - "encoding/json" - "errors" - "log" - "net/http" - "strconv" - - "mal/internal/db" - "mal/internal/middleware" - "mal/templates" -) - -type Handler struct { - service *Service -} - -func NewHandler(service *Service) *Handler { - return &Handler{service: service} -} - -// HandleUpdateWatchlist adds or updates anime in user's watchlist. accepts json {animeId, status}. -func (h *Handler) HandleUpdateWatchlist(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - - user := middleware.GetUser(r.Context()) - if user == nil { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } - - var body struct { - AnimeID int64 `json:"animeId"` - Status string `json:"status"` - } - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - http.Error(w, "invalid request", http.StatusBadRequest) - return - } - - // default status if not provided - if body.Status == "" { - body.Status = "plan_to_watch" - } - - if err := h.service.AddToWatchlist(r.Context(), user.ID, body.AnimeID, body.Status); err != nil { - log.Printf("failed to add to watchlist: %v", err) - http.Error(w, "failed to add to watchlist", http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusOK) -} - -// HandleDeleteWatchlist removes anime from user's watchlist. expects /api/watchlist/{animeId}. -func (h *Handler) HandleDeleteWatchlist(w http.ResponseWriter, r *http.Request) { - user := middleware.GetUser(r.Context()) - if user == nil { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } - - animeIDStr := r.URL.Path[len("/api/watchlist/"):] - animeID, err := strconv.ParseInt(animeIDStr, 10, 64) - if err != nil { - http.Error(w, "invalid anime id", http.StatusBadRequest) - return - } - - if _, err := h.service.RemoveEntry(r.Context(), user.ID, animeID); err != nil { - log.Printf("failed to remove from watchlist: %v", err) - http.Error(w, "failed to remove from watchlist", http.StatusInternalServerError) - return - } - - // htmx: redirect to watchlist page after delete - w.Header().Set("HX-Redirect", "/watchlist") - w.WriteHeader(http.StatusOK) -} - -// HandleDeleteContinueWatching removes entry from user's continue watching. expects /api/continue-watching/{animeId}. -func (h *Handler) HandleDeleteContinueWatching(w http.ResponseWriter, r *http.Request) { - user := middleware.GetUser(r.Context()) - if user == nil { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } - - animeIDStr := r.URL.Path[len("/api/continue-watching/"):] - animeID, err := strconv.ParseInt(animeIDStr, 10, 64) - if err != nil { - http.Error(w, "invalid anime id", http.StatusBadRequest) - return - } - - if err := h.service.DeleteContinueWatching(r.Context(), user.ID, animeID); err != nil { - log.Printf("failed to remove from continue watching: %v", err) - http.Error(w, "failed to remove from continue watching", http.StatusInternalServerError) - return - } - - w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") - w.WriteHeader(http.StatusOK) -} - -// HandleGetWatchlist renders user's watchlist page, grouped by status. -func (h *Handler) HandleGetWatchlist(w http.ResponseWriter, r *http.Request) { - user := middleware.GetUser(r.Context()) - if user == nil { - http.Redirect(w, r, "/login", http.StatusSeeOther) - return - } - - entries, err := h.service.GetUserWatchlist(r.Context(), user.ID) - if err != nil { - log.Printf("failed to fetch watchlist: %v", err) - if err := templates.GetRenderer().ExecuteTemplate(r.Context(), w, "not_found.gohtml", map[string]any{ - "CurrentPath": r.URL.Path, - }); err != nil { - if !errors.Is(err, context.Canceled) { - log.Printf("render error: %v", err) - } - } - return - } - - // group entries by status for display - watchlistByStatus := make(map[string][]db.GetUserWatchListRow) - allEntries := make([]db.GetUserWatchListRow, 0) - watchlistIDs := make([]int64, len(entries)) - - for i, entry := range entries { - status := entry.Status - if status == "" { - status = "plan_to_watch" - } - watchlistByStatus[status] = append(watchlistByStatus[status], entry) - allEntries = append(allEntries, entry) - watchlistIDs[i] = entry.AnimeID - } - - data := map[string]any{ - "User": user, - "CurrentPath": r.URL.Path, - "WatchlistByStatus": watchlistByStatus, - "AllEntries": allEntries, - "WatchlistIDs": watchlistIDs, - "StatusOrder": []string{"watching", "plan_to_watch", "on_hold", "completed", "dropped"}, - "StatusLabels": map[string]string{ - "watching": "Currently Watching", - "plan_to_watch": "Plan to Watch", - "on_hold": "On Hold", - "completed": "Completed", - "dropped": "Dropped", - }, - } - - // use partial template for htmx requests - templateName := "watchlist.gohtml" - if r.Header.Get("HX-Request") == "true" { - templateName = "watchlist_partial.gohtml" - } - - if err := templates.GetRenderer().ExecuteTemplate(r.Context(), w, templateName, data); err != nil { - if !errors.Is(err, context.Canceled) { - log.Printf("render error: %v", err) - } - } -} diff --git a/api/watchlist/service.go b/api/watchlist/service.go deleted file mode 100644 index b489c1c..0000000 --- a/api/watchlist/service.go +++ /dev/null @@ -1,184 +0,0 @@ -package watchlist - -import ( - "context" - "database/sql" - "errors" - "fmt" - "strings" - - "github.com/google/uuid" - - "mal/integrations/jikan" - "mal/internal/db" -) - -type Service struct { - db db.Querier - sqlDB *sql.DB - jikanClient *jikan.Client -} - -var ( - ErrInvalidAnimeID = errors.New("invalid anime ID") - ErrInvalidStatus = errors.New("invalid watchlist status") -) - -var validStatuses = map[string]struct{}{ - "watching": {}, - "completed": {}, - "dropped": {}, - "plan_to_watch": {}, - "on_hold": {}, -} - -func NewService(db db.Querier, sqlDB *sql.DB, jikanClient *jikan.Client) *Service { - return &Service{db: db, sqlDB: sqlDB, jikanClient: jikanClient} -} - -// ensureAnimeExists checks if anime exists in db, fetches from jikan if not, then upserts. -func (s *Service) ensureAnimeExists(ctx context.Context, animeID int64) error { - _, err := s.db.GetAnime(ctx, animeID) - if err == nil { - return nil // already exists - } - - // fetch from jikan and store locally - anime, err := s.jikanClient.GetAnimeByID(ctx, int(animeID)) - if err != nil { - return fmt.Errorf("failed to fetch anime from jikan: %w", err) - } - - _, err = s.db.UpsertAnime(ctx, db.UpsertAnimeParams{ - ID: int64(anime.MalID), - TitleOriginal: anime.Title, - TitleEnglish: sql.NullString{String: anime.TitleEnglish, Valid: anime.TitleEnglish != ""}, - TitleJapanese: sql.NullString{String: anime.TitleJapanese, Valid: anime.TitleJapanese != ""}, - ImageUrl: anime.Images.Jpg.LargeImageURL, - Airing: sql.NullBool{Bool: anime.Airing, Valid: true}, - }) - if err != nil { - return fmt.Errorf("failed to save anime: %w", err) - } - - return nil -} - -type AddRequest struct { - AnimeID int64 - TitleOriginal string - TitleEnglish string - TitleJapanese string - ImageURL string - Status string - Airing bool -} - -// AddToWatchlist adds or updates an anime entry in user's watchlist. -func (s *Service) AddToWatchlist(ctx context.Context, userID string, animeID int64, status string) error { - if animeID <= 0 { - return ErrInvalidAnimeID - } - - if _, ok := validStatuses[status]; !ok { - return ErrInvalidStatus - } - - // ensure anime exists in local db before linking - if err := s.ensureAnimeExists(ctx, animeID); err != nil { - return err - } - - entryID := uuid.New().String() - _, err := s.db.UpsertWatchListEntry(ctx, db.UpsertWatchListEntryParams{ - ID: entryID, - UserID: userID, - AnimeID: animeID, - Status: status, - CurrentEpisode: sql.NullInt64{Valid: false}, - CurrentTimeSeconds: 0, - }) - if err != nil { - return fmt.Errorf("failed to update watchlist: %w", err) - } - - return nil -} - -// RemoveEntry deletes a watchlist entry and returns the anime for potential use. -func (s *Service) RemoveEntry(ctx context.Context, userID string, animeID int64) (db.Anime, error) { - if animeID <= 0 { - return db.Anime{}, ErrInvalidAnimeID - } - - anime, err := s.db.GetAnime(ctx, animeID) - if err != nil { - return db.Anime{}, fmt.Errorf("anime not found: %w", err) - } - - err = s.db.DeleteWatchListEntry(ctx, db.DeleteWatchListEntryParams{ - UserID: userID, - AnimeID: animeID, - }) - if err != nil { - return db.Anime{}, fmt.Errorf("failed to delete from watchlist: %w", err) - } - - return anime, nil -} - -// GetUserWatchlist retrieves all watchlist entries for a user. -func (s *Service) GetUserWatchlist(ctx context.Context, userID string) ([]db.GetUserWatchListRow, error) { - entries, err := s.db.GetUserWatchList(ctx, userID) - if err != nil { - return nil, fmt.Errorf("failed to fetch watchlist: %w", err) - } - return entries, nil -} - -// DeleteContinueWatching removes entry and clears associated watch progress. -// uses transaction when sqlDB is available. -func (s *Service) DeleteContinueWatching(ctx context.Context, userID string, animeID int64) error { - if strings.TrimSpace(userID) == "" { - return errors.New("invalid user id") - } - - if animeID <= 0 { - return ErrInvalidAnimeID - } - - params := db.DeleteContinueWatchingEntryParams{ - UserID: userID, - AnimeID: animeID, - } - - clearProgress := db.SaveWatchProgressParams{ - CurrentEpisode: sql.NullInt64{Valid: false}, - CurrentTimeSeconds: 0, - UserID: userID, - AnimeID: animeID, - } - - // use transaction when sqlDB available for consistency - if s.sqlDB == nil { - if err := s.db.DeleteContinueWatchingEntry(ctx, params); err != nil { - return fmt.Errorf("failed to delete continue watching entry: %w", err) - } - return s.db.SaveWatchProgress(ctx, clearProgress) - } - - txQueries, tx, err := db.BeginTx(ctx, s.sqlDB) - if err != nil { - return fmt.Errorf("failed to begin transaction: %w", err) - } - defer func() { _ = tx.Rollback() }() - - if err := txQueries.DeleteContinueWatchingEntry(ctx, params); err != nil { - return fmt.Errorf("failed to delete continue watching entry: %w", err) - } - if err := txQueries.SaveWatchProgress(ctx, clearProgress); err != nil { - return fmt.Errorf("failed to clear watchlist progress: %w", err) - } - - return tx.Commit() -} diff --git a/api/watchlist/service_test.go b/api/watchlist/service_test.go deleted file mode 100644 index bfac5a5..0000000 --- a/api/watchlist/service_test.go +++ /dev/null @@ -1,72 +0,0 @@ -package watchlist - -import ( - "context" - "testing" - - "mal/internal/db" -) - -type fakeQuerier struct { - db.Querier - upsertAnimeCalled bool - upsertEntryCalled bool - upsertEntryParams db.UpsertWatchListEntryParams - getAnimeFunc func(ctx context.Context, id int64) (db.Anime, error) -} - -func (f *fakeQuerier) GetAnime(ctx context.Context, id int64) (db.Anime, error) { - if f.getAnimeFunc != nil { - return f.getAnimeFunc(ctx, id) - } - return db.Anime{}, nil -} - -func (f *fakeQuerier) UpsertAnime(ctx context.Context, arg db.UpsertAnimeParams) (db.Anime, error) { - f.upsertAnimeCalled = true - return db.Anime{}, nil -} - -func (f *fakeQuerier) UpsertWatchListEntry(ctx context.Context, arg db.UpsertWatchListEntryParams) (db.WatchListEntry, error) { - f.upsertEntryCalled = true - f.upsertEntryParams = arg - return db.WatchListEntry{}, nil -} - -func (f *fakeQuerier) GetUserWatchList(ctx context.Context, userID string) ([]db.GetUserWatchListRow, error) { - return nil, nil -} - -func TestAddEntry_RejectsInvalidAnimeID(t *testing.T) { - t.Parallel() - - q := &fakeQuerier{} - svc := NewService(q, nil, nil) - - err := svc.AddToWatchlist(context.Background(), "user-1", 0, "watching") - - if err != ErrInvalidAnimeID { - t.Fatalf("expected ErrInvalidAnimeID, got %v", err) - } - - if q.upsertAnimeCalled || q.upsertEntryCalled { - t.Fatal("expected no database writes for invalid anime id") - } -} - -func TestAddEntry_RejectsInvalidStatus(t *testing.T) { - t.Parallel() - - q := &fakeQuerier{} - svc := NewService(q, nil, nil) - - err := svc.AddToWatchlist(context.Background(), "user-1", 1, "invalid") - - if err != ErrInvalidStatus { - t.Fatalf("expected ErrInvalidStatus, got %v", err) - } - - if q.upsertAnimeCalled || q.upsertEntryCalled { - t.Fatal("expected no database writes for invalid status") - } -} diff --git a/internal/db/migrate.go b/internal/db/migrate.go deleted file mode 100644 index d954eaa..0000000 --- a/internal/db/migrate.go +++ /dev/null @@ -1,118 +0,0 @@ -package db - -import ( - "database/sql" - "fmt" - "log" - "os" - "path/filepath" - "sort" - "strings" -) - -// RunMigrations applies all *.sql files in migrationsDir in sorted order, -// skipping any already recorded in migration_version. -func RunMigrations(db *sql.DB, migrationsDir string) error { - if migrationsDir == "" { - return fmt.Errorf("migrations directory is required") - } - - // Create migration tracking table - _, err := db.Exec(` - CREATE TABLE IF NOT EXISTS migration_version ( - name TEXT PRIMARY KEY, - applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP - ) - `) - if err != nil { - return err - } - - migrations, err := filepath.Glob(filepath.Join(migrationsDir, "*.sql")) - if err != nil { - return err - } - if len(migrations) == 0 { - return fmt.Errorf("no migration files found in %s", migrationsDir) - } - - sort.Strings(migrations) - - appliedNames, err := loadAppliedMigrationNames(db) - if err != nil { - return err - } - - for _, migrationFile := range migrations { - migrationName := filepath.Base(migrationFile) - if migrationApplied(appliedNames, migrationName) { - continue // already applied - } - - migrationSQL, err := os.ReadFile(migrationFile) - if err != nil { - return err - } - - if _, err := db.Exec(string(migrationSQL)); err != nil { - return err // stop on first failure - } - - // record applied migration - _, err = db.Exec("INSERT INTO migration_version (name) VALUES (?)", migrationName) - if err != nil { - return err - } - - appliedNames[migrationName] = struct{}{} - - log.Printf("migration %s applied successfully", migrationName) - } - - return nil -} - -func loadAppliedMigrationNames(db *sql.DB) (map[string]struct{}, error) { - rows, err := db.Query("SELECT name FROM migration_version") - if err != nil { - return nil, err - } - defer func() { _ = rows.Close() }() - - applied := make(map[string]struct{}) - for rows.Next() { - var name string - if err := rows.Scan(&name); err != nil { - return nil, err - } - - applied[name] = struct{}{} - } - - if err := rows.Err(); err != nil { - return nil, err - } - - return applied, nil -} - -// migrationApplied checks the applied names map for a match, -// including legacy paths and case-insensitive basename matches. -func migrationApplied(appliedNames map[string]struct{}, migrationName string) bool { - if _, exists := appliedNames[migrationName]; exists { - return true - } - - legacyName := filepath.ToSlash(filepath.Join("migrations", migrationName)) - if _, exists := appliedNames[legacyName]; exists { - return true - } - - for appliedName := range appliedNames { - if strings.EqualFold(filepath.Base(appliedName), migrationName) { - return true - } - } - - return false -} diff --git a/internal/server/routes.go b/internal/server/routes.go deleted file mode 100644 index c8d7dd5..0000000 --- a/internal/server/routes.go +++ /dev/null @@ -1,162 +0,0 @@ -package server - -import ( - "database/sql" - "fmt" - "net/http" - "path/filepath" - "strings" - "time" - - "mal/api/anime" - "mal/api/auth" - "mal/api/playback" - "mal/api/watchlist" - "mal/integrations/jikan" - "mal/internal/db" - "mal/internal/middleware" - pkgmiddleware "mal/pkg/middleware" -) - -type Config struct { - DB *db.Queries - SQLDB *sql.DB - JikanClient *jikan.Client - AuthService *auth.Service - AuthLimiter *pkgmiddleware.Limiter - PlaybackProxySecret string -} - -// withMimeTypes sets Content-Type for common static asset extensions -func withMimeTypes(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - ext := strings.ToLower(filepath.Ext(r.URL.Path)) - switch ext { - case ".js": - w.Header().Set("Content-Type", "application/javascript") - case ".css": - w.Header().Set("Content-Type", "text/css") - case ".svg": - w.Header().Set("Content-Type", "image/svg+xml") - case ".json": - w.Header().Set("Content-Type", "application/json") - } - next.ServeHTTP(w, r) - }) -} - -// noCache sends headers to prevent caching of dynamic/static assets -func noCache(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") - w.Header().Set("Pragma", "no-cache") - w.Header().Set("Expires", "0") - next.ServeHTTP(w, r) - }) -} - -// NewAuthLimiter returns a rate limiter for auth endpoints: 5 attempts per minute -func NewAuthLimiter() *pkgmiddleware.Limiter { - return pkgmiddleware.NewLimiter(pkgmiddleware.Config{ - MaxAttempts: 5, - Window: time.Minute, - }) -} - -// NewRouter wires up all HTTP handlers and middleware. -// Auth is enforced globally; public routes must opt-out via middleware policy. -func NewRouter(cfg Config) http.Handler { - mux := http.NewServeMux() - - authHandler := auth.NewHandler(cfg.AuthService) - - watchlistSvc := watchlist.NewService(cfg.DB, cfg.SQLDB, cfg.JikanClient) - watchlistHandler := watchlist.NewHandler(watchlistSvc) - - animeSvc := anime.NewService(cfg.JikanClient, cfg.DB) - animeHandler := anime.NewHandler(animeSvc) - - playbackSvc, err := playback.NewService(cfg.DB, cfg.SQLDB, playback.Config{ - ProxyTokenSecret: cfg.PlaybackProxySecret, - }) - if err != nil { - panic(fmt.Sprintf("failed to initialize playback service: %v", err)) - } - playbackHandler := playback.NewHandler(playbackSvc, cfg.JikanClient) - - // Serve static files with no-cache headers - fs := noCache(http.FileServer(http.Dir("./static"))) - mux.Handle("/static/", http.StripPrefix("/static/", fs)) - - // Serve built frontend assets with no-cache headers - dist := noCache(http.FileServer(http.Dir("./dist"))) - mux.Handle("/dist/", http.StripPrefix("/dist/", withMimeTypes(dist))) - - // Serve Apple Touch Icons from static directory - mux.HandleFunc("/apple-touch-icon.png", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "image/svg+xml") - http.ServeFile(w, r, "./static/apple-touch-icon.svg") - }) - mux.HandleFunc("/apple-touch-icon-precomposed.png", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "image/svg+xml") - http.ServeFile(w, r, "./static/apple-touch-icon-precomposed.svg") - }) - mux.HandleFunc("/apple-touch-icon-120x120.png", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "image/svg+xml") - http.ServeFile(w, r, "./static/apple-touch-icon-120x120.svg") - }) - mux.HandleFunc("/apple-touch-icon-120x120-precomposed.png", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "image/svg+xml") - http.ServeFile(w, r, "./static/apple-touch-icon-120x120-precomposed.svg") - }) - - mux.HandleFunc("/", animeHandler.HandleCatalog) - mux.HandleFunc("/api/catalog/airing", animeHandler.HandleCatalogAiring) - mux.HandleFunc("/api/catalog/popular", animeHandler.HandleCatalogPopular) - mux.HandleFunc("/api/catalog/continue", animeHandler.HandleCatalogContinue) - mux.HandleFunc("/search", animeHandler.HandleSearch) - mux.HandleFunc("/browse", animeHandler.HandleBrowse) - mux.HandleFunc("/discover", animeHandler.HandleDiscover) - mux.HandleFunc("/api/discover/trending", animeHandler.HandleDiscoverTrending) - mux.HandleFunc("/api/discover/upcoming", animeHandler.HandleDiscoverUpcoming) - mux.HandleFunc("/api/discover/top", animeHandler.HandleDiscoverTop) - mux.HandleFunc("/api/search-quick", animeHandler.HandleQuickSearch) - mux.HandleFunc("/api/jikan/random/anime", animeHandler.HandleRandomAnime) - mux.HandleFunc("/anime/", func(w http.ResponseWriter, r *http.Request) { - if strings.HasSuffix(r.URL.Path, "/watch") { - playbackHandler.HandleWatchPage(w, r) - return - } - animeHandler.HandleAnimeDetails(w, r) - }) - mux.HandleFunc("/api/watch-order", animeHandler.HandleHTMLWatchOrder) - mux.HandleFunc("/watch/", playbackHandler.HandleWatchPage) - mux.HandleFunc("/watch/proxy/stream", playbackHandler.HandleProxy) - mux.HandleFunc("/watch/proxy/segment", playbackHandler.HandleProxy) - mux.HandleFunc("/watch/proxy/subtitle", playbackHandler.HandleProxy) - mux.HandleFunc("/api/watch-progress", playbackHandler.HandleSaveProgress) - mux.HandleFunc("/api/watch-complete", playbackHandler.HandleCompleteAnime) - mux.HandleFunc("/api/watch/episode/", playbackHandler.HandleEpisodeData) - mux.HandleFunc("/api/watch/thumbnails/", playbackHandler.HandleEpisodeThumbnails) - - // Auth Endpoints - mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet { - authHandler.HandleLoginPage(w, r) - } else { - cfg.AuthLimiter.AuthMiddleware(pkgmiddleware.VerifyOrigin(http.HandlerFunc(authHandler.HandleLogin))).ServeHTTP(w, r) - } - }) - mux.HandleFunc("/logout", authHandler.HandleLogout) - - // Watchlist Endpoints - mux.HandleFunc("/api/watchlist", watchlistHandler.HandleUpdateWatchlist) - mux.HandleFunc("/api/watchlist/", watchlistHandler.HandleDeleteWatchlist) - mux.HandleFunc("/api/continue-watching/", watchlistHandler.HandleDeleteContinueWatching) - mux.HandleFunc("/watchlist", watchlistHandler.HandleGetWatchlist) - - // Wrap mux with global CSRF origin verification and auth checking - protectedHandler := middleware.RequireGlobalAuthWithPolicy(middleware.NewAccessPolicy())(pkgmiddleware.VerifyOrigin(mux)) - authenticatedHandler := middleware.Auth(cfg.AuthService)(protectedHandler) - return pkgmiddleware.RequestLogger(authenticatedHandler) -} diff --git a/templates/renderer.go b/templates/renderer.go deleted file mode 100644 index 6433b1b..0000000 --- a/templates/renderer.go +++ /dev/null @@ -1,198 +0,0 @@ -package templates - -import ( - "context" - "encoding/json" - "fmt" - "html/template" - "io" - "log" - "path/filepath" - "slices" - "strconv" - "strings" - "sync" -) - -var ( - once sync.Once - renderer *Renderer -) - -type Renderer struct { - templates map[string]*template.Template -} - -// GetRenderer returns the singleton renderer, initializing it on first call. -// Templates are loaded from ./templates/*.gohtml and ./templates/components/*.gohtml. -func GetRenderer() *Renderer { - once.Do(func() { - renderer = &Renderer{ - templates: make(map[string]*template.Template), - } - - funcs := template.FuncMap{ - "dict": func(values ...any) map[string]any { - m := make(map[string]any) - for i := 0; i < len(values)-1; i += 2 { - key, ok := values[i].(string) - if !ok { - continue - } - m[key] = values[i+1] - } - return m - }, - "json": func(v any) template.HTMLAttr { - b, _ := json.Marshal(v) - return template.HTMLAttr(b) - }, - "genresParams": func(genres []int) string { - if len(genres) == 0 { - return "" - } - var s strings.Builder - for _, g := range genres { - s.WriteString("genres=" + fmt.Sprintf("%d", g) + "&") - } - return s.String()[:len(s.String())-1] - }, - "hasGenre": func(id int, genres []int) bool { - return slices.Contains(genres, id) - }, - "add": func(a, b int) int { - return a + b - }, - "sub": func(a, b int) int { - return a - b - }, - "mul": func(a, b float64) float64 { - return a * b - }, - "imul": func(a, b int) int { - return a * b - }, - "div": func(a, b float64) float64 { - if b == 0 { - return 0 - } - return a / b - }, - "ceilDiv": func(a, b int) int { - if b == 0 { - return 0 - } - return (a + b - 1) / b - }, - "toFloat": func(a int) float64 { - return float64(a) - }, - "seq": func(v any) []int { - var count int - switch n := v.(type) { - case int: - count = n - case int64: - count = int(n) - default: - count = 0 - } - res := make([]int, count) - for i := 0; i < count; i++ { - res[i] = i - } - return res - }, - "min": func(a, b int) int { - if a < b { - return a - } - return b - }, - "int": func(v any) int { - switch n := v.(type) { - case int: - return n - case int64: - return int(n) - case float64: - return int(n) - case string: - i, _ := strconv.Atoi(n) - return i - default: - return 0 - } - }, - "percent": func(current, total float64) float64 { - if total == 0 { - return 0 - } - return (current / total) * 100 - }, - } - - pages, err := filepath.Glob(filepath.Join(".", "templates", "*.gohtml")) - if err != nil { - log.Fatalf("failed to glob page templates: %v", err) - } - - components, err := filepath.Glob(filepath.Join(".", "templates", "components", "*.gohtml")) - if err != nil { - log.Fatalf("failed to glob component templates: %v", err) - } - - for _, page := range pages { - name := filepath.Base(page) - if name == "base.gohtml" { - continue - } - - tmpl := template.New(name).Funcs(funcs) - // Parse base first so it establishes the core definitions - tmpl = template.Must(tmpl.ParseFiles(filepath.Join(".", "templates", "base.gohtml"))) - - // Parse all components next so they are available to the page - if len(components) > 0 { - tmpl = template.Must(tmpl.ParseFiles(components...)) - } - - // Parse the page itself last - tmpl = template.Must(tmpl.ParseFiles(page)) - - renderer.templates[name] = tmpl - log.Printf("Loaded page template: %s", name) - } - }) - return renderer -} - -// ExecuteTemplate renders a named template into wr, returning early if context is cancelled -func (r *Renderer) ExecuteTemplate(ctx context.Context, wr io.Writer, name string, data any) error { - select { - case <-ctx.Done(): - return ctx.Err() - default: - } - - tmpl, ok := r.templates[name] - if !ok { - return fmt.Errorf("template %s not found", name) - } - return tmpl.ExecuteTemplate(wr, "base.gohtml", data) -} - -// ExecuteFragment renders a specific named block within a template (e.g. a component) -func (r *Renderer) ExecuteFragment(ctx context.Context, wr io.Writer, name string, block string, data any) error { - select { - case <-ctx.Done(): - return ctx.Err() - default: - } - - tmpl, ok := r.templates[name] - if !ok { - return fmt.Errorf("template %s not found", name) - } - return tmpl.ExecuteTemplate(wr, block, data) -} From 4d1fd2834b0ec0551af5fd947c5d7a8f13301f53 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Wed, 13 May 2026 11:20:46 +0200 Subject: [PATCH 12/71] feat: migrate playback domain to modular architecture --- internal/domain/playback.go | 21 +++- internal/middleware/auth.go | 9 +- internal/playback/handler/handler.go | 58 +++++++++--- internal/playback/module.go | 13 ++- internal/playback/service/service.go | 137 ++++++++++++++++++++++++--- 5 files changed, 205 insertions(+), 33 deletions(-) diff --git a/internal/domain/playback.go b/internal/domain/playback.go index db47947..567e73b 100644 --- a/internal/domain/playback.go +++ b/internal/domain/playback.go @@ -10,9 +10,28 @@ type PlaybackService interface { SaveProgress(ctx context.Context, userID string, animeID int64, episode int, timeSeconds float64) error } +type ProviderStream struct { + Name string `json:"name"` + URL string `json:"url"` + Quality string `json:"quality"` + MalID int `json:"mal_id"` + IsCurrent bool `json:"is_current"` +} + +type ProviderData struct { + Streams []ProviderStream `json:"streams"` +} + +type EpisodeData struct { + MalID int `json:"mal_id"` + Title string `json:"title"` + IsFiller bool `json:"is_filler"` + IsRecap bool `json:"is_recap"` +} + type PlaybackRepository interface { GetWatchListEntry(ctx context.Context, params db.GetWatchListEntryParams) (db.WatchListEntry, error) - GetContinueWatchingEntry(ctx context.Context, params db.GetContinueWatchingEntryParams) (db.GetContinueWatchingEntryRow, error) + GetContinueWatchingEntry(ctx context.Context, params db.GetContinueWatchingEntryParams) (db.ContinueWatchingEntry, error) SaveWatchProgress(ctx context.Context, params db.SaveWatchProgressParams) error UpsertContinueWatchingEntry(ctx context.Context, params db.UpsertContinueWatchingEntryParams) (db.ContinueWatchingEntry, error) } diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index a31b16e..8a349b7 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -4,13 +4,12 @@ import ( "context" "net/http" - "mal/api/auth" + "mal/internal/domain" ctxpkg "mal/internal/context" - "mal/internal/db" ) // Auth middleware validates the session cookie and injects the user into context -func Auth(authService *auth.Service) func(http.Handler) http.Handler { +func Auth(authService domain.AuthService) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { cookie, err := r.Cookie("session_id") @@ -32,8 +31,8 @@ func Auth(authService *auth.Service) func(http.Handler) http.Handler { } // GetUser retrieves the authenticated user from context, or nil if not authenticated -func GetUser(ctx context.Context) *db.User { - user, ok := ctx.Value(ctxpkg.UserKey).(*db.User) +func GetUser(ctx context.Context) *domain.User { + user, ok := ctx.Value(ctxpkg.UserKey).(*domain.User) if !ok { return nil } diff --git a/internal/playback/handler/handler.go b/internal/playback/handler/handler.go index d483dfa..9fb7a40 100644 --- a/internal/playback/handler/handler.go +++ b/internal/playback/handler/handler.go @@ -1,8 +1,8 @@ package handler import ( + "log" "mal/internal/domain" - "mal/internal/server" "net/http" "strconv" @@ -10,38 +10,70 @@ import ( ) type PlaybackHandler struct { - svc domain.PlaybackService + svc domain.PlaybackService + animeSvc domain.AnimeService } -func NewPlaybackHandler(svc domain.PlaybackService) *PlaybackHandler { - return &PlaybackHandler{svc: svc} +func NewPlaybackHandler(svc domain.PlaybackService, animeSvc domain.AnimeService) *PlaybackHandler { + return &PlaybackHandler{svc: svc, animeSvc: animeSvc} } func (h *PlaybackHandler) Register(r *gin.Engine) { + log.Println("Registering playback routes") r.GET("/watch/:id", h.HandleWatchPage) r.POST("/api/watch-progress", h.HandleSaveProgress) } func (h *PlaybackHandler) HandleWatchPage(c *gin.Context) { + log.Printf("Route /watch/:id triggered for ID: %s", c.Param("id")) id, _ := strconv.Atoi(c.Param("id")) ep := c.DefaultQuery("ep", "1") mode := c.DefaultQuery("mode", "sub") - userID := "" // TODO: get from auth context - data, err := h.svc.BuildWatchData(c.Request.Context(), id, []string{}, ep, mode, userID) - if err != nil { - c.Status(http.StatusNotFound) - return + user, _ := c.Get("User") + userID := "" + if u, ok := user.(*domain.User); ok { + userID = u.ID } - c.HTML(http.StatusOK, "watch.gohtml", gin.H{ - "WatchData": data, + data, err := h.svc.BuildWatchData(c.Request.Context(), id, []string{}, ep, mode, userID) + if err != nil { + log.Printf("BuildWatchData failed for ID %d: %v", id, err) + // Try to at least get anime info for the error page + anime, _ := h.animeSvc.GetAnimeByID(c.Request.Context(), id) + c.HTML(http.StatusOK, "watch.gohtml", gin.H{ + "Error": err.Error(), + "Anime": anime, + "Episodes": []domain.EpisodeData{}, + "CurrentPath": c.Request.URL.Path, + "User": user, + "CurrentEpID": ep, + "WatchData": map[string]any{"Episodes": []domain.EpisodeData{}, "Providers": []any{}}, + }) + return + } + log.Printf("BuildWatchData succeeded for ID %d", id) + + // Merge data from service with handler-specific context + responseData := gin.H{ + "User": user, "CurrentPath": c.Request.URL.Path, - }) + } + for k, v := range data { + responseData[k] = v + } + + c.HTML(http.StatusOK, "watch.gohtml", responseData) + log.Printf("c.HTML finished for ID %d", id) } func (h *PlaybackHandler) HandleSaveProgress(c *gin.Context) { - userID := "" // TODO: get from auth context + user, _ := c.Get("User") + userID := "" + if u, ok := user.(*domain.User); ok { + userID = u.ID + } + var req struct { MalID int64 `json:"mal_id"` Episode int `json:"episode"` diff --git a/internal/playback/module.go b/internal/playback/module.go index 00efb69..5977504 100644 --- a/internal/playback/module.go +++ b/internal/playback/module.go @@ -1,6 +1,8 @@ package playback import ( + "mal/integrations/jikan" + "mal/integrations/playback/allanime" "mal/internal/domain" "mal/internal/playback/handler" "mal/internal/playback/repository" @@ -13,12 +15,19 @@ import ( var Module = fx.Options( fx.Provide( repository.NewPlaybackRepository, - service.NewPlaybackService, - handler.NewPlaybackHandler, + func(repo domain.PlaybackRepository, providers []domain.Provider, jikan *jikan.Client) domain.PlaybackService { + return service.NewPlaybackService(repo, providers, jikan) + }, + func(svc domain.PlaybackService, animeSvc domain.AnimeService) *handler.PlaybackHandler { + return handler.NewPlaybackHandler(svc, animeSvc) + }, ), fx.Provide( server.AsRouteRegister(func(h *handler.PlaybackHandler) server.RouteRegister { return h }), ), + fx.Provide(func(p *allanime.AllAnimeProvider) []domain.Provider { + return []domain.Provider{p} + }), ) diff --git a/internal/playback/service/service.go b/internal/playback/service/service.go index 283ba91..dec3d06 100644 --- a/internal/playback/service/service.go +++ b/internal/playback/service/service.go @@ -2,29 +2,40 @@ package service import ( "context" + "database/sql" "fmt" + "log" + "mal/integrations/jikan" "mal/internal/db" "mal/internal/domain" + "sort" "strconv" + "strings" ) type playbackService struct { repo domain.PlaybackRepository providers []domain.Provider + jikan *jikan.Client } -func NewPlaybackService(repo domain.PlaybackRepository, providers []domain.Provider) domain.PlaybackService { - return &playbackService{repo: repo, providers: providers} +func NewPlaybackService(repo domain.PlaybackRepository, providers []domain.Provider, jikan *jikan.Client) domain.PlaybackService { + return &playbackService{repo: repo, providers: providers, jikan: jikan} } func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, titleCandidates []string, episode string, mode string, userID string) (map[string]any, error) { - // Minimal implementation for now to show the pattern - var result *domain.StreamResult - var err error + // 1. Get Anime details for total episodes and titles + anime, err := s.jikan.GetAnimeByID(ctx, animeID) + if err != nil { + return nil, fmt.Errorf("failed to fetch anime: %w", err) + } + // 2. Resolve streams from providers + var result *domain.StreamResult for _, p := range s.providers { - result, err = p.GetStreams(ctx, animeID, episode, mode) - if err == nil && result != nil { + res, err := p.GetStreams(ctx, animeID, episode, mode) + if err == nil && res != nil { + result = res break } } @@ -33,25 +44,127 @@ func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, title return nil, fmt.Errorf("no streams found") } + // 3. Get start time from progress startTime := 0.0 + var watchlistStatus string if userID != "" { entry, err := s.repo.GetWatchListEntry(ctx, db.GetWatchListEntryParams{ UserID: userID, AnimeID: int64(animeID), }) if err == nil { + watchlistStatus = entry.Status if entry.CurrentEpisode.Valid && strconv.FormatInt(entry.CurrentEpisode.Int64, 10) == episode { startTime = entry.CurrentTimeSeconds } } } + // 4. Get Episodes list + jikanEpisodes, err := s.jikan.GetAllEpisodes(ctx, animeID) + if err != nil { + log.Printf("failed to fetch episodes from jikan: %v", err) + } + + // Fallback/Fill episodes if needed + totalCount := anime.Episodes + if len(jikanEpisodes) < totalCount { + epMap := make(map[int]jikan.Episode) + for _, ep := range jikanEpisodes { + epMap[ep.MalID] = ep + } + for i := 1; i <= totalCount; i++ { + if _, ok := epMap[i]; !ok { + jikanEpisodes = append(jikanEpisodes, jikan.Episode{ + MalID: i, + Episode: fmt.Sprintf("Episode %d", i), + Title: fmt.Sprintf("Episode %d", i), + }) + } + } + } + sort.Slice(jikanEpisodes, func(i, j int) bool { + return jikanEpisodes[i].MalID < jikanEpisodes[j].MalID + }) + + domainEpisodes := make([]domain.EpisodeData, len(jikanEpisodes)) + for i, ep := range jikanEpisodes { + domainEpisodes[i] = domain.EpisodeData{ + MalID: ep.MalID, + Title: ep.Title, + IsFiller: ep.Filler, + IsRecap: ep.Recap, + } + } + + // 5. Build provider data + // AllAnime currently returns one stream in result.URL + // We wrap it for the template + streams := []domain.ProviderStream{ + { + Name: "Primary", + URL: result.URL, + Quality: "Auto", + MalID: animeID, + IsCurrent: true, + }, + } + + modeSources := map[string]any{ + mode: map[string]any{ + "url": result.URL, + "referer": result.Referer, + "subtitles": result.Subtitles, + }, + } + + // 6. Resolve relations/seasons + relations, _ := s.jikan.GetFullRelations(ctx, animeID) + type SeasonEntry struct { + MalID int `json:"mal_id"` + Title string `json:"title"` + Prefix string `json:"prefix"` + IsCurrent bool `json:"is_current"` + } + var seasons []SeasonEntry + tvCounter := 1 + for _, rel := range relations { + if strings.ToLower(rel.Anime.Type) == "tv" || strings.ToLower(rel.Anime.Type) == "movie" { + seasons = append(seasons, SeasonEntry{ + MalID: rel.Anime.MalID, + Title: rel.Anime.DisplayTitle(), + Prefix: rel.Relation, + IsCurrent: rel.IsCurrent, + }) + if rel.Relation == "TV" { + seasons[len(seasons)-1].Prefix = fmt.Sprintf("S%d", tvCounter) + tvCounter++ + } + } + } + + // Final assembly + watchData := map[string]any{ + "MalID": animeID, + "Title": anime.DisplayTitle(), + "CurrentEpisode": episode, + "StartTimeSeconds": startTime, + "Episodes": domainEpisodes, + "Providers": []domain.ProviderData{ + {Streams: streams}, + }, + "ModeSources": modeSources, + "InitialMode": mode, + "AvailableModes": []string{"sub", "dub"}, + } + return map[string]any{ - "URL": result.URL, - "Referer": result.Referer, - "StartTime": startTime, - "Subtitles": result.Subtitles, - "Qualities": result.Qualities, + "WatchData": watchData, + "Anime": anime, + "Episodes": domainEpisodes, + "CurrentEpID": episode, + "WatchlistStatus": watchlistStatus, + "Seasons": seasons, }, nil } From 1380fda7f7fb8ef0568b389308b585233aac9095 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Wed, 13 May 2026 11:20:47 +0200 Subject: [PATCH 13/71] feat: implement allanime provider with utls support --- integrations/playback/allanime/client.go | 733 ++++++++++++++++++ integrations/playback/allanime/client_test.go | 449 +++++++++++ integrations/playback/allanime/extractor.go | 221 ++++++ integrations/playback/allanime/http_utils.go | 26 + integrations/playback/allanime/module.go | 9 + integrations/playback/allanime/types.go | 52 ++ 6 files changed, 1490 insertions(+) create mode 100644 integrations/playback/allanime/client.go create mode 100644 integrations/playback/allanime/client_test.go create mode 100644 integrations/playback/allanime/extractor.go create mode 100644 integrations/playback/allanime/http_utils.go create mode 100644 integrations/playback/allanime/module.go create mode 100644 integrations/playback/allanime/types.go diff --git a/integrations/playback/allanime/client.go b/integrations/playback/allanime/client.go new file mode 100644 index 0000000..a611e2c --- /dev/null +++ b/integrations/playback/allanime/client.go @@ -0,0 +1,733 @@ +package allanime + +import ( + "bytes" + "context" + "crypto/aes" + "crypto/cipher" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "mal/pkg/net/utls" + "net/http" + "net/url" + "strconv" + "strings" + "time" + "mal/internal/domain" +) + +const ( + allAnimeBaseURL = "https://api.allanime.day" + allAnimeReferer = "https://allmanga.to/" + allAnimeOrigin = "https://youtu-chan.com" + defaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/121.0" +) + +var ( + aesKeys = []string{"Xot36i3lK3:v1", "SimtVuagFbGR2K7P"} +) + +var allAnimeUTLSClient = &http.Client{ + Transport: &utls.UtlsRoundTripper{}, + Timeout: 30 * time.Second, +} + +type searchResult struct { + ID string + MalID string + Name string +} + +type AvailableEpisodes struct { + Sub []string + Dub []string + Raw []string +} + +type AllAnimeProvider struct { + httpClient *http.Client + extractor *providerExtractor +} + +func NewAllAnimeProvider() *AllAnimeProvider { + return &AllAnimeProvider{ + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + extractor: newProviderExtractor(), + } +} + +func (c *AllAnimeProvider) Name() string { + return "AllAnime" +} + +func (c *AllAnimeProvider) Search(ctx context.Context, query string, mode string) ([]searchResult, error) { + // 1. Search for the show to get its AllAnime ID + graphqlQuery := `query($search: SearchInput, $limit: Int, $page: Int, $translationType: VaildTranslationTypeEnumType, $countryOrigin: VaildCountryOriginEnumType) { + shows(search: $search, limit: $limit, page: $page, translationType: $translationType, countryOrigin: $countryOrigin) { + edges { + _id + malId + name + } + } + }` + + variables := map[string]any{ + "search": map[string]any{ + "allowAdult": false, + "allowUnknown": false, + "query": query, + }, + "limit": 40, + "page": 1, + "translationType": mode, + "countryOrigin": "ALL", + } + + result, err := c.graphqlRequest(ctx, graphqlQuery, variables) + if err != nil { + return nil, err + } + + data, ok := result["data"].(map[string]any) + if !ok { + return nil, fmt.Errorf("invalid search response") + } + + shows, ok := data["shows"].(map[string]any) + if !ok { + return nil, fmt.Errorf("invalid shows payload") + } + + edges, ok := shows["edges"].([]any) + if !ok { + return nil, fmt.Errorf("invalid search edges") + } + + out := make([]searchResult, 0, len(edges)) + for _, edge := range edges { + item, ok := edge.(map[string]any) + if !ok { + continue + } + + id, _ := item["_id"].(string) + malID, _ := item["malId"].(string) + name, _ := item["name"].(string) + if unquoted, err := strconv.Unquote("\"" + name + "\""); err == nil { + name = unquoted + } + name = strings.TrimSpace(name) + + if id == "" { + continue + } + + out = append(out, searchResult{ID: id, MalID: malID, Name: name}) + } + + return out, nil +} + +func (c *AllAnimeProvider) GetStreams(ctx context.Context, animeID int, episode string, mode string) (*domain.StreamResult, error) { + // 1. Search for the show to get its AllAnime ID + searchResults, err := c.Search(ctx, fmt.Sprintf("malId:%d", animeID), mode) + if err != nil || len(searchResults) == 0 { + return nil, fmt.Errorf("allanime: show not found for malID %d", animeID) + } + + showID := searchResults[0].ID + + // 2. Get sources + sources, err := c.GetEpisodeSources(ctx, showID, episode, mode) + if err != nil || len(sources) == 0 { + return nil, fmt.Errorf("allanime: no sources for show %s", showID) + } + + // 3. Return the first usable source + primary := sources[0] + + result := &domain.StreamResult{ + URL: primary.URL, + Referer: primary.Referer, + } + + for _, sub := range primary.Subtitles { + result.Subtitles = append(result.Subtitles, domain.Subtitle{ + Label: sub.Lang, + URL: sub.URL, + }) + } + + return result, nil +} + +func (c *AllAnimeProvider) graphqlRequest(ctx context.Context, query string, variables map[string]any) (map[string]any, error) { + if mode, ok := variables["translationType"].(string); ok { + variables["translationType"] = strings.ToLower(mode) + } + + payload := map[string]any{ + "query": query, + "variables": variables, + } + + body, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("marshal graphql payload: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, allAnimeBaseURL+"/api", bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("create graphql request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Referer", allAnimeReferer) + req.Header.Set("User-Agent", defaultUserAgent) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("execute graphql request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + respBody, err := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024)) + if err != nil { + return nil, fmt.Errorf("read graphql response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("graphql status %d", resp.StatusCode) + } + + var parsed map[string]any + if err := json.Unmarshal(respBody, &parsed); err != nil { + return nil, fmt.Errorf("decode graphql response: %w", err) + } + + if errs, ok := parsed["errors"].([]any); ok && len(errs) > 0 { + return nil, fmt.Errorf("graphql error: %v", errs[0]) + } + + return parsed, nil +} + +const episodeQueryHash = "d405d0edd690624b66baba3068e0edc3ac90f1597d898a1ec8db4e5c43c00fec" + +func (c *AllAnimeProvider) graphqlRequestWithHash(ctx context.Context, showID, episode, mode string) (map[string]any, error) { + mode = strings.ToLower(mode) + + varsJSON := fmt.Sprintf(`{"showId":"%s","translationType":"%s","episodeString":"%s"}`, showID, mode, episode) + extJSON := fmt.Sprintf(`{"persistedQuery":{"version":1,"sha256Hash":"%s"}}`, episodeQueryHash) + + apiURL := fmt.Sprintf("%s/api?variables=%s&extensions=%s", + allAnimeBaseURL, + url.QueryEscape(varsJSON), + url.QueryEscape(extJSON)) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return nil, fmt.Errorf("create GET request: %w", err) + } + + req.Header.Set("User-Agent", defaultUserAgent) + req.Header.Set("Accept", "*/*") + req.Header.Set("Accept-Language", "en-US,en;q=0.5") + req.Header.Set("Accept-Encoding", "identity") + req.Header.Set("Referer", allAnimeReferer) + req.Header.Set("Origin", allAnimeOrigin) + req.Header.Set("Sec-Fetch-Dest", "empty") + req.Header.Set("Sec-Fetch-Mode", "cors") + req.Header.Set("Sec-Fetch-Site", "cross-site") + + resp, err := allAnimeUTLSClient.Do(req) + if err != nil { + return nil, fmt.Errorf("execute GET request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + respBody, err := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1022)) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GET status %d: %s", resp.StatusCode, string(respBody)) + } + + var parsed map[string]any + if err := json.Unmarshal(respBody, &parsed); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + + if errs, ok := parsed["errors"].([]any); ok && len(errs) > 0 { + return nil, fmt.Errorf("graphql error: %v", errs[0]) + } + + data, ok := parsed["data"].(map[string]any) + if !ok { + return nil, fmt.Errorf("no data in response") + } + + var toBeParsed string + if s, ok := data["tobeparsed"].(string); ok && s != "" { + toBeParsed = s + } else if episodeData, ok := data["episode"].(map[string]any); ok { + if s, ok := episodeData["tobeparsed"].(string); ok { + toBeParsed = s + } + } + + if toBeParsed != "" { + decrypted, err := decryptTobeparsed(toBeParsed) + if err != nil { + return nil, fmt.Errorf("decrypt tobeparsed: %w", err) + } + + var ep map[string]any + if jerr := json.Unmarshal(decrypted, &ep); jerr != nil { + return nil, fmt.Errorf("unmarshal decrypted: %w", jerr) + } + + var sourceURLs []any + if srcs, ok := ep["sourceUrls"].([]any); ok { + sourceURLs = srcs + } else if epInner, ok := ep["episode"].(map[string]any); ok { + if srcs, ok := epInner["sourceUrls"].([]any); ok { + sourceURLs = srcs + } + } + + if len(sourceURLs) > 0 { + return map[string]any{ + "episode": map[string]any{ + "sourceUrls": sourceURLs, + }, + }, nil + } + } + + if episodeData, ok := data["episode"].(map[string]any); ok { + if srcs, ok := episodeData["sourceUrls"].([]any); ok && len(srcs) > 0 { + return parsed, nil + } + } + + return nil, fmt.Errorf("no usable data in response") +} + +// GetEpisodeSources fetches stream URLs for a given show, episode, and mode (dub/sub). +func (c *AllAnimeProvider) GetEpisodeSources(ctx context.Context, showID string, episode string, mode string) ([]StreamSource, error) { + episodeQuery := `query($showId: String!, $translationType: VaildTranslationTypeEnumType!, $episodeString: String!) { + episode(showId: $showId, translationType: $translationType, episodeString: $episodeString) { + sourceUrls + } + }` + + result, err := c.graphqlRequestWithHash(ctx, showID, episode, mode) + if err == nil { + sources := c.extractSourceURLsFromData(ctx, result) + if len(sources) > 0 { + return sources, nil + } + } + + result, err = c.graphqlRequest(ctx, episodeQuery, map[string]any{ + "showId": showID, + "translationType": mode, + "episodeString": episode, + }) + if err != nil { + return nil, err + } + + data, ok := result["data"].(map[string]any) + if !ok { + return nil, fmt.Errorf("invalid source response") + } + + rawSourceURLs, ok := data["episode"].(map[string]any) + if !ok { + return nil, fmt.Errorf("invalid episode response") + } + + sourceURLs, ok := rawSourceURLs["sourceUrls"].([]any) + if !ok || len(sourceURLs) == 0 { + return nil, fmt.Errorf("no source urls") + } + + references := buildSourceReferences(sourceURLs) + if len(references) == 0 { + return nil, fmt.Errorf("no source references") + } + + out := make([]StreamSource, 0, len(references)) + for _, ref := range references { + target := strings.TrimSpace(ref.URL) + if target == "" { + continue + } + + if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") { + sourceType := detectStreamType(target) + if sourceType == "unknown" { + sourceType = detectEmbedType(target) + } + + out = append(out, buildStreamSource(target, sourceType, ref.Name)) + continue + } + + decoded := decodeSourceURL(target) + if decoded == "" { + continue + } + + if strings.HasPrefix(decoded, "http://") || strings.HasPrefix(decoded, "https://") { + sourceType := detectStreamType(decoded) + if sourceType == "unknown" { + sourceType = detectEmbedType(decoded) + } + + out = append(out, buildStreamSource(decoded, sourceType, ref.Name)) + continue + } + + if !strings.HasPrefix(decoded, "/") { + decoded = "/" + decoded + } + + extracted, err := c.extractor.ExtractVideoLinks(ctx, decoded) + if err != nil { + continue + } + + out = append(out, extracted...) + } + + if len(out) == 0 { + return nil, fmt.Errorf("no playable sources extracted") + } + + return out, nil +} + +func (c *AllAnimeProvider) extractSourceURLsFromData(ctx context.Context, data map[string]any) []StreamSource { + episodeData, ok := data["episode"].(map[string]any) + if !ok { + return nil + } + + sourceURLs, ok := episodeData["sourceUrls"].([]any) + if !ok || len(sourceURLs) == 0 { + return nil + } + + references := buildSourceReferences(sourceURLs) + if len(references) == 0 { + return nil + } + + out := make([]StreamSource, 0, len(references)) + for _, ref := range references { + target := strings.TrimSpace(ref.URL) + if target == "" { + continue + } + + if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") { + sourceType := detectStreamType(target) + if sourceType == "unknown" { + sourceType = detectEmbedType(target) + } + + out = append(out, buildStreamSource(target, sourceType, ref.Name)) + continue + } + + decoded := decodeSourceURL(target) + if decoded == "" { + continue + } + + if strings.HasPrefix(decoded, "http://") || strings.HasPrefix(decoded, "https://") { + sourceType := detectStreamType(decoded) + if sourceType == "unknown" { + sourceType = detectEmbedType(decoded) + } + + out = append(out, buildStreamSource(decoded, sourceType, ref.Name)) + continue + } + + if !strings.HasPrefix(decoded, "/") { + decoded = "/" + decoded + } + + extracted, err := c.extractor.ExtractVideoLinks(ctx, decoded) + if err != nil { + continue + } + + out = append(out, extracted...) + } + + return out +} + +func buildStreamSource(url, sourceType, provider string) StreamSource { + return StreamSource{ + URL: url, + Provider: provider, + Type: sourceType, + Referer: allAnimeReferer, + } +} + +type sourceReference struct { + URL string + Name string +} + +// buildSourceReferences orders source URLs by provider priority, deduplicating entries. +func buildSourceReferences(rawSourceURLs []any) []sourceReference { + priorityOrder := []string{"default", "yt-mp4", "s-mp4", "luf-mp4"} + prioritySet := map[string]struct{}{"default": {}, "yt-mp4": {}, "s-mp4": {}, "luf-mp4": {}} + + prioritized := make(map[string]sourceReference) + fallback := make([]sourceReference, 0, len(rawSourceURLs)) + seen := make(map[string]struct{}) + + for _, source := range rawSourceURLs { + item, ok := source.(map[string]any) + if !ok { + continue + } + + sourceURL, _ := item["sourceUrl"].(string) + sourceName, _ := item["sourceName"].(string) + sourceURL = strings.TrimSpace(sourceURL) + sourceName = strings.TrimSpace(sourceName) + if sourceURL == "" { + continue + } + + if _, exists := seen[sourceURL]; exists { + continue + } + seen[sourceURL] = struct{}{} + + ref := sourceReference{URL: sourceURL, Name: sourceName} + normalized := strings.ToLower(sourceName) + // separate prioritized providers from fallback + if _, prioritizedProvider := prioritySet[normalized]; prioritizedProvider { + if _, exists := prioritized[normalized]; !exists { + prioritized[normalized] = ref + } + continue + } + + fallback = append(fallback, ref) + } + + // output: prioritized in order, then fallback + ordered := make([]sourceReference, 0, len(prioritized)+len(fallback)) + for _, provider := range priorityOrder { + if ref, ok := prioritized[provider]; ok { + ordered = append(ordered, ref) + } + } + + ordered = append(ordered, fallback...) + return ordered +} + +func decryptTobeparsed(encoded string) ([]byte, error) { + raw, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return nil, fmt.Errorf("base64 decode failed: %w", err) + } + + if len(raw) < 29 { + return nil, fmt.Errorf("encrypted payload too short") + } + + version := raw[0] + iv := raw[1:13] + cipherText := raw[13 : len(raw)-16] + + for _, keyStr := range aesKeys { + key := sha256.Sum256([]byte(keyStr)) + + block, err := aes.NewCipher(key[:]) + if err != nil { + continue + } + + if version == 1 { + plainText := tryDecryptCTR(block, iv, cipherText) + if json.Valid(plainText) { + return plainText, nil + } + } + + gcm, err := cipher.NewGCM(block) + if err == nil { + tag := raw[len(raw)-16:] + combined := append(append([]byte{}, cipherText...), tag...) + plainText, openErr := gcm.Open(nil, iv, combined, nil) + if openErr == nil && json.Valid(plainText) { + return plainText, nil + } + } + } + + return nil, fmt.Errorf("decryption failed") +} + +func tryDecryptCTR(block cipher.Block, iv []byte, cipherText []byte) []byte { + ctrIV := append([]byte{}, iv...) + ctrIV = append(ctrIV, 0x00, 0x00, 0x00, 0x02) + ctr := cipher.NewCTR(block, ctrIV) + plainText := make([]byte, len(cipherText)) + ctr.XORKeyStream(plainText, cipherText) + return plainText +} + +// GetAvailableEpisodes returns the count of sub/dub/raw episodes available for a show. +func (c *AllAnimeProvider) GetAvailableEpisodes(ctx context.Context, showID string) (AvailableEpisodes, error) { + graphqlQuery := `query($showId: String!) { + show(_id: $showId) { + availableEpisodesDetail + lastEpisodeInfo + } + }` + + result, err := c.graphqlRequest(ctx, graphqlQuery, map[string]any{"showId": showID}) + if err != nil { + return AvailableEpisodes{}, err + } + + data, ok := result["data"].(map[string]any) + if !ok { + return AvailableEpisodes{}, fmt.Errorf("invalid response") + } + + show, ok := data["show"].(map[string]any) + if !ok || show == nil { + return AvailableEpisodes{}, fmt.Errorf("show not found") + } + + detail, ok := show["availableEpisodesDetail"].(map[string]any) + if !ok { + return AvailableEpisodes{}, fmt.Errorf("invalid detail") + } + + var count AvailableEpisodes + if sub, ok := detail["sub"].([]any); ok { + for _, s := range sub { + if str, ok := s.(string); ok { + count.Sub = append(count.Sub, str) + } + } + } + if dub, ok := detail["dub"].([]any); ok { + for _, s := range dub { + if str, ok := s.(string); ok { + count.Dub = append(count.Dub, str) + } + } + } + if raw, ok := detail["raw"].([]any); ok { + for _, s := range raw { + if str, ok := s.(string); ok { + count.Raw = append(count.Raw, str) + } + } + } + + return count, nil +} + +func decodeSourceURL(encoded string) string { + if encoded == "" { + return "" + } + + encoded = strings.TrimPrefix(encoded, "--") + + substitutions := map[string]string{ + "79": "A", "7a": "B", "7b": "C", "7c": "D", "7d": "E", + "7e": "F", "7f": "G", "70": "H", "71": "I", "72": "J", + "73": "K", "74": "L", "75": "M", "76": "N", "77": "O", + "68": "P", "69": "Q", "6a": "R", "6b": "S", "6c": "T", + "6d": "U", "6e": "V", "6f": "W", "60": "X", "61": "Y", + "62": "Z", + "59": "a", "5a": "b", "5b": "c", "5c": "d", "5d": "e", + "5e": "f", "5f": "g", "50": "h", "51": "i", "52": "j", + "53": "k", "54": "l", "55": "m", "56": "n", "57": "o", + "48": "p", "49": "q", "4a": "r", "4b": "s", "4c": "t", + "4d": "u", "4e": "v", "4f": "w", "40": "x", "41": "y", + "42": "z", + "08": "0", "09": "1", "0a": "2", "0b": "3", "0c": "4", + "0d": "5", "0e": "6", "0f": "7", "00": "8", "01": "9", + "15": "-", "16": ".", "67": "_", "46": "~", "02": ":", + "17": "/", "07": "?", "1b": "#", "63": "[", "65": "]", + "78": "@", "19": "!", "1c": "$", "1e": "&", "10": "(", + "11": ")", "12": "*", "13": "+", "14": ",", "03": ";", + "05": "=", "1d": "%", + } + + var result strings.Builder + for idx := 0; idx < len(encoded); { + if idx+2 <= len(encoded) { + pair := encoded[idx : idx+2] + if sub, ok := substitutions[pair]; ok { + result.WriteString(sub) + idx += 2 + continue + } + } + + result.WriteByte(encoded[idx]) + idx++ + } + + decoded := result.String() + if strings.Contains(decoded, "/clock") && !strings.Contains(decoded, "/clock.json") { + decoded = strings.Replace(decoded, "/clock", "/clock.json", 1) + } + + return decoded +} + +func detectStreamType(sourceURL string) string { + lower := strings.ToLower(sourceURL) + if strings.Contains(lower, ".m3u8") || strings.Contains(lower, "master.m3u8") { + return "m3u8" + } + + if strings.Contains(lower, ".mp4") { + return "mp4" + } + + return "unknown" +} + +func detectEmbedType(rawURL string) string { + lower := strings.ToLower(rawURL) + embedHosts := []string{"streamwish", "streamsb", "mp4upload", "ok.ru", "gogoplay", "streamlare"} + for _, host := range embedHosts { + if strings.Contains(lower, host) { + return "embed" + } + } + + return "unknown" +} diff --git a/integrations/playback/allanime/client_test.go b/integrations/playback/allanime/client_test.go new file mode 100644 index 0000000..7160881 --- /dev/null +++ b/integrations/playback/allanime/client_test.go @@ -0,0 +1,449 @@ +package allanime + +import ( + "context" + "crypto/aes" + "encoding/json" + "testing" + "mal/internal/domain" +) + +func TestDecodeSourceURL(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + encoded string + want string + }{ + { + name: "empty returns empty", + encoded: "", + want: "", + }, + { + name: "with double prefix stripped", + encoded: "--example.com/video.mp4", + want: "example.com/video.mp4", + }, + { + name: "hex substitution", + encoded: "7aexample", + want: "Bexample", + }, + { + name: "mixed substitution", + encoded: "79url7a01", + want: "AurlB9", + }, + { + name: "clock replacement", + encoded: "/clock", + want: "/clock.json", + }, + { + name: "no clock replacement if already json", + encoded: "/clock.json", + want: "/clock.json", + }, + { + name: "complex url", + encoded: "--79stream7acom", + want: "AstreamBcom", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := decodeSourceURL(tt.encoded) + if got != tt.want { + t.Errorf("decodeSourceURL(%q) = %q, want %q", tt.encoded, got, tt.want) + } + }) + } +} + +func TestDetectStreamType(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + url string + wantType string + }{ + { + name: "m3u8 extension", + url: "https://example.com/video.m3u8", + wantType: "m3u8", + }, + { + name: "master m3u8", + url: "https://example.com/master.m3u8", + wantType: "m3u8", + }, + { + name: "mp4 extension", + url: "https://example.com/video.mp4", + wantType: "mp4", + }, + { + name: "unknown", + url: "https://example.com/video.avi", + wantType: "unknown", + }, + { + name: "empty returns unknown", + url: "", + wantType: "unknown", + }, + { + name: "case insensitive - M3U8", + url: "https://example.com/MASTER.M3U8", + wantType: "m3u8", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := detectStreamType(tt.url) + if got != tt.wantType { + t.Errorf("detectStreamType(%q) = %q, want %q", tt.url, got, tt.wantType) + } + }) + } +} + +func TestDetectEmbedType(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + url string + wantType string + }{ + { + name: "streamwish", + url: "https://streamwish.com/e/abc123", + wantType: "embed", + }, + { + name: "streamsb", + url: "https://streamsb.com/e/abc123", + wantType: "embed", + }, + { + name: "mp4upload", + url: "https://mp4upload.com/e/abc123", + wantType: "embed", + }, + { + name: "ok.ru", + url: "https://ok.ru/video/123", + wantType: "embed", + }, + { + name: "gogoplay", + url: "https://gogoplay.io/embed/123", + wantType: "embed", + }, + { + name: "streamlare", + url: "https://streamlare.com/e/abc", + wantType: "embed", + }, + { + name: "unknown host", + url: "https://unknown.com/video", + wantType: "unknown", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := detectEmbedType(tt.url) + if got != tt.wantType { + t.Errorf("detectEmbedType(%q) = %q, want %q", tt.url, got, tt.wantType) + } + }) + } +} + +func TestBuildStreamSource(t *testing.T) { + t.Parallel() + + t.Run("constructs with correct defaults", func(t *testing.T) { + got := buildStreamSource("https://example.com/video.mp4", "mp4", "test-provider") + + if got.URL != "https://example.com/video.mp4" { + t.Errorf("URL = %q, want %q", got.URL, "https://example.com/video.mp4") + } + if got.Provider != "test-provider" { + t.Errorf("Provider = %q, want %q", got.Provider, "test-provider") + } + if got.Type != "mp4" { + t.Errorf("Type = %q, want %q", got.Type, "mp4") + } + if got.Referer != allAnimeReferer { + t.Errorf("Referer = %q, want %q", got.Referer, allAnimeReferer) + } + }) +} + +func TestBuildSourceReferences(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + rawURLs []any + wantRefs []sourceReference + }{ + { + name: "empty returns empty", + rawURLs: nil, + wantRefs: nil, + }, + { + name: "filters empty URLs", + rawURLs: []any{ + map[string]any{"sourceUrl": "", "sourceName": "test"}, + map[string]any{"sourceUrl": "https://example.com/v.mp4", "sourceName": "default"}, + }, + wantRefs: []sourceReference{ + {URL: "https://example.com/v.mp4", Name: "default"}, + }, + }, + { + name: "deduplicates URLs", + rawURLs: []any{ + map[string]any{"sourceUrl": "https://example.com/v.mp4", "sourceName": "test"}, + map[string]any{"sourceUrl": "https://example.com/v.mp4", "sourceName": "test2"}, + }, + wantRefs: []sourceReference{ + {URL: "https://example.com/v.mp4", Name: "test"}, + }, + }, + { + name: "prioritizes default provider", + rawURLs: []any{ + map[string]any{"sourceUrl": "https://a.com/v.mp4", "sourceName": "fallback"}, + map[string]any{"sourceUrl": "https://b.com/v.mp4", "sourceName": "default"}, + map[string]any{"sourceUrl": "https://c.com/v.mp4", "sourceName": "yt-mp4"}, + }, + wantRefs: []sourceReference{ + {URL: "https://b.com/v.mp4", Name: "default"}, + {URL: "https://c.com/v.mp4", Name: "yt-mp4"}, + {URL: "https://a.com/v.mp4", Name: "fallback"}, + }, + }, + { + name: "skips invalid map entries", + rawURLs: []any{ + "invalid", + 123, + map[string]any{"sourceUrl": "https://example.com/v.mp4"}, + }, + wantRefs: []sourceReference{ + {URL: "https://example.com/v.mp4", Name: ""}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := buildSourceReferences(tt.rawURLs) + + if len(got) != len(tt.wantRefs) { + t.Errorf("got %d refs, want %d", len(got), len(tt.wantRefs)) + return + } + + for i, want := range tt.wantRefs { + if got[i].URL != want.URL { + t.Errorf("ref[%d].URL = %q, want %q", i, got[i].URL, want.URL) + } + if got[i].Name != want.Name { + t.Errorf("ref[%d].Name = %q, want %q", i, got[i].Name, want.Name) + } + } + }) + } +} + +func TestBuildSourceReferencesOrder(t *testing.T) { + t.Parallel() + + rawURLs := []any{ + map[string]any{"sourceUrl": "https://s.com/v.mp4", "sourceName": "s-mp4"}, + map[string]any{"sourceUrl": "https://default.com/v.mp4", "sourceName": "default"}, + map[string]any{"sourceUrl": "https://luf.com/v.mp4", "sourceName": "luf-mp4"}, + map[string]any{"sourceUrl": "https://yt.com/v.mp4", "sourceName": "yt-mp4"}, + } + + got := buildSourceReferences(rawURLs) + + wantOrder := []string{"default", "yt-mp4", "s-mp4", "luf-mp4"} + if len(got) != len(wantOrder) { + t.Fatalf("got %d refs, want %d", len(got), len(wantOrder)) + } + + for i, wantName := range wantOrder { + if got[i].Name != wantName { + t.Errorf("ref[%d].Name = %q, want %q (priority order: default > yt-mp4 > s-mp4 > luf-mp4)", i, got[i].Name, wantName) + } + } +} + +func TestIsLikelyM3U8(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input []byte + want bool + }{ + { + name: "valid m3u8", + input: []byte("#EXTM3U\n#EXT-X-VERSION:3"), + want: true, + }, + { + name: "with leading spaces", + input: []byte(" #EXTM3U\n"), + want: true, + }, + { + name: "empty", + input: []byte{}, + want: false, + }, + { + name: "not m3u8", + input: []byte(" 0 { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(time.Duration(attempt) * 2 * time.Second): + } + } + + resp, err = doProxiedRequest(ctx, e.httpClient, endpoint, e.referer) + if err == nil { + break + } + + if attempt == 2 { + return nil, fmt.Errorf("fetch provider response: %w", err) + } + } + + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024)) // 2MB limit + if err != nil { + return nil, fmt.Errorf("read provider response: %w", err) + } + + return e.parseProviderResponse(ctx, string(body)), nil +} + +// parseProviderResponse extracts stream sources from provider JSON response. +func (e *providerExtractor) parseProviderResponse(ctx context.Context, response string) []StreamSource { + sources := make([]StreamSource, 0) + providerReferer := e.referer + + // extract per-source referer if present + refererPattern := regexp.MustCompile(`"Referer":"([^"]+)"`) + if match := refererPattern.FindStringSubmatch(response); len(match) >= 2 { + providerReferer = strings.ReplaceAll(match[1], `\/`, "/") + } + if providerReferer == "" { + providerReferer = e.referer + } + + // extract direct link sources (mp4/embed) + linkPattern := regexp.MustCompile(`"link":"([^"]+)","resolutionStr":"([^"]+)"`) + for _, match := range linkPattern.FindAllStringSubmatch(response, -1) { + if len(match) < 3 { + continue + } + + link := strings.ReplaceAll(match[1], `\/`, "/") + quality := strings.TrimSpace(match[2]) + sourceType := detectStreamType(link) + if sourceType == "unknown" { + sourceType = detectEmbedType(link) + } + + sources = append(sources, StreamSource{ + URL: link, + Quality: quality, + Provider: "wixmp", + Type: sourceType, + Referer: providerReferer, + }) + } + + // extract HLS playlist sources + hlsPattern := regexp.MustCompile(`"url":"([^"]+)","hardsub_lang":"en-US"`) + for _, match := range hlsPattern.FindAllStringSubmatch(response, -1) { + if len(match) < 2 { + continue + } + + playlistURL := strings.ReplaceAll(match[1], `\/`, "/") + if strings.Contains(playlistURL, "master.m3u8") { + parsed, err := e.parseM3U8(ctx, playlistURL, providerReferer) + if err == nil { + sources = append(sources, parsed...) + } + continue + } + + sources = append(sources, StreamSource{ + URL: playlistURL, + Quality: "auto", + Provider: "hls", + Type: "m3u8", + Referer: providerReferer, + }) + } + + // extract subtitles and attach to all sources + subtitlePattern := regexp.MustCompile(`"subtitles":\[(.*?)\]`) + if subtitleMatch := subtitlePattern.FindStringSubmatch(response); len(subtitleMatch) >= 2 { + subtitles := make([]Subtitle, 0) + subtitleEntryPattern := regexp.MustCompile(`"lang":"([^"]+)".*?"src":"([^"]+)"`) + for _, entry := range subtitleEntryPattern.FindAllStringSubmatch(subtitleMatch[1], -1) { + if len(entry) < 3 { + continue + } + + subtitles = append(subtitles, Subtitle{ + Lang: strings.TrimSpace(entry[1]), + URL: strings.ReplaceAll(entry[2], `\/`, "/"), + }) + } + + if len(subtitles) > 0 { + for idx := range sources { + sources[idx].Subtitles = subtitles + } + } + } + + return sources +} + +// parseM3U8 fetches a master playlist and extracts individual stream URLs with bandwidth-derived quality. +func (e *providerExtractor) parseM3U8(ctx context.Context, masterURL string, referer string) ([]StreamSource, error) { + resp, err := doProxiedRequest(ctx, e.httpClient, masterURL, referer) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 512*1024)) // 512KB limit + if err != nil { + return nil, err + } + + lines := strings.Split(string(body), "\n") + baseURL := masterURL + if idx := strings.LastIndex(masterURL, "/"); idx >= 0 { + baseURL = masterURL[:idx+1] + } + + currentBandwidth := 0 + sources := make([]StreamSource, 0) + bwPattern := regexp.MustCompile(`BANDWIDTH=(\d+)`) + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "#EXT-X-STREAM-INF") { + match := bwPattern.FindStringSubmatch(trimmed) + if len(match) >= 2 { + value, convErr := strconv.Atoi(match[1]) + if convErr == nil { + currentBandwidth = value + } + } + continue + } + + // skip empty lines and non-stream lines + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + + streamURL := trimmed + if !strings.HasPrefix(streamURL, "http://") && !strings.HasPrefix(streamURL, "https://") { + streamURL = baseURL + streamURL + } + + quality := "auto" + kbps := currentBandwidth / 1000 + switch { + case kbps >= 8000: + quality = "1080p" + case kbps >= 5000: + quality = "720p" + case kbps >= 2500: + quality = "480p" + case kbps > 0: + quality = "360p" + } + + sources = append(sources, StreamSource{ + URL: streamURL, + Quality: quality, + Provider: "hls", + Type: "m3u8", + Referer: referer, + }) + } + + return sources, nil +} diff --git a/integrations/playback/allanime/http_utils.go b/integrations/playback/allanime/http_utils.go new file mode 100644 index 0000000..342b551 --- /dev/null +++ b/integrations/playback/allanime/http_utils.go @@ -0,0 +1,26 @@ +package allanime + +import ( + "context" + "net/http" +) + +// doProxiedRequest performs an HTTP GET with standard playback headers. +func doProxiedRequest(ctx context.Context, client *http.Client, url string, referer string) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + req.Header.Set("User-Agent", defaultUserAgent) + if referer != "" { + req.Header.Set("Referer", referer) + } + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + + return resp, nil +} diff --git a/integrations/playback/allanime/module.go b/integrations/playback/allanime/module.go new file mode 100644 index 0000000..2d94809 --- /dev/null +++ b/integrations/playback/allanime/module.go @@ -0,0 +1,9 @@ +package allanime + +import ( + "go.uber.org/fx" +) + +var Module = fx.Options( + fx.Provide(NewAllAnimeProvider), +) diff --git a/integrations/playback/allanime/types.go b/integrations/playback/allanime/types.go new file mode 100644 index 0000000..9b76d50 --- /dev/null +++ b/integrations/playback/allanime/types.go @@ -0,0 +1,52 @@ +package allanime + +// StreamSource represents a video stream from a provider. +type StreamSource struct { + URL string + Quality string + Provider string + Type string // m3u8, mp4, embed, unknown + Referer string + Subtitles []Subtitle + AvailableQualities []StreamSource +} + +type Subtitle struct { + Lang string + URL string +} + +type ModeSource struct { + URL string `json:"url,omitempty"` + Referer string `json:"referer,omitempty"` + Token string `json:"token"` + Subtitles []SubtitleItem `json:"subtitles"` + Qualities []string `json:"qualities,omitempty"` +} + +type SubtitleItem struct { + Lang string `json:"lang"` + URL string `json:"url,omitempty"` + Referer string `json:"referer,omitempty"` + Token string `json:"token"` +} + +type SkipSegment struct { + Type string `json:"type"` + Start float64 `json:"start"` + End float64 `json:"end"` +} + +// WatchPageData is the response payload for the watch page frontend. +type WatchPageData struct { + MalID int + Title string + CurrentEpisode string + StartTimeSeconds float64 + CurrentStatus string + InitialMode string + AvailableModes []string + ModeSources map[string]ModeSource + Segments []SkipSegment + FallbackEpisodes map[string]int +} From 1b88c4597c1da71d5752e8ae74e8eceb8e238df6 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Wed, 13 May 2026 11:20:48 +0200 Subject: [PATCH 14/71] feat: implement auth middleware using domain service --- internal/auth/middleware/middleware.go | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 internal/auth/middleware/middleware.go diff --git a/internal/auth/middleware/middleware.go b/internal/auth/middleware/middleware.go new file mode 100644 index 0000000..b48f92e --- /dev/null +++ b/internal/auth/middleware/middleware.go @@ -0,0 +1,20 @@ +package middleware + +import ( + "mal/internal/domain" + + "github.com/gin-gonic/gin" +) + +func AuthMiddleware(svc domain.AuthService) gin.HandlerFunc { + return func(c *gin.Context) { + sessionID, err := c.Cookie("session_id") + if err == nil { + user, err := svc.ValidateSession(c.Request.Context(), sessionID) + if err == nil { + c.Set("User", user) + } + } + c.Next() + } +} From 345853406c8f68874170141fb07261965907d694 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Wed, 13 May 2026 11:20:49 +0200 Subject: [PATCH 15/71] refactor: general architectural cleanup and bug fixes --- cmd/server/main.go | 1 - integrations/jikan/client.go | 4 +- integrations/jikan/module.go | 2 - internal/anime/handler/handler.go | 165 ++++++++++++++---- internal/anime/service/service.go | 4 +- internal/app/app.go | 15 +- internal/auth/handler/handler.go | 7 +- internal/auth/module.go | 6 + internal/auth/repository/repository.go | 2 - internal/database/migrations/001_init.sql | 8 + .../migrations/002_add_anime_titles.sql | 3 + .../migrations/003_add_anime_airing.sql | 3 + .../migrations/004_add_notifications.sql | 3 + .../migrations/005_add_anime_relations.sql | 3 + .../migrations/006_add_jikan_cache.sql | 3 + .../migrations/007_add_query_indexes.sql | 3 + .../migrations/009_add_anime_fetch_retry.sql | 3 + .../010_add_watch_progress_seconds.sql | 4 +- .../migrations/011_add_continue_watching.sql | 3 + .../migrations/012_remove_recovery_key.sql | 3 + .../database/migrations/013_drop_account.sql | 4 +- .../migrations/014_add_watchlist_statuses.sql | 3 + .../database/migrations/015_add_duration.sql | 4 +- .../migrations/016_add_avatar_url.sql | 4 +- internal/db/sqlite.go | 9 +- internal/server/server.go | 3 +- internal/templates/renderer.go | 27 ++- internal/watchlist/handler/handler.go | 31 +++- internal/watchlist/service/service.go | 1 - static/assets/style.css | 4 +- templates/anime.gohtml | 8 +- templates/base.gohtml | 24 ++- templates/components/continue_watching.gohtml | 4 +- templates/components/watchlist_actions.gohtml | 5 +- 34 files changed, 274 insertions(+), 102 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index 8e7d227..860e6a1 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -1,7 +1,6 @@ package main import ( - "log" "mal/internal/app" "github.com/joho/godotenv" diff --git a/integrations/jikan/client.go b/integrations/jikan/client.go index 1c88d76..2ba59b6 100644 --- a/integrations/jikan/client.go +++ b/integrations/jikan/client.go @@ -25,7 +25,7 @@ type Client struct { lastReqTime time.Time // rate limiting: last request timestamp } -func NewClient(db db.Querier) *Client { +func NewClient(queries *db.Queries) *Client { return &Client{ httpClient: &http.Client{ Timeout: 10 * time.Second, @@ -37,7 +37,7 @@ func NewClient(db db.Querier) *Client { }, }, baseURL: "https://api.jikan.moe/v4", - db: db, + db: queries, retrySignal: make(chan struct{}, 1), } } diff --git a/integrations/jikan/module.go b/integrations/jikan/module.go index 587b6dc..811d82b 100644 --- a/integrations/jikan/module.go +++ b/integrations/jikan/module.go @@ -1,8 +1,6 @@ package jikan import ( - "mal/internal/db" - "go.uber.org/fx" ) diff --git a/internal/anime/handler/handler.go b/internal/anime/handler/handler.go index 78f122b..679df08 100644 --- a/internal/anime/handler/handler.go +++ b/internal/anime/handler/handler.go @@ -1,28 +1,29 @@ package handler import ( - "context" "fmt" "log" "mal/internal/domain" - "mal/internal/server" "net/http" "strconv" - "strings" - "time" "github.com/gin-gonic/gin" ) type AnimeHandler struct { - svc domain.AnimeService + svc domain.AnimeService + watchlistSvc domain.WatchlistService } -func NewAnimeHandler(svc domain.AnimeService) *AnimeHandler { - return &AnimeHandler{svc: svc} +func NewAnimeHandler(svc domain.AnimeService, watchlistSvc domain.WatchlistService) *AnimeHandler { + return &AnimeHandler{ + svc: svc, + watchlistSvc: watchlistSvc, + } } func (h *AnimeHandler) Register(r *gin.Engine) { + log.Println("Registering anime routes") r.GET("/", h.HandleCatalog) r.GET("/api/catalog/airing", h.HandleCatalogAiring) r.GET("/api/catalog/popular", h.HandleCatalogPopular) @@ -39,8 +40,19 @@ func (h *AnimeHandler) Register(r *gin.Engine) { } func (h *AnimeHandler) HandleCatalog(c *gin.Context) { + user, _ := c.Get("User") + watchlistMap := make(map[int]bool) + if u, ok := user.(*domain.User); ok { + entries, _ := h.watchlistSvc.GetWatchlist(c.Request.Context(), u.ID) + for _, e := range entries { + watchlistMap[int(e.AnimeID)] = true + } + } + c.HTML(http.StatusOK, "index.gohtml", gin.H{ - "CurrentPath": "/", + "CurrentPath": "/", + "User": user, + "WatchlistMap": watchlistMap, }) } @@ -57,21 +69,36 @@ func (h *AnimeHandler) HandleCatalogContinue(c *gin.Context) { } func (h *AnimeHandler) renderCatalogSection(c *gin.Context, section string) { - userID := "" // TODO: get from auth context + user, _ := c.Get("User") + userID := "" + if u, ok := user.(*domain.User); ok { + userID = u.ID + } data, err := h.svc.GetCatalogSection(c.Request.Context(), userID, section) if err != nil { log.Printf("catalog %s error: %v", section, err) return } + watchlistMap := make(map[int]bool) + if userID != "" { + entries, _ := h.watchlistSvc.GetWatchlist(c.Request.Context(), userID) + for _, e := range entries { + watchlistMap[int(e.AnimeID)] = true + } + } + data["Section"] = section data["_fragment"] = "catalog_section" + data["WatchlistMap"] = watchlistMap c.HTML(http.StatusOK, "index.gohtml", data) } func (h *AnimeHandler) HandleDiscover(c *gin.Context) { + user, _ := c.Get("User") c.HTML(http.StatusOK, "discover.gohtml", gin.H{ "CurrentPath": "/discover", + "User": user, }) } @@ -88,15 +115,28 @@ func (h *AnimeHandler) HandleDiscoverTop(c *gin.Context) { } func (h *AnimeHandler) renderDiscoverSection(c *gin.Context, section string) { - userID := "" // TODO: get from auth context + user, _ := c.Get("User") + userID := "" + if u, ok := user.(*domain.User); ok { + userID = u.ID + } data, err := h.svc.GetDiscoverSection(c.Request.Context(), userID, section) if err != nil { log.Printf("discover %s error: %v", section, err) return } + watchlistMap := make(map[int]bool) + if userID != "" { + entries, _ := h.watchlistSvc.GetWatchlist(c.Request.Context(), userID) + for _, e := range entries { + watchlistMap[int(e.AnimeID)] = true + } + } + data["Section"] = section data["_fragment"] = "discover_section" + data["WatchlistMap"] = watchlistMap c.HTML(http.StatusOK, "discover.gohtml", data) } @@ -145,19 +185,30 @@ func (h *AnimeHandler) HandleBrowse(c *gin.Context) { genresList, _ := h.svc.GetGenres(c.Request.Context()) + user, _ := c.Get("User") + watchlistMap := make(map[int]bool) + if u, ok := user.(*domain.User); ok { + entries, _ := h.watchlistSvc.GetWatchlist(c.Request.Context(), u.ID) + for _, e := range entries { + watchlistMap[int(e.AnimeID)] = true + } + } + c.HTML(http.StatusOK, "browse.gohtml", gin.H{ - "CurrentPath": "/browse", - "Query": q, - "Type": animeType, - "Status": status, - "OrderBy": orderBy, - "Sort": sort, - "Genres": genres, - "SFW": sfw, - "GenresList": genresList, - "Animes": res.Animes, - "HasNextPage": res.HasNextPage, - "NextPage": page + 1, + "CurrentPath": "/browse", + "Query": q, + "Type": animeType, + "Status": status, + "OrderBy": orderBy, + "Sort": sort, + "Genres": genres, + "SFW": sfw, + "GenresList": genresList, + "Animes": res.Animes, + "HasNextPage": res.HasNextPage, + "NextPage": page + 1, + "User": user, + "WatchlistMap": watchlistMap, }) } @@ -189,7 +240,7 @@ func (h *AnimeHandler) HandleAnimeDetails(c *gin.Context) { c.HTML(http.StatusOK, "anime.gohtml", gin.H{ "_fragment": tplName, - "Data": data, + "Items": data, }) return } @@ -200,9 +251,11 @@ func (h *AnimeHandler) HandleAnimeDetails(c *gin.Context) { return } + user, _ := c.Get("User") c.HTML(http.StatusOK, "anime.gohtml", gin.H{ "Anime": anime, "CurrentPath": fmt.Sprintf("/anime/%d", id), + "User": user, }) } @@ -213,16 +266,31 @@ func (h *AnimeHandler) HandleHTMLWatchOrder(c *gin.Context) { return } + user, _ := c.Get("User") + userID := "" + if u, ok := user.(*domain.User); ok { + userID = u.ID + } + relations, err := h.svc.GetRelations(c.Request.Context(), id) if err != nil { c.Status(http.StatusInternalServerError) return } + watchlistMap := make(map[int]bool) + if userID != "" { + entries, _ := h.watchlistSvc.GetWatchlist(c.Request.Context(), userID) + for _, e := range entries { + watchlistMap[int(e.AnimeID)] = true + } + } + c.HTML(http.StatusOK, "anime.gohtml", gin.H{ - "_fragment": "watch_order", - "Relations": relations, - "AnimeID": id, + "_fragment": "watch_order", + "Relations": relations, + "AnimeID": id, + "WatchlistMap": watchlistMap, }) } @@ -239,20 +307,31 @@ func (h *AnimeHandler) HandleQuickSearch(c *gin.Context) { return } + user, _ := c.Get("User") + watchlistMap := make(map[int]bool) + if u, ok := user.(*domain.User); ok { + entries, _ := h.watchlistSvc.GetWatchlist(c.Request.Context(), u.ID) + for _, e := range entries { + watchlistMap[int(e.AnimeID)] = true + } + } + type quickSearchResult struct { - ID int `json:"id"` - Title string `json:"title"` - Type string `json:"type"` - Image string `json:"image"` + ID int `json:"id"` + Title string `json:"title"` + Type string `json:"type"` + Image string `json:"image"` + InWatchlist bool `json:"in_watchlist"` } output := make([]quickSearchResult, len(res.Animes)) for i, anime := range res.Animes { output[i] = quickSearchResult{ - ID: anime.MalID, - Title: anime.DisplayTitle(), - Type: anime.Type, - Image: anime.ImageURL(), + ID: anime.MalID, + Title: anime.DisplayTitle(), + Type: anime.Type, + Image: anime.ImageURL(), + InWatchlist: watchlistMap[anime.MalID], } } c.JSON(http.StatusOK, output) @@ -264,5 +343,21 @@ func (h *AnimeHandler) HandleRandomAnime(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch random anime"}) return } - c.JSON(http.StatusOK, gin.H{"data": anime}) + + user, _ := c.Get("User") + inWatchlist := false + if u, ok := user.(*domain.User); ok { + entries, _ := h.watchlistSvc.GetWatchlist(c.Request.Context(), u.ID) + for _, e := range entries { + if int(e.AnimeID) == anime.MalID { + inWatchlist = true + break + } + } + } + + c.JSON(http.StatusOK, gin.H{ + "data": anime, + "in_watchlist": inWatchlist, + }) } diff --git a/internal/anime/service/service.go b/internal/anime/service/service.go index 6e754f7..93f2c57 100644 --- a/internal/anime/service/service.go +++ b/internal/anime/service/service.go @@ -128,7 +128,7 @@ func (s *animeService) GetAnimeByID(ctx context.Context, id int) (domain.Anime, return s.jikan.GetAnimeByID(ctx, id) } -func (s *animeService) SearchAdvanced(ctx context.Context, q, animeType, status, orderBy, sort string, genres []int, sfw bool, page, limit int) (jikan.SearchResponse, error) { +func (s *animeService) SearchAdvanced(ctx context.Context, q, animeType, status, orderBy, sort string, genres []int, sfw bool, page, limit int) (jikan.SearchResult, error) { return s.jikan.SearchAdvanced(ctx, q, animeType, status, orderBy, sort, genres, sfw, page, limit) } @@ -144,7 +144,7 @@ func (s *animeService) GetRecommendations(ctx context.Context, id int) ([]domain return s.jikan.GetAnimeRecommendations(ctx, id) } -func (s *animeService) GetRelations(ctx context.Context, id int) ([]jikan.Relation, error) { +func (s *animeService) GetRelations(ctx context.Context, id int) ([]jikan.RelationEntry, error) { return s.jikan.GetFullRelations(ctx, id) } diff --git a/internal/app/app.go b/internal/app/app.go index 4672d5b..2d431d3 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -1,6 +1,8 @@ package app import ( + "mal/integrations/jikan" + "mal/integrations/playback/allanime" "mal/internal/database" "mal/internal/auth" "mal/internal/anime" @@ -18,17 +20,22 @@ func NewApp() *fx.App { return fx.New( database.Module, jikan.Module, + allanime.Module, auth.Module, anime.Module, watchlist.Module, playback.Module, templates.Module, server.Module, - fx.Decorate(func(r *templates.Renderer) render.HTMLRender { + fx.Provide(func(r *templates.Renderer) render.HTMLRender { return r }), - fx.Invoke(func(r *gin.Engine, registers []server.RouteRegister) { - server.RegisterRoutes(r, registers) - }), + fx.Invoke(fx.Annotate( + func(r *gin.Engine, authMiddleware gin.HandlerFunc, registers []server.RouteRegister) { + r.Use(authMiddleware) + server.RegisterRoutes(r, registers) + }, + fx.ParamTags(``, ``, `group:"routes"`), + )), ) } diff --git a/internal/auth/handler/handler.go b/internal/auth/handler/handler.go index 1474819..e844a18 100644 --- a/internal/auth/handler/handler.go +++ b/internal/auth/handler/handler.go @@ -2,7 +2,6 @@ package handler import ( "mal/internal/domain" - "mal/internal/server" "net/http" "time" @@ -43,7 +42,11 @@ func (h *AuthHandler) HandleLogin(c *gin.Context) { } c.SetCookie("session_id", session.ID, int(24*time.Hour.Seconds()), "/", "", false, true) - c.Header("HX-Redirect", "/") + if c.GetHeader("HX-Request") == "true" { + c.Header("HX-Redirect", "/") + c.Status(http.StatusOK) + return + } c.Redirect(http.StatusSeeOther, "/") } diff --git a/internal/auth/module.go b/internal/auth/module.go index c9ea02c..e5721e6 100644 --- a/internal/auth/module.go +++ b/internal/auth/module.go @@ -2,10 +2,13 @@ package auth import ( "mal/internal/auth/handler" + "mal/internal/auth/middleware" "mal/internal/auth/repository" "mal/internal/auth/service" + "mal/internal/domain" "mal/internal/server" + "github.com/gin-gonic/gin" "go.uber.org/fx" ) @@ -14,6 +17,9 @@ var Module = fx.Options( repository.NewAuthRepository, service.NewAuthService, handler.NewAuthHandler, + func(svc domain.AuthService) gin.HandlerFunc { + return middleware.AuthMiddleware(svc) + }, ), fx.Provide( server.AsRouteRegister(func(h *handler.AuthHandler) server.RouteRegister { diff --git a/internal/auth/repository/repository.go b/internal/auth/repository/repository.go index b41f241..9ec4e8d 100644 --- a/internal/auth/repository/repository.go +++ b/internal/auth/repository/repository.go @@ -7,8 +7,6 @@ import ( "mal/internal/db" "mal/internal/domain" "time" - - "github.com/google/uuid" ) type authRepository struct { diff --git a/internal/database/migrations/001_init.sql b/internal/database/migrations/001_init.sql index f3a2932..09fc8f7 100644 --- a/internal/database/migrations/001_init.sql +++ b/internal/database/migrations/001_init.sql @@ -1,3 +1,4 @@ +-- +goose Up CREATE TABLE IF NOT EXISTS user ( id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE, @@ -40,3 +41,10 @@ CREATE TABLE IF NOT EXISTS watch_list_entry ( current_time_seconds REAL NOT NULL DEFAULT 0, UNIQUE(user_id, anime_id) ); + +-- +goose Down +DROP TABLE IF EXISTS watch_list_entry; +DROP TABLE IF EXISTS anime; +DROP TABLE IF EXISTS account; +DROP TABLE IF EXISTS session; +DROP TABLE IF EXISTS user; diff --git a/internal/database/migrations/002_add_anime_titles.sql b/internal/database/migrations/002_add_anime_titles.sql index a1f2564..e0427a5 100644 --- a/internal/database/migrations/002_add_anime_titles.sql +++ b/internal/database/migrations/002_add_anime_titles.sql @@ -1,6 +1,9 @@ +-- +goose Up -- Add English and Japanese title columns to anime table ALTER TABLE anime ADD COLUMN title_english TEXT; ALTER TABLE anime ADD COLUMN title_japanese TEXT; -- Rename existing title to title_original for clarity ALTER TABLE anime RENAME COLUMN title TO title_original; + +-- +goose Down diff --git a/internal/database/migrations/003_add_anime_airing.sql b/internal/database/migrations/003_add_anime_airing.sql index 8f74ee6..dce11c7 100644 --- a/internal/database/migrations/003_add_anime_airing.sql +++ b/internal/database/migrations/003_add_anime_airing.sql @@ -1,2 +1,5 @@ +-- +goose Up -- Add airing status column to anime table ALTER TABLE anime ADD COLUMN airing BOOLEAN DEFAULT 0; + +-- +goose Down diff --git a/internal/database/migrations/004_add_notifications.sql b/internal/database/migrations/004_add_notifications.sql index a51e1ce..33936f7 100644 --- a/internal/database/migrations/004_add_notifications.sql +++ b/internal/database/migrations/004_add_notifications.sql @@ -1,3 +1,4 @@ +-- +goose Up -- Note: watch_list_entry columns now in 001_init.sql -- Add notification preferences @@ -8,3 +9,5 @@ CREATE TABLE IF NOT EXISTS notification_preference ( created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE(user_id) ); + +-- +goose Down diff --git a/internal/database/migrations/005_add_anime_relations.sql b/internal/database/migrations/005_add_anime_relations.sql index 11a82ee..f12329c 100644 --- a/internal/database/migrations/005_add_anime_relations.sql +++ b/internal/database/migrations/005_add_anime_relations.sql @@ -1,3 +1,4 @@ +-- +goose Up ALTER TABLE anime ADD COLUMN status TEXT DEFAULT ''; ALTER TABLE anime ADD COLUMN relations_synced_at DATETIME; @@ -7,3 +8,5 @@ CREATE TABLE IF NOT EXISTS anime_relation ( relation_type TEXT NOT NULL, PRIMARY KEY (anime_id, related_anime_id) ); + +-- +goose Down diff --git a/internal/database/migrations/006_add_jikan_cache.sql b/internal/database/migrations/006_add_jikan_cache.sql index bc4852a..99b63d5 100644 --- a/internal/database/migrations/006_add_jikan_cache.sql +++ b/internal/database/migrations/006_add_jikan_cache.sql @@ -1,6 +1,9 @@ +-- +goose Up CREATE TABLE IF NOT EXISTS jikan_cache ( key TEXT PRIMARY KEY, data TEXT NOT NULL, expires_at DATETIME NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); + +-- +goose Down diff --git a/internal/database/migrations/007_add_query_indexes.sql b/internal/database/migrations/007_add_query_indexes.sql index 206396f..cbacaef 100644 --- a/internal/database/migrations/007_add_query_indexes.sql +++ b/internal/database/migrations/007_add_query_indexes.sql @@ -1,3 +1,4 @@ +-- +goose Up CREATE INDEX IF NOT EXISTS idx_watch_list_entry_user_status_updated_at ON watch_list_entry(user_id, status, updated_at); @@ -9,3 +10,5 @@ ON anime(relations_synced_at, status); CREATE INDEX IF NOT EXISTS idx_jikan_cache_expires_at ON jikan_cache(expires_at); + +-- +goose Down diff --git a/internal/database/migrations/009_add_anime_fetch_retry.sql b/internal/database/migrations/009_add_anime_fetch_retry.sql index ffbbe40..11d5e10 100644 --- a/internal/database/migrations/009_add_anime_fetch_retry.sql +++ b/internal/database/migrations/009_add_anime_fetch_retry.sql @@ -1,3 +1,4 @@ +-- +goose Up CREATE TABLE IF NOT EXISTS anime_fetch_retry ( anime_id INTEGER PRIMARY KEY, attempts INTEGER NOT NULL DEFAULT 0, @@ -9,3 +10,5 @@ CREATE TABLE IF NOT EXISTS anime_fetch_retry ( CREATE INDEX IF NOT EXISTS idx_anime_fetch_retry_next_retry_at ON anime_fetch_retry(next_retry_at); + +-- +goose Down diff --git a/internal/database/migrations/010_add_watch_progress_seconds.sql b/internal/database/migrations/010_add_watch_progress_seconds.sql index c29e82b..6d59f67 100644 --- a/internal/database/migrations/010_add_watch_progress_seconds.sql +++ b/internal/database/migrations/010_add_watch_progress_seconds.sql @@ -1 +1,3 @@ --- Note: watch_list_entry columns now in 001_init.sql \ No newline at end of file +-- +goose Up +-- Note: watch_list_entry columns now in 001_init.sql +-- +goose Down diff --git a/internal/database/migrations/011_add_continue_watching.sql b/internal/database/migrations/011_add_continue_watching.sql index d12b2af..5f0ff6c 100644 --- a/internal/database/migrations/011_add_continue_watching.sql +++ b/internal/database/migrations/011_add_continue_watching.sql @@ -1,3 +1,4 @@ +-- +goose Up CREATE TABLE IF NOT EXISTS continue_watching_entry ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE, @@ -11,3 +12,5 @@ CREATE TABLE IF NOT EXISTS continue_watching_entry ( CREATE INDEX IF NOT EXISTS idx_continue_watching_user_updated ON continue_watching_entry(user_id, updated_at DESC); + +-- +goose Down diff --git a/internal/database/migrations/012_remove_recovery_key.sql b/internal/database/migrations/012_remove_recovery_key.sql index e60c060..5f33e4e 100644 --- a/internal/database/migrations/012_remove_recovery_key.sql +++ b/internal/database/migrations/012_remove_recovery_key.sql @@ -1,3 +1,4 @@ +-- +goose Up PRAGMA foreign_keys = OFF; BEGIN TRANSACTION; @@ -20,3 +21,5 @@ ALTER TABLE user_new RENAME TO user; COMMIT; PRAGMA foreign_keys = ON; + +-- +goose Down diff --git a/internal/database/migrations/013_drop_account.sql b/internal/database/migrations/013_drop_account.sql index 6a6cca9..780ca0e 100644 --- a/internal/database/migrations/013_drop_account.sql +++ b/internal/database/migrations/013_drop_account.sql @@ -1,2 +1,4 @@ +-- +goose Up DROP TABLE IF EXISTS account; -DROP TABLE IF EXISTS notification_preference; \ No newline at end of file +DROP TABLE IF EXISTS notification_preference; +-- +goose Down diff --git a/internal/database/migrations/014_add_watchlist_statuses.sql b/internal/database/migrations/014_add_watchlist_statuses.sql index b0d8bd1..2588db0 100644 --- a/internal/database/migrations/014_add_watchlist_statuses.sql +++ b/internal/database/migrations/014_add_watchlist_statuses.sql @@ -1,3 +1,4 @@ +-- +goose Up -- Add "watching" and "on_hold" to the valid statuses for watch_list_entry PRAGMA foreign_keys=OFF; @@ -24,3 +25,5 @@ FROM watch_list_entry_old; DROP TABLE watch_list_entry_old; PRAGMA foreign_keys=ON; + +-- +goose Down diff --git a/internal/database/migrations/015_add_duration.sql b/internal/database/migrations/015_add_duration.sql index 80057a5..81564ca 100644 --- a/internal/database/migrations/015_add_duration.sql +++ b/internal/database/migrations/015_add_duration.sql @@ -1,5 +1,7 @@ +-- +goose Up -- Add duration column to anime table to store episode duration in seconds ALTER TABLE anime ADD COLUMN duration_seconds REAL; -- Add duration_seconds column to continue_watching_entry to track episode duration -ALTER TABLE continue_watching_entry ADD COLUMN duration_seconds REAL; \ No newline at end of file +ALTER TABLE continue_watching_entry ADD COLUMN duration_seconds REAL; +-- +goose Down diff --git a/internal/database/migrations/016_add_avatar_url.sql b/internal/database/migrations/016_add_avatar_url.sql index bb153ac..21abe1f 100644 --- a/internal/database/migrations/016_add_avatar_url.sql +++ b/internal/database/migrations/016_add_avatar_url.sql @@ -1,3 +1,5 @@ +-- +goose Up ALTER TABLE user ADD COLUMN avatar_url TEXT NOT NULL DEFAULT ''; -UPDATE user SET avatar_url = 'https://api.dicebear.com/9.x/dylan/svg?seed=' || username WHERE avatar_url = ''; \ No newline at end of file +UPDATE user SET avatar_url = 'https://api.dicebear.com/9.x/dylan/svg?seed=' || username WHERE avatar_url = ''; +-- +goose Down diff --git a/internal/db/sqlite.go b/internal/db/sqlite.go index bec1147..db160cf 100644 --- a/internal/db/sqlite.go +++ b/internal/db/sqlite.go @@ -38,14 +38,7 @@ func GetMigrationsDir() (string, error) { return filepath.Join(wd, "migrations"), nil } -// Init opens the database, runs migrations, and returns a Queries instance +// Init opens the database and returns a Queries instance func Init(db *sql.DB) (*Queries, error) { - migrationsDir, err := GetMigrationsDir() - if err != nil { - return nil, err - } - if err := RunMigrations(db, migrationsDir); err != nil { - return nil, fmt.Errorf("failed to run migrations: %w", err) - } return New(db), nil } diff --git a/internal/server/server.go b/internal/server/server.go index ce2be58..125f6ff 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -2,7 +2,6 @@ package server import ( "context" - "fmt" "log" "net/http" "os" @@ -23,6 +22,8 @@ func ProvideRouter(htmlRender render.HTMLRender) *gin.Engine { } r := gin.New() r.Use(gin.Logger(), gin.Recovery()) + r.Static("/static", "./static") + r.Static("/dist", "./dist") r.HTMLRender = htmlRender return r } diff --git a/internal/templates/renderer.go b/internal/templates/renderer.go index fdc3bb2..0aef18d 100644 --- a/internal/templates/renderer.go +++ b/internal/templates/renderer.go @@ -1,13 +1,12 @@ package templates import ( - "context" "encoding/json" "fmt" "html/template" "io" - "log" "net/http" + "os" "path/filepath" "slices" "strconv" @@ -21,7 +20,7 @@ import ( // FS is the interface for template filesystem, to be provided by the main app or a mock. type FS interface { ReadFile(name string) ([]byte, error) - ReadDir(name string) ([]osDirEntry, error) + ReadDir(name string) ([]os.DirEntry, error) } // We will use embed.FS but wrapped in an interface if needed, or just use it directly. @@ -153,14 +152,16 @@ func ProvideRenderer() (*Renderer, error) { return nil, err } + basePath := filepath.Join(".", "templates", "base.gohtml") + for _, page := range pages { name := filepath.Base(page) if name == "base.gohtml" { continue } - tmpl := template.New(name).Funcs(funcs) - tmpl = template.Must(tmpl.ParseFiles(filepath.Join(".", "templates", "base.gohtml"))) + tmpl := template.New("base.gohtml").Funcs(funcs) + tmpl = template.Must(tmpl.ParseFiles(basePath)) if len(components) > 0 { tmpl = template.Must(tmpl.ParseFiles(components...)) } @@ -192,10 +193,18 @@ func (h HTMLRender) Render(w http.ResponseWriter) error { return fmt.Errorf("template %s not found", h.Name) } - if block, ok := h.Data.(map[string]any)["_fragment"]; ok { - if blockStr, ok := block.(string); ok { - return tmpl.ExecuteTemplate(w, blockStr, h.Data) - } + var block any + + // Handle both map[string]any and gin.H (which is map[string]any but might + // behave differently depending on the Go version/compiler in type assertions) + if dataMap, ok := h.Data.(map[string]any); ok { + block = dataMap["_fragment"] + } else if ginH, ok := h.Data.(gin.H); ok { + block = ginH["_fragment"] + } + + if blockStr, ok := block.(string); ok && blockStr != "" { + return tmpl.ExecuteTemplate(w, blockStr, h.Data) } return tmpl.ExecuteTemplate(w, "base.gohtml", h.Data) diff --git a/internal/watchlist/handler/handler.go b/internal/watchlist/handler/handler.go index 8293323..190ee3f 100644 --- a/internal/watchlist/handler/handler.go +++ b/internal/watchlist/handler/handler.go @@ -2,10 +2,8 @@ package handler import ( "mal/internal/domain" - "mal/internal/server" "net/http" "strconv" - "strings" "github.com/gin-gonic/gin" ) @@ -26,7 +24,12 @@ func (h *WatchlistHandler) Register(r *gin.Engine) { } func (h *WatchlistHandler) HandleUpdateWatchlist(c *gin.Context) { - userID := "" // TODO: get from auth context + user, _ := c.Get("User") + userID := "" + if u, ok := user.(*domain.User); ok { + userID = u.ID + } + animeID, _ := strconv.ParseInt(c.PostForm("anime_id"), 10, 64) status := c.PostForm("status") @@ -45,7 +48,12 @@ func (h *WatchlistHandler) HandleUpdateWatchlist(c *gin.Context) { } func (h *WatchlistHandler) HandleDeleteWatchlist(c *gin.Context) { - userID := "" // TODO: get from auth context + user, _ := c.Get("User") + userID := "" + if u, ok := user.(*domain.User); ok { + userID = u.ID + } + animeID, _ := strconv.ParseInt(c.Param("id"), 10, 64) if animeID <= 0 { @@ -63,7 +71,12 @@ func (h *WatchlistHandler) HandleDeleteWatchlist(c *gin.Context) { } func (h *WatchlistHandler) HandleDeleteContinueWatching(c *gin.Context) { - userID := "" // TODO: get from auth context + user, _ := c.Get("User") + userID := "" + if u, ok := user.(*domain.User); ok { + userID = u.ID + } + animeID, _ := strconv.ParseInt(c.Param("id"), 10, 64) if animeID <= 0 { @@ -81,7 +94,12 @@ func (h *WatchlistHandler) HandleDeleteContinueWatching(c *gin.Context) { } func (h *WatchlistHandler) HandleGetWatchlist(c *gin.Context) { - userID := "" // TODO: get from auth context + user, _ := c.Get("User") + userID := "" + if u, ok := user.(*domain.User); ok { + userID = u.ID + } + entries, err := h.svc.GetWatchlist(c.Request.Context(), userID) if err != nil { c.Status(http.StatusInternalServerError) @@ -91,5 +109,6 @@ func (h *WatchlistHandler) HandleGetWatchlist(c *gin.Context) { c.HTML(http.StatusOK, "watchlist.gohtml", gin.H{ "Entries": entries, "CurrentPath": "/watchlist", + "User": user, }) } diff --git a/internal/watchlist/service/service.go b/internal/watchlist/service/service.go index 09a0c03..5547fc6 100644 --- a/internal/watchlist/service/service.go +++ b/internal/watchlist/service/service.go @@ -3,7 +3,6 @@ package service import ( "context" "database/sql" - "fmt" "mal/integrations/jikan" "mal/internal/db" "mal/internal/domain" diff --git a/static/assets/style.css b/static/assets/style.css index 51980bd..5520eb5 100644 --- a/static/assets/style.css +++ b/static/assets/style.css @@ -2,8 +2,8 @@ @import url('https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,400;9..40,500;9..40,600;9..40,700&display=swap'); @import '@toolwind/anchors'; -@source "."; -@source "../web/**/*.templ"; +@source "../../templates/**/*.gohtml"; +@source "../**/*.ts"; @theme { --color-background: light-dark(#ffffff, #080808); diff --git a/templates/anime.gohtml b/templates/anime.gohtml index 0236b7f..1069a8e 100644 --- a/templates/anime.gohtml +++ b/templates/anime.gohtml @@ -2,7 +2,7 @@

Characters & Cast

- {{range (slice . 0 (min (len .) 10))}} + {{range (slice .Items 0 (min (len .Items) 10))}}
{{.Character.Name}} @@ -21,11 +21,11 @@ {{end}} {{define "anime_recommendations"}} -{{if .}} +{{if .Items}}

Recommendations

- {{range (slice . 0 (min (len .) 8))}} + {{range (slice .Items 0 (min (len .Items) 8))}}
{{.Entry.Title}} @@ -43,7 +43,7 @@ {{if .WatchlistIDs}}{{end}} {{$anime := .Anime}} -
+
diff --git a/templates/base.gohtml b/templates/base.gohtml index bbc9f42..61201d9 100644 --- a/templates/base.gohtml +++ b/templates/base.gohtml @@ -33,7 +33,7 @@ html[data-theme="light"] .theme-icon-light { display: none; } html[data-theme="light"] .theme-icon-dark { display: block; } - + - - - - + + + + - - - - + + + + - - - - - - - - - - + + + + + + + + + + +
From b3c906a16e5b6fc8a2bc426cce42dd06ea6de2a1 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Wed, 13 May 2026 19:05:10 +0200 Subject: [PATCH 67/71] fix: centralize watchlist dropdown js and fix page load timing --- internal/playback/service/service.go | 3 + templates/base.gohtml | 71 +++++++++++++++++-- templates/components/watchlist_actions.gohtml | 65 +---------------- templates/watch.gohtml | 66 ----------------- 4 files changed, 71 insertions(+), 134 deletions(-) diff --git a/internal/playback/service/service.go b/internal/playback/service/service.go index 9913627..be847a6 100644 --- a/internal/playback/service/service.go +++ b/internal/playback/service/service.go @@ -188,6 +188,7 @@ func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, title // 3. Get start time from progress startTime := 0.0 var watchlistStatus string + var watchlistIDs []int64 if userID != "" { entry, err := s.repo.GetWatchListEntry(ctx, db.GetWatchListEntryParams{ UserID: userID, @@ -195,6 +196,7 @@ func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, title }) if err == nil { watchlistStatus = entry.Status + watchlistIDs = []int64{entry.AnimeID} if entry.CurrentEpisode.Valid && strconv.FormatInt(entry.CurrentEpisode.Int64, 10) == episode { startTime = entry.CurrentTimeSeconds } @@ -318,6 +320,7 @@ func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, title "Episodes": domainEpisodes, "CurrentEpID": episode, "WatchlistStatus": watchlistStatus, + "WatchlistIDs": watchlistIDs, "Seasons": seasons, }, nil } diff --git a/templates/base.gohtml b/templates/base.gohtml index 9c1c869..b80cb9c 100644 --- a/templates/base.gohtml +++ b/templates/base.gohtml @@ -109,7 +109,20 @@ const watchlistIds = new Set() function initWatchlist(ids) { - ids.forEach(id => watchlistIds.add(id)) + ids.forEach(id => watchlistIds.add(id)); + const sync = () => ids.forEach(id => syncRemoveButtonVisibility(id)); + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', sync); + } else { + sync(); + } + } + + function syncRemoveButtonVisibility(id) { + const container = document.getElementById('remove-watchlist-container-' + id); + if (container) { + container.classList.toggle('hidden', !watchlistIds.has(id)); + } } function toggleWatchlist(id, btn) { @@ -169,10 +182,7 @@ if (window.showToast) showToast({ message: 'Something went wrong' }) const statusDisplay = document.getElementById('watchlist-status-display-' + id) if (statusDisplay) { statusDisplay.textContent = inWatchlist ? 'Plan to Watch' : 'Add to Watchlist' - const removeContainer = document.getElementById('remove-watchlist-container-' + id) - if (removeContainer) { - removeContainer.classList.toggle('hidden', !inWatchlist) - } + syncRemoveButtonVisibility(id) } } @@ -186,6 +196,57 @@ if (window.showToast) showToast({ message: 'Something went wrong' }) } }) } + + function updateWatchlist(id, status, display, btn) { + fetch('/api/watchlist', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ animeId: id, status: status }) + }).then(res => { + if (res.ok) { + watchlistIds.add(id); + document.getElementById('watchlist-status-display-' + id).textContent = display; + syncRemoveButtonVisibility(id); + document.querySelectorAll('.watchlist-icon').forEach(function(icon) { + const button = icon.closest('button'); + if (button) { + const malId = button.dataset.malId; + if (malId && parseInt(malId) === id) { + button.classList.add('in-watchlist'); + } + } + }); + requestAnimationFrame(() => { + const dropdown = btn.closest('ui-dropdown'); + if (dropdown) dropdown.close(); + }); + } + }); + } + + function removeWatchlist(id, btn) { + fetch('/api/watchlist/' + id, { method: 'DELETE' }).then(res => { + if (res.ok) { + watchlistIds.delete(id); + document.getElementById('watchlist-status-display-' + id).textContent = 'Add to Watchlist'; + syncRemoveButtonVisibility(id); + if (window.showToast) showToast({ message: 'Removed from watchlist' }); + document.querySelectorAll('.watchlist-icon').forEach(function(icon) { + const button = icon.closest('button'); + if (button) { + const malId = button.dataset.malId; + if (malId && parseInt(malId) === id) { + button.classList.remove('in-watchlist'); + } + } + }); + if (btn) { + const dropdown = btn.closest('ui-dropdown'); + if (dropdown) dropdown.close(); + } + } + }); + } diff --git a/templates/components/watchlist_actions.gohtml b/templates/components/watchlist_actions.gohtml index b725ba1..81274f7 100644 --- a/templates/components/watchlist_actions.gohtml +++ b/templates/components/watchlist_actions.gohtml @@ -38,7 +38,7 @@
-
@@ -50,65 +50,4 @@ {{if and .ContinueWatchingEp (ne .ContinueWatchingEp 1)}}Continue Episode {{.ContinueWatchingEp}}{{else}}Watch Now{{end}}
- - -{{end}} +{{end}} \ No newline at end of file diff --git a/templates/watch.gohtml b/templates/watch.gohtml index d0294c1..7a5e54d 100644 --- a/templates/watch.gohtml +++ b/templates/watch.gohtml @@ -164,72 +164,6 @@ {{end}} {{end}} -
{{end}} From 4f6b5340938e039b674a3b4f65933be961e87d22 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Wed, 13 May 2026 19:08:13 +0200 Subject: [PATCH 68/71] refactor: extract watchlist remove button into shared component --- templates/components/watchlist_actions.gohtml | 22 ++++++++++++++----- templates/watch.gohtml | 13 ++++++----- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/templates/components/watchlist_actions.gohtml b/templates/components/watchlist_actions.gohtml index 81274f7..549d90e 100644 --- a/templates/components/watchlist_actions.gohtml +++ b/templates/components/watchlist_actions.gohtml @@ -36,12 +36,13 @@ Dropped -
-
- -
+ {{template "watchlist_remove_button" dict + "ID" $anime.MalID + "ContainerClass" "hidden" + "DividerClass" "my-1 h-px bg-border" + "ButtonClass" "flex w-full items-center px-5 py-2.5 transition-colors focus:outline-none hover:bg-red-500/10 focus:bg-red-500/10" + "SpanClass" "font-medium text-sm text-red-500 text-left whitespace-nowrap" + }}
@@ -50,4 +51,13 @@ {{if and .ContinueWatchingEp (ne .ContinueWatchingEp 1)}}Continue Episode {{.ContinueWatchingEp}}{{else}}Watch Now{{end}}
+{{end}} + +{{define "watchlist_remove_button"}} +
+
+ +
{{end}} \ No newline at end of file diff --git a/templates/watch.gohtml b/templates/watch.gohtml index 7a5e54d..52007b3 100644 --- a/templates/watch.gohtml +++ b/templates/watch.gohtml @@ -37,12 +37,13 @@ - + {{template "watchlist_remove_button" dict + "ID" $anime.MalID + "ContainerClass" "hidden" + "DividerClass" "border-t border-border my-1" + "ButtonClass" "flex w-full items-center px-5 py-2.5 transition-colors focus:outline-none hover:bg-red-500/10" + "SpanClass" "text-sm text-red-400 whitespace-nowrap" + }}
From e675f125d4a1edfc4597343fdffbce783a8b1c04 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Wed, 13 May 2026 20:55:45 +0200 Subject: [PATCH 69/71] fix: replace revealed sentinel with intersect once for infinite scroll --- templates/browse.gohtml | 45 +++++++++++++++++------------------------ 1 file changed, 18 insertions(+), 27 deletions(-) diff --git a/templates/browse.gohtml b/templates/browse.gohtml index 4a2d10b..bb38b52 100644 --- a/templates/browse.gohtml +++ b/templates/browse.gohtml @@ -20,19 +20,11 @@
{{else}}
- {{range $i, $anime := .Animes}} - {{$isThreshold := eq (add $i 1) (sub (len $.Animes) 8)}} - {{if and $isThreshold $.HasNextPage}} -
- {{template "anime_card" dict "Anime" . "WithActions" true "IsWatchlist" (index $.WatchlistMap .MalID)}} -
- {{else}} - {{template "anime_card" dict "Anime" . "WithActions" true "IsWatchlist" (index $.WatchlistMap .MalID)}} - {{end}} + {{range .Animes}} + {{template "anime_card" dict "Anime" . "WithActions" true "IsWatchlist" (index $.WatchlistMap .MalID)}} + {{end}} + {{if .HasNextPage}} + {{template "browse_sentinel" .}} {{end}}
{{end}} @@ -41,19 +33,18 @@ {{end}} {{define "anime_card_scroll"}} - {{$count := len .Animes}} - {{range $i, $anime := .Animes}} - {{$isThreshold := eq (add $i 1) (sub $count 8)}} - {{if and $isThreshold $.HasNextPage}} -
- {{template "anime_card" dict "Anime" $anime "WithActions" true "IsWatchlist" (index $.WatchlistMap $anime.MalID)}} -
- {{else}} - {{template "anime_card" dict "Anime" $anime "WithActions" true "IsWatchlist" (index $.WatchlistMap $anime.MalID)}} - {{end}} + {{range .Animes}} + {{template "anime_card" dict "Anime" . "WithActions" true "IsWatchlist" (index $.WatchlistMap .MalID)}} + {{end}} + {{if .HasNextPage}} + {{template "browse_sentinel" .}} {{end}} {{end}} + +{{define "browse_sentinel"}} +
+{{end}} From 2619dc2c94b3378ba5938d6c438e90c0a3c17014 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Wed, 13 May 2026 23:48:09 +0200 Subject: [PATCH 70/71] fix: autoplay video instantly on watch page load --- static/player/controls.ts | 4 ++-- static/player/main.ts | 17 +++++++++++++---- templates/components/video_player.gohtml | 22 +++++++++++++++++++++- 3 files changed, 36 insertions(+), 7 deletions(-) diff --git a/static/player/controls.ts b/static/player/controls.ts index d228f22..f9e6df6 100644 --- a/static/player/controls.ts +++ b/static/player/controls.ts @@ -209,7 +209,7 @@ export const setupControls = (): void => { // mouse move in container shows controls state.container.addEventListener('mousemove', showControls); - // initial sync - updatePlayPauseIcons(false); + // initial sync — check actual video state since inline script may have started playback + updatePlayPauseIcons(!state.video.paused); syncVolumeUI(); }; diff --git a/static/player/main.ts b/static/player/main.ts index 7f00bd1..aac57ed 100644 --- a/static/player/main.ts +++ b/static/player/main.ts @@ -66,9 +66,10 @@ const initPlayer = (): void => { const progressWrap = container.querySelector('[data-progress-wrap]') as HTMLElement | null; // build video src from mode, token, and saved quality preference + // Only set if not already provided by the inline script during HTML parsing const preferredQuality = localStorage.getItem('mal:preferred-quality') || 'best'; const streamToken = state.modeSources[state.currentMode]?.token; - if (streamToken) { + if (!state.video.src && streamToken) { state.video.src = `${state.streamURL}?mode=${encodeURIComponent(state.currentMode)}&token=${encodeURIComponent(streamToken)}${preferredQuality !== 'best' ? `&quality=${encodeURIComponent(preferredQuality)}` : ''}`; } @@ -87,7 +88,7 @@ const initPlayer = (): void => { updateAutoSkipButton(); showControls(); - state.video.addEventListener('loadedmetadata', () => { + const onLoadedMetadata = (): void => { loading && (loading.style.display = 'none'); invalidateBounds(); @@ -104,11 +105,19 @@ const initPlayer = (): void => { state.video.currentTime = state.pendingSeekTime; state.pendingSeekTime = null; } - if (state.shouldAutoPlay) state.video.play().catch(() => {}); + // autoplay if not already playing (inline script may have already called play()) + if (state.shouldAutoPlay || state.video.paused) state.video.play().catch(() => {}); updateTimeline(state.video.currentTime); updateSkipButton(state.video.currentTime); - }); + }; + + state.video.addEventListener('loadedmetadata', onLoadedMetadata); + // inline script runs during HTML parsing before initPlayer; if metadata + // already loaded, fire the handler immediately + if (state.video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) { + onLoadedMetadata(); + } state.video.addEventListener('waiting', () => { loading && (loading.style.display = 'flex'); diff --git a/templates/components/video_player.gohtml b/templates/components/video_player.gohtml index 68c0b5a..7f792e2 100644 --- a/templates/components/video_player.gohtml +++ b/templates/components/video_player.gohtml @@ -14,7 +14,27 @@ class="group relative aspect-video w-full overflow-hidden bg-black"> - + +