Compare commits

...

13 Commits

Author SHA1 Message Date
b31ef97204 chore: tidy go dependencies 2026-05-14 12:42:17 +02:00
596e8265f7 refactor: remove noisy debug logging 2026-05-14 12:41:41 +02:00
eb173ac072 chore: formatting 2026-05-14 12:30:06 +02:00
2619dc2c94 fix: autoplay video instantly on watch page load 2026-05-13 23:48:09 +02:00
e675f125d4 fix: replace revealed sentinel with intersect once for infinite scroll 2026-05-13 20:55:45 +02:00
4f6b534093 refactor: extract watchlist remove button into shared component 2026-05-13 19:08:13 +02:00
b3c906a16e fix: centralize watchlist dropdown js and fix page load timing 2026-05-13 19:05:10 +02:00
950e143faf fix: clean up completion flow and watch page dropdown 2026-05-13 18:44:08 +02:00
efbce87d5c feat: set status to completed on anime completion
Check existing watchlist status — if already completed, just clear
progress and remove from continue watching. Otherwise upsert with
status 'completed'.
2026-05-13 18:28:33 +02:00
28e8b322d0 feat: add watch-complete endpoint
Removes continue_watching_entry and clears progress when the last
episode finishes so it no longer shows in Continue Watching.
2026-05-13 18:22:18 +02:00
6c45a80623 fix: pass watchlist status to anime detail page
Anime detail page never looked up or passed the user's watchlist
status, so the dropdown always showed 'Add to Watchlist'. Now
queries watch_list_entry and passes Status and WatchlistIDs.
2026-05-13 18:18:22 +02:00
413ee70923 feat: use saved progress for watch button on anime page
Check continue_watching_entry to find the episode to resume from.
Show 'Continue Episode N' instead of 'Watch Now' when progress exists.
2026-05-13 18:16:25 +02:00
851c9d701f feat: link continue watching cards to saved episode
Include ?ep=N in the watch links so clicking a continue watching
card loads the correct episode and resumes from saved progress.
2026-05-13 18:16:19 +02:00
23 changed files with 297 additions and 273 deletions

9
go.mod
View File

@@ -12,7 +12,11 @@ require (
golang.org/x/net v0.53.0
)
require github.com/hashicorp/golang-lru/v2 v2.0.7
require (
github.com/gin-gonic/gin v1.12.0
github.com/pressly/goose/v3 v3.27.1
go.uber.org/fx v1.24.0
)
require (
github.com/bytedance/gopkg v0.1.3 // indirect
@@ -21,7 +25,6 @@ require (
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
@@ -35,7 +38,6 @@ require (
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
@@ -43,7 +45,6 @@ require (
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

35
go.sum
View File

@@ -1,7 +1,5 @@
github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
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=
@@ -15,13 +13,18 @@ github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCc
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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
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/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
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=
@@ -33,17 +36,15 @@ github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PU
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/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
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=
@@ -61,8 +62,11 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
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/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
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=
@@ -72,6 +76,8 @@ github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SA
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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
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=
@@ -83,10 +89,14 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
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/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
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/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
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=
@@ -94,6 +104,10 @@ 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/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
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=
@@ -177,4 +191,13 @@ google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBN
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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/libc v1.72.1 h1:db1xwJ6u1kE3KHTFTTbe2GCrczHPKzlURP0aDC4NGD0=
modernc.org/libc v1.72.1/go.mod h1:HRMiC/PhPGLIPM7GzAFCbI+oSgE3dhZ8FWftmRrHVlY=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U=
modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=

View File

@@ -5,7 +5,6 @@ import (
"encoding/json"
"errors"
"fmt"
"log"
"net"
"net/http"
"strconv"
@@ -257,7 +256,6 @@ func (c *Client) getWithCache(ctx context.Context, cacheKey string, ttl time.Dur
if !isEmptyResult(out) {
return nil
}
log.Printf("jikan: cache hit for %s but data is empty, refetching", cacheKey)
}
var stale any
@@ -273,7 +271,6 @@ func (c *Client) getWithCache(ctx context.Context, cacheKey string, ttl time.Dur
}
}
if !errors.Is(err, context.Canceled) {
log.Printf("jikan: stale cache unmarshal failed or empty, falling back to error: %v", err)
}
}
return err
@@ -281,7 +278,6 @@ func (c *Client) getWithCache(ctx context.Context, cacheKey string, ttl time.Dur
// Don't cache empty results to avoid caching failures
if isEmptyResult(out) {
log.Printf("jikan: fetched data for %s is empty, not caching", cacheKey)
return fmt.Errorf("jikan: empty response for %s", cacheKey)
}

View File

@@ -143,7 +143,6 @@ func (c *Client) GetFullRelations(ctx context.Context, id int) ([]RelationEntry,
return nil
}
c.EnqueueAnimeFetchRetry(gCtx, entry.ID, err)
log.Printf("relations: skipping related anime %d for root %d: %v", entry.ID, id, err)
return nil
}
select {

View File

@@ -2,7 +2,6 @@ package handler
import (
"fmt"
"log"
"mal/internal/domain"
"net/http"
"strconv"
@@ -23,7 +22,7 @@ func NewAnimeHandler(svc domain.AnimeService, watchlistSvc domain.WatchlistServi
}
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)
@@ -76,7 +75,6 @@ func (h *AnimeHandler) renderCatalogSection(c *gin.Context, section string) {
}
data, err := h.svc.GetCatalogSection(c.Request.Context(), userID, section)
if err != nil {
log.Printf("catalog %s error: %v", section, err)
return
}
@@ -122,7 +120,6 @@ func (h *AnimeHandler) renderDiscoverSection(c *gin.Context, section string) {
}
data, err := h.svc.GetDiscoverSection(c.Request.Context(), userID, section)
if err != nil {
log.Printf("discover %s error: %v", section, err)
return
}
@@ -163,7 +160,6 @@ func (h *AnimeHandler) HandleBrowse(c *gin.Context) {
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)
}
user, _ := c.Get("User")
@@ -253,10 +249,32 @@ func (h *AnimeHandler) HandleAnimeDetails(c *gin.Context) {
}
user, _ := c.Get("User")
status := ""
var watchlistIDs []int64
ep := 1
var cwSeconds float64
if u, ok := user.(*domain.User); ok {
entry, err := h.watchlistSvc.GetWatchListEntry(c.Request.Context(), u.ID, int64(id))
if err == nil {
status = entry.Status
watchlistIDs = []int64{entry.AnimeID}
}
cwEntry, err := h.watchlistSvc.GetContinueWatchingEntry(c.Request.Context(), u.ID, int64(id))
if err == nil && cwEntry.CurrentEpisode.Valid {
ep = int(cwEntry.CurrentEpisode.Int64)
cwSeconds = cwEntry.CurrentTimeSeconds
}
}
c.HTML(http.StatusOK, "anime.gohtml", gin.H{
"Anime": anime,
"CurrentPath": fmt.Sprintf("/anime/%d", id),
"User": user,
"Anime": anime,
"CurrentPath": fmt.Sprintf("/anime/%d", id),
"User": user,
"Status": status,
"WatchlistIDs": watchlistIDs,
"ContinueWatchingEp": ep,
"ContinueWatchingTime": cwSeconds,
})
}

View File

@@ -10,9 +10,9 @@ import (
func AuthMiddleware(svc domain.AuthService) gin.HandlerFunc {
return func(c *gin.Context) {
// Allow access to login, logout and static assets without authentication
if c.Request.URL.Path == "/login" || c.Request.URL.Path == "/logout" ||
len(c.Request.URL.Path) >= 7 && c.Request.URL.Path[:7] == "/static" ||
len(c.Request.URL.Path) >= 5 && c.Request.URL.Path[:5] == "/dist" {
if c.Request.URL.Path == "/login" || c.Request.URL.Path == "/logout" ||
len(c.Request.URL.Path) >= 7 && c.Request.URL.Path[:7] == "/static" ||
len(c.Request.URL.Path) >= 5 && c.Request.URL.Path[:5] == "/dist" {
c.Next()
return
}

View File

@@ -8,6 +8,7 @@ import (
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
CompleteAnime(ctx context.Context, userID string, animeID int64) error
ResolveProxyToken(token string) (string, string, error)
}
@@ -34,5 +35,7 @@ type PlaybackRepository interface {
GetWatchListEntry(ctx context.Context, params db.GetWatchListEntryParams) (db.WatchListEntry, error)
GetContinueWatchingEntry(ctx context.Context, params db.GetContinueWatchingEntryParams) (db.ContinueWatchingEntry, error)
SaveWatchProgress(ctx context.Context, params db.SaveWatchProgressParams) error
UpsertWatchListEntry(ctx context.Context, params db.UpsertWatchListEntryParams) (db.WatchListEntry, error)
UpsertContinueWatchingEntry(ctx context.Context, params db.UpsertContinueWatchingEntryParams) (db.ContinueWatchingEntry, error)
DeleteContinueWatchingEntry(ctx context.Context, params db.DeleteContinueWatchingEntryParams) error
}

View File

@@ -12,6 +12,7 @@ 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)
GetWatchListEntry(ctx context.Context, userID string, animeID int64) (WatchlistEntry, error)
GetContinueWatchingEntry(ctx context.Context, userID string, animeID int64) (db.ContinueWatchingEntry, error)
DeleteContinueWatching(ctx context.Context, userID string, animeID int64) error
}
@@ -22,6 +23,7 @@ type WatchlistRepository interface {
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)
GetWatchListEntry(ctx context.Context, arg db.GetWatchListEntryParams) (db.WatchListEntry, error)
GetContinueWatchingEntry(ctx context.Context, arg db.GetContinueWatchingEntryParams) (db.ContinueWatchingEntry, error)
DeleteContinueWatchingEntry(ctx context.Context, arg db.DeleteContinueWatchingEntryParams) error
SaveWatchProgress(ctx context.Context, arg db.SaveWatchProgressParams) error

View File

@@ -3,7 +3,6 @@ package handler
import (
"fmt"
"io"
"log"
"mal/internal/domain"
"mal/pkg/net/proxytransport"
"net/http"
@@ -33,16 +32,16 @@ func NewPlaybackHandler(svc domain.PlaybackService, animeSvc domain.AnimeService
}
func (h *PlaybackHandler) Register(r *gin.Engine) {
log.Println("Registering playback routes")
r.GET("/anime/:id/watch", h.HandleWatchPage)
r.POST("/api/watch-progress", h.HandleSaveProgress)
r.POST("/api/watch-complete", h.HandleWatchComplete)
r.GET("/api/watch/thumbnails/:animeId", h.HandleEpisodeThumbnails)
r.GET("/watch/proxy/stream", h.HandleProxyStream)
r.GET("/watch/proxy/subtitle", h.HandleProxySubtitle)
}
func (h *PlaybackHandler) HandleWatchPage(c *gin.Context) {
log.Printf("Route /anime/:id/watch triggered for ID: %s", c.Param("id"))
id, _ := strconv.Atoi(c.Param("id"))
ep := c.DefaultQuery("ep", "1")
mode := c.DefaultQuery("mode", "sub")
@@ -55,8 +54,6 @@ func (h *PlaybackHandler) HandleWatchPage(c *gin.Context) {
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(),
@@ -69,7 +66,6 @@ func (h *PlaybackHandler) HandleWatchPage(c *gin.Context) {
})
return
}
log.Printf("BuildWatchData succeeded for ID %d", id)
// Merge data from service with handler-specific context
responseData := gin.H{
@@ -81,7 +77,6 @@ func (h *PlaybackHandler) HandleWatchPage(c *gin.Context) {
}
c.HTML(http.StatusOK, "watch.gohtml", responseData)
log.Printf("c.HTML finished for ID %d", id)
}
func (h *PlaybackHandler) HandleSaveProgress(c *gin.Context) {
@@ -111,6 +106,32 @@ func (h *PlaybackHandler) HandleSaveProgress(c *gin.Context) {
c.Status(http.StatusOK)
}
func (h *PlaybackHandler) HandleWatchComplete(c *gin.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"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.Status(http.StatusBadRequest)
return
}
err := h.svc.CompleteAnime(c.Request.Context(), userID, req.MalID)
if err != nil {
c.Status(http.StatusInternalServerError)
return
}
c.Status(http.StatusOK)
}
func (h *PlaybackHandler) HandleEpisodeThumbnails(c *gin.Context) {
id, err := strconv.Atoi(c.Param("animeId"))
if err != nil {
@@ -120,7 +141,6 @@ func (h *PlaybackHandler) HandleEpisodeThumbnails(c *gin.Context) {
allEpisodes, err := h.animeSvc.GetAllEpisodes(c.Request.Context(), id)
if err != nil {
log.Printf("failed to fetch thumbnails/episodes: %v", err)
}
anime, _ := h.animeSvc.GetAnimeByID(c.Request.Context(), id)
@@ -168,7 +188,6 @@ func (h *PlaybackHandler) HandleProxyStream(c *gin.Context) {
targetURL, referer, err := h.svc.ResolveProxyToken(token)
if err != nil {
log.Printf("proxy token error: %v", err)
c.Status(http.StatusForbidden)
return
}
@@ -185,7 +204,6 @@ func (h *PlaybackHandler) HandleProxyStream(c *gin.Context) {
resp, err := h.streamingClient.Do(req)
if err != nil {
log.Printf("proxy stream fetch error: %v", err)
c.Status(http.StatusBadGateway)
return
}
@@ -212,7 +230,6 @@ func (h *PlaybackHandler) HandleProxySubtitle(c *gin.Context) {
targetURL, referer, err := h.svc.ResolveProxyToken(token)
if err != nil {
log.Printf("proxy subtitle token error: %v", err)
c.Status(http.StatusForbidden)
return
}
@@ -235,7 +252,6 @@ func (h *PlaybackHandler) HandleProxySubtitle(c *gin.Context) {
resp, err := h.proxyClient.Do(req)
if err != nil {
log.Printf("proxy subtitle fetch error: %v", err)
c.Status(http.StatusBadGateway)
return
}
@@ -243,7 +259,6 @@ func (h *PlaybackHandler) HandleProxySubtitle(c *gin.Context) {
body, err := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024))
if err != nil {
log.Printf("proxy subtitle read error: %v", err)
c.Status(http.StatusBadGateway)
return
}

View File

@@ -26,6 +26,14 @@ func (r *playbackRepository) SaveWatchProgress(ctx context.Context, params db.Sa
return r.queries.SaveWatchProgress(ctx, params)
}
func (r *playbackRepository) UpsertWatchListEntry(ctx context.Context, params db.UpsertWatchListEntryParams) (db.WatchListEntry, error) {
return r.queries.UpsertWatchListEntry(ctx, params)
}
func (r *playbackRepository) UpsertContinueWatchingEntry(ctx context.Context, params db.UpsertContinueWatchingEntryParams) (db.ContinueWatchingEntry, error) {
return r.queries.UpsertContinueWatchingEntry(ctx, params)
}
func (r *playbackRepository) DeleteContinueWatchingEntry(ctx context.Context, params db.DeleteContinueWatchingEntryParams) error {
return r.queries.DeleteContinueWatchingEntry(ctx, params)
}

View File

@@ -9,7 +9,6 @@ import (
"encoding/json"
"fmt"
"io"
"log"
"mal/integrations/jikan"
"mal/internal/db"
"mal/internal/domain"
@@ -188,6 +187,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 +195,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
}
@@ -215,7 +216,6 @@ func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, title
// 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
@@ -318,10 +318,42 @@ func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, title
"Episodes": domainEpisodes,
"CurrentEpID": episode,
"WatchlistStatus": watchlistStatus,
"WatchlistIDs": watchlistIDs,
"Seasons": seasons,
}, nil
}
func (s *playbackService) CompleteAnime(ctx context.Context, userID string, animeID int64) error {
entry, err := s.repo.GetWatchListEntry(ctx, db.GetWatchListEntryParams{
UserID: userID,
AnimeID: animeID,
})
if err != nil || entry.Status != "completed" {
_, err = s.repo.UpsertWatchListEntry(ctx, db.UpsertWatchListEntryParams{
ID: uuid.New().String(),
UserID: userID,
AnimeID: animeID,
Status: "completed",
CurrentEpisode: sql.NullInt64{Valid: false},
CurrentTimeSeconds: 0,
})
if err != nil {
return err
}
}
_ = 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,
})
}
func (s *playbackService) SaveProgress(ctx context.Context, userID string, animeID int64, episode int, timeSeconds float64) error {
_, err := s.repo.UpsertContinueWatchingEntry(ctx, db.UpsertContinueWatchingEntryParams{
ID: uuid.New().String(),

View File

@@ -34,6 +34,10 @@ func (r *watchlistRepository) GetUserWatchList(ctx context.Context, userID strin
return r.queries.GetUserWatchList(ctx, userID)
}
func (r *watchlistRepository) GetWatchListEntry(ctx context.Context, arg db.GetWatchListEntryParams) (db.WatchListEntry, error) {
return r.queries.GetWatchListEntry(ctx, arg)
}
func (r *watchlistRepository) GetContinueWatchingEntry(ctx context.Context, arg db.GetContinueWatchingEntryParams) (db.ContinueWatchingEntry, error) {
return r.queries.GetContinueWatchingEntry(ctx, arg)
}

View File

@@ -55,6 +55,13 @@ func (s *watchlistService) GetWatchlist(ctx context.Context, userID string) ([]d
return s.repo.GetUserWatchList(ctx, userID)
}
func (s *watchlistService) GetWatchListEntry(ctx context.Context, userID string, animeID int64) (db.WatchListEntry, error) {
return s.repo.GetWatchListEntry(ctx, db.GetWatchListEntryParams{
UserID: userID,
AnimeID: animeID,
})
}
func (s *watchlistService) GetContinueWatchingEntry(ctx context.Context, userID string, animeID int64) (db.ContinueWatchingEntry, error) {
return s.repo.GetContinueWatchingEntry(ctx, db.GetContinueWatchingEntryParams{
UserID: userID,

View File

@@ -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();
};

View File

@@ -1,11 +1,5 @@
import DOMPurify from 'dompurify';
import { state } from '../state';
/**
* Marks anime as completed when final episode finishes.
* Calls completion API, updates dropdown UI, adds to watchlist.
* Retries up to 2 times on failure.
*/
export const completeAnime = async (episodeNumber: number): Promise<void> => {
if (state.completionSent || !state.malID || !episodeNumber) return;
state.completionSent = true;
@@ -20,7 +14,6 @@ export const completeAnime = async (episodeNumber: number): Promise<void> => {
if (!res.ok) {
state.completionSent = false;
// retry
if (state.completionAttempts < 2) {
state.completionAttempts++;
setTimeout(() => completeAnime(episodeNumber), 1000);
@@ -28,7 +21,6 @@ export const completeAnime = async (episodeNumber: number): Promise<void> => {
return;
}
// update dropdown trigger text
const trigger = document.querySelector('[data-dropdown-trigger]') as HTMLButtonElement | null;
if (trigger) {
trigger.textContent = 'Completed ';
@@ -37,37 +29,6 @@ export const completeAnime = async (episodeNumber: number): Promise<void> => {
caret.textContent = '▾';
trigger.appendChild(caret);
}
// add to watchlist with 'completed' status
const dropdown = document.getElementById('watch-status-dropdown');
if (dropdown) {
const payload = {
anime_id: String(state.malID),
anime_title: state.container.dataset.animeTitle ?? '',
anime_title_english: state.container.dataset.animeTitleEnglish ?? '',
anime_title_japanese: state.container.dataset.animeTitleJapanese ?? '',
anime_image: state.container.dataset.animeImage ?? '',
status: 'completed',
airing: state.container.dataset.animeAiring === 'true',
};
fetch('/api/watchlist', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'HX-Request': 'true' },
body: `anime_id=${encodeURIComponent(payload.anime_id)}&anime_title=${encodeURIComponent(payload.anime_title)}&anime_title_english=${encodeURIComponent(payload.anime_title_english)}&anime_title_japanese=${encodeURIComponent(payload.anime_title_japanese)}&anime_image=${encodeURIComponent(payload.anime_image)}&status=${encodeURIComponent(payload.status)}&airing=${encodeURIComponent(String(payload.airing))}`,
credentials: 'same-origin',
})
.then(async res => {
if (!res.ok) return;
// replace dropdown with HTMX response
const html = await res.text();
const wrapper = document.createElement('span');
wrapper.id = 'watch-status-dropdown';
wrapper.innerHTML = DOMPurify.sanitize(html);
dropdown.replaceWith(wrapper);
})
.catch(() => {});
}
} catch {
state.completionSent = false;
if (state.completionAttempts < 2) {

View File

@@ -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');

View File

@@ -85,7 +85,7 @@
{{if $anime.ShortRating}}<span class="flex items-center gap-1.5"><span>•</span>{{$anime.ShortRating}}</span>{{end}}
</div>
{{template "watchlist_actions" dict "Anime" $anime "User" .User "Status" .Status}}
{{template "watchlist_actions" dict "Anime" $anime "User" .User "Status" .Status "ContinueWatchingEp" .ContinueWatchingEp "ContinueWatchingTime" .ContinueWatchingTime}}
<div class="flex flex-col gap-12 lg:flex-row">
<div class="grow lg:max-w-4xl">

View File

@@ -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();
}
}
});
}
</script>
</head>
<body class="bg-background text-foreground">

View File

@@ -20,19 +20,11 @@
</div>
{{else}}
<div id="anime-grid" class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6">
{{range $i, $anime := .Animes}}
{{$isThreshold := eq (add $i 1) (sub (len $.Animes) 8)}}
{{if and $isThreshold $.HasNextPage}}
<div hx-get="/browse?q={{$.Query}}&type={{$.Type}}&status={{$.Status}}&order_by={{$.OrderBy}}&sort={{$.Sort}}&sfw={{$.SFW}}&{{genresParams $.Genres}}&page={{$.NextPage}}"
hx-trigger="revealed"
hx-swap="afterend"
hx-target="this"
class="contents">
{{template "anime_card" dict "Anime" . "WithActions" true "IsWatchlist" (index $.WatchlistMap .MalID)}}
</div>
{{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}}
</div>
{{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}}
<div hx-get="/browse?q={{$.Query}}&type={{$.Type}}&status={{$.Status}}&order_by={{$.OrderBy}}&sort={{$.Sort}}&sfw={{$.SFW}}&{{genresParams $.Genres}}&page={{$.NextPage}}"
hx-trigger="revealed"
hx-swap="afterend"
hx-target="this"
class="contents">
{{template "anime_card" dict "Anime" $anime "WithActions" true "IsWatchlist" (index $.WatchlistMap $anime.MalID)}}
</div>
{{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"}}
<div hx-get="/browse?q={{.Query}}&type={{.Type}}&status={{.Status}}&order_by={{.OrderBy}}&sort={{.Sort}}&sfw={{.SFW}}&{{genresParams .Genres}}&page={{.NextPage}}"
hx-trigger="intersect once"
hx-swap="outerHTML"
hx-target="this"
class="col-span-full h-px"></div>
{{end}}

View File

@@ -9,7 +9,7 @@
<div id="continue-watching-{{.AnimeID}}" class="continue-watching-item group relative w-70 shrink-0 snap-start space-y-2 2xl:w-lg">
<div class="bg-background/80 relative aspect-video w-full overflow-hidden">
<a href="/anime/{{.AnimeID}}/watch" class="block h-full w-full">
<a href="/anime/{{.AnimeID}}/watch{{if .CurrentEpisode.Valid}}?ep={{.CurrentEpisode.Int64}}{{end}}" class="block h-full w-full">
<img src="{{if .ImageUrl}}{{.ImageUrl}}{{else}}https://placehold.co/500x500{{end}}" alt="{{$title}}" class="h-full w-full object-cover" />
</a>
@@ -29,7 +29,7 @@
</div>
<div>
<a href="/anime/{{.AnimeID}}/watch" class="block">
<a href="/anime/{{.AnimeID}}/watch{{if .CurrentEpisode.Valid}}?ep={{.CurrentEpisode.Int64}}{{end}}" class="block">
<h3 class="text-foreground truncate text-lg font-normal">
{{$title}}
</h3>

View File

@@ -14,7 +14,27 @@
class="group relative aspect-video w-full overflow-hidden bg-black">
<video class="h-full w-full cursor-pointer" preload="metadata" playsinline autoplay></video>
<video class="h-full w-full cursor-pointer" preload="metadata" playsinline></video>
<script>
(function() {
var p = document.currentScript.closest('[data-video-player]');
var v = p.querySelector('video');
var sources = JSON.parse(p.getAttribute('data-mode-sources') || '{}');
var mode = p.getAttribute('data-initial-mode') || 'dub';
var stored = localStorage.getItem('player-audio-mode');
if (stored && sources[stored] && sources[stored].token) mode = stored;
var src = sources[mode];
if (!src || !src.token) {
for (var k in sources) {
if (sources[k] && sources[k].token) { src = sources[k]; mode = k; break; }
}
}
if (src && src.token) {
v.src = '/watch/proxy/stream?mode=' + encodeURIComponent(mode) + '&token=' + encodeURIComponent(src.token);
v.play().catch(function() {});
}
})();
</script>
<div data-loading class="absolute inset-0 flex items-center justify-center bg-black/60 hidden z-50">
<div class="border-accent size-10 animate-spin rounded-full border-4 border-t-transparent"></div>

View File

@@ -36,80 +36,28 @@
<span class="font-medium text-sm text-foreground">Dropped</span>
</button>
<div id="remove-watchlist-container-{{$anime.MalID}}" class="{{if not $status}}hidden{{end}}">
<div class="my-1 h-px bg-border"></div>
<button class="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" onclick="removeWatchlist({{$anime.MalID}})">
<span class="font-medium text-sm text-red-500 text-left whitespace-nowrap">Remove from Watchlist</span>
</button>
</div>
{{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"
}}
</div>
</div>
</ui-dropdown>
<a href="/anime/{{$anime.MalID}}/watch" class="bg-background-button hover:bg-background-button-hover px-5 py-2.5 text-sm font-medium text-foreground transition-colors">
<i class="fa-solid fa-play mr-2"></i>
Watch Now
<a href="/anime/{{$anime.MalID}}/watch{{if and .ContinueWatchingEp (ne .ContinueWatchingEp 1)}}?ep={{.ContinueWatchingEp}}{{end}}" class="bg-background-button hover:bg-background-button-hover px-5 py-2.5 text-sm font-medium text-foreground transition-colors">
{{if and .ContinueWatchingEp (ne .ContinueWatchingEp 1)}}Continue Episode {{.ContinueWatchingEp}}{{else}}Watch Now{{end}}
</a>
</div>
<script>
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;
document.getElementById('remove-watchlist-container-' + id).classList.remove('hidden');
// Update all watchlist icons on the page
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')
}
}
});
// Close dropdown after a small delay to let click event finish
requestAnimationFrame(() => {
const dropdown = btn.closest('ui-dropdown');
if (dropdown) dropdown.close();
});
}
});
}
function removeWatchlist(id) {
fetch('/api/watchlist/' + id, { method: 'DELETE' }).then(res => {
if (res.ok) {
watchlistIds.delete(id);
document.getElementById('watchlist-status-display-' + id).textContent = 'Add to Watchlist';
document.getElementById('remove-watchlist-container-' + id).classList.add('hidden');
// Update all watchlist icons on the page
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')
}
}
});
// Close dropdown
const btn = document.getElementById('watchlist-status-display-' + id);
if (btn) {
const dropdown = btn.closest('ui-dropdown');
if (dropdown) dropdown.close();
}
}
});
}
</script>
{{end}}
{{define "watchlist_remove_button"}}
<div id="remove-watchlist-container-{{.ID}}" class="{{.ContainerClass}}">
<div class="{{.DividerClass}}"></div>
<button class="{{.ButtonClass}}" onclick="removeWatchlist({{.ID}}, this)">
<span class="{{.SpanClass}}">Remove from Watchlist</span>
</button>
</div>
{{end}}

View File

@@ -24,7 +24,6 @@
</button>
</div>
<div data-content class="hidden absolute z-50 min-w-40 bg-background-button shadow-2xl right-0 top-full mt-2">
{{if .WatchlistStatus}}
<div class="flex flex-col py-1">
<button class="flex w-full items-center px-5 py-2.5 transition-colors focus:outline-none hover:bg-surface-hover" onclick="updateWatchlist({{$anime.MalID}}, 'watching', 'Watching', this)">
<span class="text-sm text-foreground">Watching</span>
@@ -38,27 +37,14 @@
<button class="flex w-full items-center px-5 py-2.5 transition-colors focus:outline-none hover:bg-surface-hover" onclick="updateWatchlist({{$anime.MalID}}, 'dropped', 'Dropped', this)">
<span class="text-sm text-foreground">Dropped</span>
</button>
<div class="border-t border-border my-1"></div>
<button class="flex w-full items-center px-5 py-2.5 transition-colors focus:outline-none hover:bg-red-500/10" onclick="removeWatchlist({{$anime.MalID}}, this)">
<span class="text-sm text-red-400 whitespace-nowrap">Remove from Watchlist</span>
</button>
{{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"
}}
</div>
{{else}}
<div class="flex flex-col py-1">
<button class="flex w-full items-center px-5 py-2.5 transition-colors focus:outline-none hover:bg-surface-hover" onclick="updateWatchlist({{$anime.MalID}}, 'watching', 'Watching', this)">
<span class="text-sm text-foreground">Watching</span>
</button>
<button class="flex w-full items-center px-5 py-2.5 transition-colors focus:outline-none hover:bg-surface-hover" onclick="updateWatchlist({{$anime.MalID}}, 'completed', 'Completed', this)">
<span class="text-sm text-foreground">Completed</span>
</button>
<button class="flex w-full items-center px-5 py-2.5 transition-colors focus:outline-none hover:bg-surface-hover" onclick="updateWatchlist({{$anime.MalID}}, 'plan_to_watch', 'Plan to Watch', this)">
<span class="text-sm text-foreground">Plan to Watch</span>
</button>
<button class="flex w-full items-center px-5 py-2.5 transition-colors focus:outline-none hover:bg-surface-hover" onclick="updateWatchlist({{$anime.MalID}}, 'dropped', 'Dropped', this)">
<span class="text-sm text-foreground">Dropped</span>
</button>
</div>
{{end}}
</div>
</ui-dropdown>
</div>
@@ -179,66 +165,6 @@
{{end}}
{{end}}
<script>
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;
// Update all watchlist icons on the page
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')
}
}
});
// Close dropdown after a small delay to let click event finish
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';
if (window.showToast) showToast({ message: 'Removed from watchlist' });
// Update all watchlist icons on the page
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')
}
}
});
// Close dropdown
if (btn) {
const dropdown = btn.closest('ui-dropdown');
if (dropdown) dropdown.close();
}
}
});
}
</script>
</div>
</div>
{{end}}