feat: torrent streaming with hls transcoding (#1)

* feat: add ffmpeg for hls streaming

* feat: torrent streaming with hls transcoding

- add nyaa.si torrent search client
- add streaming service using anacrolix/torrent
- add hls transcoding via ffmpeg for browser playback
- add watch page with episode selection
- add socks5 proxy support via TORRENT_PROXY env
- switch to modernc.org/sqlite (pure go, no cgo conflicts)
- update dockerfile with ffmpeg
This commit is contained in:
2026-04-07 13:23:08 +02:00
committed by GitHub
parent 579b194eb9
commit a25e8f1655
27 changed files with 3744 additions and 329 deletions

View File

@@ -1,4 +1,4 @@
FROM golang:1.24-bullseye AS builder
FROM golang:1.24-bookworm AS builder
WORKDIR /app
@@ -10,16 +10,24 @@ RUN go mod download
COPY . .
# Generate templ templates
RUN go run github.com/a-h/templ/cmd/templ@latest generate
# Build the server and the CLI tools
RUN go build -o main_server ./cmd/server
RUN go build -o create_user ./cmd/create-user
FROM debian:bullseye-slim
FROM debian:bookworm-slim
WORKDIR /app
# Required for sqlite
RUN apt-get update && apt-get install -y ca-certificates sqlite3 && rm -rf /var/lib/apt/lists/*
# Install ffmpeg, sqlite and ca-certificates
RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
ca-certificates \
sqlite3 \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Create data directory for sqlite
RUN mkdir -p /app/data

View File

@@ -8,7 +8,7 @@ import (
"log"
"os"
_ "github.com/mattn/go-sqlite3"
_ "modernc.org/sqlite"
"mal/internal/database"
"mal/internal/features/auth"
@@ -30,7 +30,7 @@ func main() {
dbFile = "mal.db"
}
db, err := sql.Open("sqlite3", dbFile)
db, err := sql.Open("sqlite", dbFile)
if err != nil {
log.Fatalf("failed to open db: %v", err)
}

View File

@@ -6,7 +6,7 @@ import (
"net/http"
"os"
_ "github.com/mattn/go-sqlite3"
_ "modernc.org/sqlite"
"mal/internal/database"
"mal/internal/features/auth"
@@ -21,7 +21,7 @@ func main() {
dbFile = "mal.db"
}
db, err := sql.Open("sqlite3", dbFile)
db, err := sql.Open("sqlite", dbFile)
if err != nil {
log.Fatalf("failed to open db: %v", err)
}

BIN
create-user Executable file

Binary file not shown.

93
go.mod
View File

@@ -1,11 +1,98 @@
module mal
go 1.24.0
go 1.25.0
require (
github.com/a-h/templ v0.3.1001
github.com/anacrolix/torrent v1.48.1-0.20230103142631-c20f73d53e9f
github.com/google/uuid v1.6.0
github.com/hashicorp/golang-lru/v2 v2.0.7
github.com/mattn/go-sqlite3 v1.14.40
golang.org/x/crypto v0.31.0
golang.org/x/crypto v0.44.0
modernc.org/sqlite v1.48.1
)
require (
github.com/RoaringBitmap/roaring v1.2.3 // indirect
github.com/alecthomas/atomic v0.1.0-alpha2 // indirect
github.com/anacrolix/btree v0.0.0-20251201064447-d86c3fa41bd8 // indirect
github.com/anacrolix/chansync v0.7.0 // indirect
github.com/anacrolix/dht/v2 v2.23.0 // indirect
github.com/anacrolix/envpprof v1.4.0 // indirect
github.com/anacrolix/generics v0.1.1-0.20251125230353-15d98d46693b // indirect
github.com/anacrolix/go-libutp v1.3.2 // indirect
github.com/anacrolix/log v0.17.1-0.20251118025802-918f1157b7bb // indirect
github.com/anacrolix/missinggo v1.3.0 // indirect
github.com/anacrolix/missinggo/perf v1.0.0 // indirect
github.com/anacrolix/missinggo/v2 v2.10.0 // indirect
github.com/anacrolix/mmsg v1.0.1 // indirect
github.com/anacrolix/multiless v0.4.0 // indirect
github.com/anacrolix/stm v0.5.0 // indirect
github.com/anacrolix/sync v0.5.5-0.20251119100342-d78dd1f686f1 // indirect
github.com/anacrolix/upnp v0.1.4 // indirect
github.com/anacrolix/utp v0.1.0 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/benbjohnson/immutable v0.4.1-0.20221220213129-8932b999621d // indirect
github.com/bits-and-blooms/bitset v1.2.2 // indirect
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 // indirect
github.com/cespare/xxhash v1.1.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/edsrzf/mmap-go v1.1.0 // indirect
github.com/go-llsqlite/adapter v0.0.0-20230927005056-7f5ce7f0c916 // indirect
github.com/go-llsqlite/crawshaw v0.5.6-0.20250312230104-194977a03421 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/google/btree v1.1.2 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/gorilla/websocket v1.5.0 // indirect
github.com/huandu/xstrings v1.3.2 // indirect
github.com/klauspost/cpuid/v2 v2.2.3 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/minio/sha256-simd v1.0.0 // indirect
github.com/mr-tron/base58 v1.2.0 // indirect
github.com/mschoch/smat v0.2.0 // indirect
github.com/multiformats/go-multihash v0.2.3 // indirect
github.com/multiformats/go-varint v0.0.6 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pion/datachannel v1.5.9 // indirect
github.com/pion/dtls/v3 v3.0.3 // indirect
github.com/pion/ice/v4 v4.0.2 // indirect
github.com/pion/interceptor v0.1.40 // indirect
github.com/pion/logging v0.2.3 // indirect
github.com/pion/mdns/v2 v2.0.7 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/rtcp v1.2.15 // indirect
github.com/pion/rtp v1.8.18 // indirect
github.com/pion/sctp v1.8.33 // indirect
github.com/pion/sdp/v3 v3.0.9 // indirect
github.com/pion/srtp/v3 v3.0.4 // indirect
github.com/pion/stun/v3 v3.0.0 // indirect
github.com/pion/transport/v3 v3.0.7 // indirect
github.com/pion/turn/v4 v4.0.0 // indirect
github.com/pion/webrtc/v4 v4.0.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/protolambda/ctxlock v0.1.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rs/dnscache v0.0.0-20211102005908-e0241e321417 // indirect
github.com/spaolacci/murmur3 v1.1.0 // indirect
github.com/tidwall/btree v1.8.1 // indirect
github.com/wlynxg/anet v0.0.3 // indirect
go.etcd.io/bbolt v1.3.6 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel v1.38.0 // indirect
go.opentelemetry.io/otel/metric v1.38.0 // indirect
go.opentelemetry.io/otel/trace v1.38.0 // indirect
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.31.0 // indirect
golang.org/x/time v0.14.0 // indirect
lukechampine.com/blake3 v1.1.6 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
zombiezen.com/go/sqlite v0.13.1 // indirect
)
replace github.com/anacrolix/torrent => github.com/anacrolix/torrent v1.61.0

539
go.sum
View File

@@ -1,12 +1,539 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
crawshaw.io/iox v0.0.0-20181124134642-c51c3df30797/go.mod h1:sXBiorCo8c46JlQV3oXPKINnZ8mcqnye1EkVkqsectk=
crawshaw.io/sqlite v0.3.2/go.mod h1:igAO5JulrQ1DbdZdtVq48mnZUBAPOeFzer7VhDWNtW4=
filippo.io/edwards25519 v1.0.0-rc.1 h1:m0VOOB23frXZvAOK44usCgLWvtsxIoMCTBGJZlpmGfU=
filippo.io/edwards25519 v1.0.0-rc.1/go.mod h1:N1IkdkCkiLB6tki+MYJoSx2JTY9NUlxZE7eHn5EwJns=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/RoaringBitmap/roaring v0.4.7/go.mod h1:8khRDP4HmeXns4xIj9oGrKSz7XTQiJx2zgh7AcNke4w=
github.com/RoaringBitmap/roaring v0.4.17/go.mod h1:D3qVegWTmfCaX4Bl5CrBE9hfrSrrXIr8KVNvRsDi1NI=
github.com/RoaringBitmap/roaring v0.4.23/go.mod h1:D0gp8kJQgE1A4LQ5wFLggQEyvDi06Mq5mKs52e1TwOo=
github.com/RoaringBitmap/roaring v1.2.3 h1:yqreLINqIrX22ErkKI0vY47/ivtJr6n+kMhVOVmhWBY=
github.com/RoaringBitmap/roaring v1.2.3/go.mod h1:plvDsJQpxOC5bw8LRteu/MLWHsHez/3y6cubLI4/1yE=
github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo=
github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI=
github.com/a-h/templ v0.3.1001 h1:yHDTgexACdJttyiyamcTHXr2QkIeVF1MukLy44EAhMY=
github.com/a-h/templ v0.3.1001/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/alecthomas/assert/v2 v2.0.0-alpha3 h1:pcHeMvQ3OMstAWgaeaXIAL8uzB9xMm2zlxt+/4ml8lk=
github.com/alecthomas/assert/v2 v2.0.0-alpha3/go.mod h1:+zD0lmDXTeQj7TgDgCt0ePWxb0hMC1G+PGTsTCv1B9o=
github.com/alecthomas/atomic v0.1.0-alpha2 h1:dqwXmax66gXvHhsOS4pGPZKqYOlTkapELkLb3MNdlH8=
github.com/alecthomas/atomic v0.1.0-alpha2/go.mod h1:zD6QGEyw49HIq19caJDc2NMXAy8rNi9ROrxtMXATfyI=
github.com/alecthomas/repr v0.0.0-20210801044451-80ca428c5142 h1:8Uy0oSf5co/NZXje7U1z8Mpep++QJOldL2hs/sBQf48=
github.com/alecthomas/repr v0.0.0-20210801044451-80ca428c5142/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/anacrolix/btree v0.0.0-20251201064447-d86c3fa41bd8 h1:c02PsmoaChabVqAFm7pqPI1UIkDdDAjUaWa6ZmfxybQ=
github.com/anacrolix/btree v0.0.0-20251201064447-d86c3fa41bd8/go.mod h1:7stWJ39LeusmMI8mjJuhFNRqep//vx0AsaySRoK9or0=
github.com/anacrolix/chansync v0.7.0 h1:wgwxbsJRmOqNjil4INpxHrDp4rlqQhECxR8/WBP4Et0=
github.com/anacrolix/chansync v0.7.0/go.mod h1:DZsatdsdXxD0WiwcGl0nJVwyjCKMDv+knl1q2iBjA2k=
github.com/anacrolix/dht/v2 v2.23.0 h1:EuD17ykTTEkAMPLjBsS5QjGOwuBgLTdQhds6zPAjeVY=
github.com/anacrolix/dht/v2 v2.23.0/go.mod h1:seXRz6HLw8zEnxlysf9ye2eQbrKUmch6PyOHpe/Nb/U=
github.com/anacrolix/envpprof v0.0.0-20180404065416-323002cec2fa/go.mod h1:KgHhUaQMc8cC0+cEflSgCFNFbKwi5h54gqtVn8yhP7c=
github.com/anacrolix/envpprof v1.0.0/go.mod h1:KgHhUaQMc8cC0+cEflSgCFNFbKwi5h54gqtVn8yhP7c=
github.com/anacrolix/envpprof v1.1.0/go.mod h1:My7T5oSqVfEn4MD4Meczkw/f5lSIndGAKu/0SM/rkf4=
github.com/anacrolix/envpprof v1.4.0 h1:QHeIcrgHcRChhnxR8l6rlaLlRQx9zd7Q2NII6Zbt83w=
github.com/anacrolix/envpprof v1.4.0/go.mod h1:7QIG4CaX1uexQ3tqd5+BRa/9e2D02Wcertl6Yh0jCB0=
github.com/anacrolix/generics v0.0.0-20230113004304-d6428d516633/go.mod h1:ff2rHB/joTV03aMSSn/AZNnaIpUw0h3njetGsaXcMy8=
github.com/anacrolix/generics v0.1.1-0.20251125230353-15d98d46693b h1:Kuvx/A/TTJuT9x8mn7DeGx2KW9tWn1LI8bira67xdT0=
github.com/anacrolix/generics v0.1.1-0.20251125230353-15d98d46693b/go.mod h1:NGehhfeXJPBujPx0s6cstSj8B+TERsTY32Xckfx5ftc=
github.com/anacrolix/go-libutp v1.3.2 h1:WswiaxTIogchbkzNgGHuHRfbrYLpv4o290mlvcx+++M=
github.com/anacrolix/go-libutp v1.3.2/go.mod h1:fCUiEnXJSe3jsPG554A200Qv+45ZzIIyGEvE56SHmyA=
github.com/anacrolix/log v0.3.0/go.mod h1:lWvLTqzAnCWPJA08T2HCstZi0L1y2Wyvm3FJgwU9jwU=
github.com/anacrolix/log v0.6.0/go.mod h1:lWvLTqzAnCWPJA08T2HCstZi0L1y2Wyvm3FJgwU9jwU=
github.com/anacrolix/log v0.13.1/go.mod h1:D4+CvN8SnruK6zIFS/xPoRJmtvtnxs+CSfDQ+BFxZ68=
github.com/anacrolix/log v0.14.2/go.mod h1:1OmJESOtxQGNMlUO5rcv96Vpp9mfMqXXbe2RdinFLdY=
github.com/anacrolix/log v0.17.1-0.20251118025802-918f1157b7bb h1:nGNLCQbxFQZz7/9PXLGQ9GmavI/W+eX66pSwVeUwugU=
github.com/anacrolix/log v0.17.1-0.20251118025802-918f1157b7bb/go.mod h1:YjBZbwe2v3RsU7WdoBlVSPVpfKuOAno9SRQ/8tIl+hk=
github.com/anacrolix/lsan v0.0.0-20211126052245-807000409a62/go.mod h1:66cFKPCO7Sl4vbFnAaSq7e4OXtdMhRSBagJGWgmpJbM=
github.com/anacrolix/lsan v0.1.0 h1:TbgB8fdVXgBwrNsJGHtht9+9FepNFu5H7dU8ek6XYAY=
github.com/anacrolix/lsan v0.1.0/go.mod h1:66cFKPCO7Sl4vbFnAaSq7e4OXtdMhRSBagJGWgmpJbM=
github.com/anacrolix/missinggo v0.0.0-20180725070939-60ef2fbf63df/go.mod h1:kwGiTUTZ0+p4vAz3VbAI5a30t2YbvemcmspjKwrAz5s=
github.com/anacrolix/missinggo v1.1.0/go.mod h1:MBJu3Sk/k3ZfGYcS7z18gwfu72Ey/xopPFJJbTi5yIo=
github.com/anacrolix/missinggo v1.1.2-0.20190815015349-b888af804467/go.mod h1:MBJu3Sk/k3ZfGYcS7z18gwfu72Ey/xopPFJJbTi5yIo=
github.com/anacrolix/missinggo v1.2.1/go.mod h1:J5cMhif8jPmFoC3+Uvob3OXXNIhOUikzMt+uUjeM21Y=
github.com/anacrolix/missinggo v1.3.0 h1:06HlMsudotL7BAELRZs0yDZ4yVXsHXGi323QBjAVASw=
github.com/anacrolix/missinggo v1.3.0/go.mod h1:bqHm8cE8xr+15uVfMG3BFui/TxyB6//H5fwlq/TeqMc=
github.com/anacrolix/missinggo/perf v1.0.0 h1:7ZOGYziGEBytW49+KmYGTaNfnwUqP1HBsy6BqESAJVw=
github.com/anacrolix/missinggo/perf v1.0.0/go.mod h1:ljAFWkBuzkO12MQclXzZrosP5urunoLS0Cbvb4V0uMQ=
github.com/anacrolix/missinggo/v2 v2.2.0/go.mod h1:o0jgJoYOyaoYQ4E2ZMISVa9c88BbUBVQQW4QeRkNCGY=
github.com/anacrolix/missinggo/v2 v2.5.1/go.mod h1:WEjqh2rmKECd0t1VhQkLGTdIWXO6f6NLjp5GlMZ+6FA=
github.com/anacrolix/missinggo/v2 v2.10.0 h1:pg0iO4Z/UhP2MAnmGcaMtp5ZP9kyWsusENWN9aolrkY=
github.com/anacrolix/missinggo/v2 v2.10.0/go.mod h1:nCRMW6bRCMOVcw5z9BnSYKF+kDbtenx+hQuphf4bK8Y=
github.com/anacrolix/mmsg v1.0.1 h1:TxfpV7kX70m3f/O7ielL/2I3OFkMPjrRCPo7+4X5AWw=
github.com/anacrolix/mmsg v1.0.1/go.mod h1:x8kRaJY/dCrY9Al0PEcj1mb/uFHwP6GCJ9fLl4thEPc=
github.com/anacrolix/multiless v0.4.0 h1:lqSszHkliMsZd2hsyrDvHOw4AbYWa+ijQ66LzbjqWjM=
github.com/anacrolix/multiless v0.4.0/go.mod h1:zJv1JF9AqdZiHwxqPgjuOZDGWER6nyE48WBCi/OOrMM=
github.com/anacrolix/stm v0.2.0/go.mod h1:zoVQRvSiGjGoTmbM0vSLIiaKjWtNPeTvXUSdJQA4hsg=
github.com/anacrolix/stm v0.5.0 h1:9df1KBpttF0TzLgDq51Z+TEabZKMythqgx89f1FQJt8=
github.com/anacrolix/stm v0.5.0/go.mod h1:MOwrSy+jCm8Y7HYfMAwPj7qWVu7XoVvjOiYwJmpeB/M=
github.com/anacrolix/sync v0.0.0-20180808010631-44578de4e778/go.mod h1:s735Etp3joe/voe2sdaXLcqDdJSay1O0OPnM0ystjqk=
github.com/anacrolix/sync v0.3.0/go.mod h1:BbecHL6jDSExojhNtgTFSBcdGerzNc64tz3DCOj/I0g=
github.com/anacrolix/sync v0.5.5-0.20251119100342-d78dd1f686f1 h1:oLCfNgEOR3/Z98mSwmwTM1pcqCDb/1zIjxCNn7dzVaE=
github.com/anacrolix/sync v0.5.5-0.20251119100342-d78dd1f686f1/go.mod h1:21cUWerw9eiu/3T3kyoChu37AVO+YFue1/H15qqubS0=
github.com/anacrolix/tagflag v0.0.0-20180109131632-2146c8d41bf0/go.mod h1:1m2U/K6ZT+JZG0+bdMK6qauP49QT4wE5pmhJXOKKCHw=
github.com/anacrolix/tagflag v1.0.0/go.mod h1:1m2U/K6ZT+JZG0+bdMK6qauP49QT4wE5pmhJXOKKCHw=
github.com/anacrolix/tagflag v1.1.0/go.mod h1:Scxs9CV10NQatSmbyjqmqmeQNwGzlNe0CMUMIxqHIG8=
github.com/anacrolix/torrent v1.61.0 h1:vxo+B4SwnoP5AQWbhvnTYIaTgPSX+llYUVuQVsN4Jg8=
github.com/anacrolix/torrent v1.61.0/go.mod h1:yKUKuZSSDdyOsCbuH+rDOpswl/g546gICapdrU7aUmQ=
github.com/anacrolix/upnp v0.1.4 h1:+2t2KA6QOhm/49zeNyeVwDu1ZYS9dB9wfxyVvh/wk7U=
github.com/anacrolix/upnp v0.1.4/go.mod h1:Qyhbqo69gwNWvEk1xNTXsS5j7hMHef9hdr984+9fIic=
github.com/anacrolix/utp v0.1.0 h1:FOpQOmIwYsnENnz7tAGohA+r6iXpRjrq8ssKSre2Cp4=
github.com/anacrolix/utp v0.1.0/go.mod h1:MDwc+vsGEq7RMw6lr2GKOEqjWny5hO5OZXRVNaBJ2Dk=
github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/benbjohnson/immutable v0.2.0/go.mod h1:uc6OHo6PN2++n98KHLxW8ef4W42ylHiQSENghE1ezxI=
github.com/benbjohnson/immutable v0.4.1-0.20221220213129-8932b999621d h1:2qVb9bsAMtmAfnxXltm+6eBzrrS7SZ52c3SedsulaMI=
github.com/benbjohnson/immutable v0.4.1-0.20221220213129-8932b999621d/go.mod h1:iAr8OjJGLnLmVUr9MZ/rz4PWUy6Ouc2JLYuMArmvAJM=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bits-and-blooms/bitset v1.2.0/go.mod h1:gIdJ4wp64HaoK2YrL1Q5/N7Y16edYb8uY+O0FJTyyDA=
github.com/bits-and-blooms/bitset v1.2.2 h1:J5gbX05GpMdBjCvQ9MteIg2KKDExr7DrgK+Yc15FvIk=
github.com/bits-and-blooms/bitset v1.2.2/go.mod h1:gIdJ4wp64HaoK2YrL1Q5/N7Y16edYb8uY+O0FJTyyDA=
github.com/bradfitz/iter v0.0.0-20140124041915-454541ec3da2/go.mod h1:PyRFw1Lt2wKX4ZVSQ2mk+PeDa1rxyObEDlApuIsUKuo=
github.com/bradfitz/iter v0.0.0-20190303215204-33e6a9893b0c/go.mod h1:PyRFw1Lt2wKX4ZVSQ2mk+PeDa1rxyObEDlApuIsUKuo=
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 h1:GKTyiRCL6zVf5wWaqKnf+7Qs6GbEPfd4iMOitWzXJx8=
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8/go.mod h1:spo1JLcs67NmW1aVLEgtA8Yy1elc+X8y5SRW1sFW4Og=
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
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/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE=
github.com/dustin/go-humanize v0.0.0-20180421182945-02af3965c54e/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
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/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs=
github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU=
github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I=
github.com/edsrzf/mmap-go v1.1.0 h1:6EUwBLQ/Mcr1EYLE4Tn1VdW1A4ckqCQWZBw8Hr0kjpQ=
github.com/edsrzf/mmap-go v1.1.0/go.mod h1:19H/e8pUPLicwkyNgOykDXkJ9F0MHE+Z52B8EIth78Q=
github.com/frankban/quicktest v1.9.0/go.mod h1:ui7WezCLWMWxVWr1GETZY3smRy0G4KWq9vcPtJmFl7Y=
github.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/glycerine/go-unsnap-stream v0.0.0-20180323001048-9f0cb55181dd/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE=
github.com/glycerine/go-unsnap-stream v0.0.0-20181221182339-f9677308dec2/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE=
github.com/glycerine/go-unsnap-stream v0.0.0-20190901134440-81cf024a9e0a/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE=
github.com/glycerine/goconvey v0.0.0-20180728074245-46e3a41ad493/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24=
github.com/glycerine/goconvey v0.0.0-20190315024820-982ee783a72e/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24=
github.com/glycerine/goconvey v0.0.0-20190410193231-58a59202ab31/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-llsqlite/adapter v0.0.0-20230927005056-7f5ce7f0c916 h1:OyQmpAN302wAopDgwVjgs2HkFawP9ahIEqkUYz7V7CA=
github.com/go-llsqlite/adapter v0.0.0-20230927005056-7f5ce7f0c916/go.mod h1:DADrR88ONKPPeSGjFp5iEN55Arx3fi2qXZeKCYDpbmU=
github.com/go-llsqlite/crawshaw v0.5.6-0.20250312230104-194977a03421 h1:GClwZI0at7xwV0TpgUMTYr/DoTE7TJZ/tc29LcPcs7o=
github.com/go-llsqlite/crawshaw v0.5.6-0.20250312230104-194977a03421/go.mod h1:/YJdV7uBQaYDE0fwe4z3wwJIZBJxdYzd38ICggWqtaE=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/btree v0.0.0-20180124185431-e89373fe6b4a/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU=
github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9/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/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
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/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gopherjs/gopherjs v0.0.0-20190309154008-847fc94819f9/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gopherjs/gopherjs v0.0.0-20190910122728-9d188e94fb99/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
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/mattn/go-sqlite3 v1.14.40 h1:f7+saIsbq4EF86mUqe0uiecQOJYMOdfi5uATADmUG94=
github.com/mattn/go-sqlite3 v1.14.40/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/huandu/xstrings v1.0.0/go.mod h1:4qWG/gcEcfX4z/mBDHJ++3ReCw9ibxbsNJbcucJdbSo=
github.com/huandu/xstrings v1.2.0/go.mod h1:DvyZB1rfVYsBIigL8HwpZgxHwXozlTgGqn63UyNX5k4=
github.com/huandu/xstrings v1.3.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/huandu/xstrings v1.3.1/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/huandu/xstrings v1.3.2 h1:L18LIDzqlW6xN2rEkpdV8+oL/IXWJ1APd+vsdYy4Wdw=
github.com/huandu/xstrings v1.3.2/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.3 h1:sxCkb+qR91z4vsqw4vGGZlDgPz3G7gjaLyK3V8y70BU=
github.com/klauspost/cpuid/v2 v2.2.3/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g=
github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
github.com/mschoch/smat v0.0.0-20160514031455-90eadee771ae/go.mod h1:qAyveg+e4CE+eKJXWVjKXM4ck2QobLqTDytGJbLLhJg=
github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM=
github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw=
github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U=
github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM=
github.com/multiformats/go-varint v0.0.6 h1:gk85QWKxh3TazbLxED/NlDVv8+q+ReFJk7Y2W/KhfNY=
github.com/multiformats/go-varint v0.0.6/go.mod h1:3Ls8CIEsrijN6+B7PbrXRPxHRPuXSrVKRY101jdMZYE=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
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/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw=
github.com/philhofer/fwd v1.0.0/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU=
github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
github.com/pion/datachannel v1.5.9 h1:LpIWAOYPyDrXtU+BW7X0Yt/vGtYxtXQ8ql7dFfYUVZA=
github.com/pion/datachannel v1.5.9/go.mod h1:kDUuk4CU4Uxp82NH4LQZbISULkX/HtzKa4P7ldf9izE=
github.com/pion/dtls/v3 v3.0.3 h1:j5ajZbQwff7Z8k3pE3S+rQ4STvKvXUdKsi/07ka+OWM=
github.com/pion/dtls/v3 v3.0.3/go.mod h1:weOTUyIV4z0bQaVzKe8kpaP17+us3yAuiQsEAG1STMU=
github.com/pion/ice/v4 v4.0.2 h1:1JhBRX8iQLi0+TfcavTjPjI6GO41MFn4CeTBX+Y9h5s=
github.com/pion/ice/v4 v4.0.2/go.mod h1:DCdqyzgtsDNYN6/3U8044j3U7qsJ9KFJC92VnOWHvXg=
github.com/pion/interceptor v0.1.40 h1:e0BjnPcGpr2CFQgKhrQisBU7V3GXK6wrfYrGYaU6Jq4=
github.com/pion/interceptor v0.1.40/go.mod h1:Z6kqH7M/FYirg3frjGJ21VLSRJGBXB/KqaTIrdqnOic=
github.com/pion/logging v0.2.3 h1:gHuf0zpoh1GW67Nr6Gj4cv5Z9ZscU7g/EaoC/Ke/igI=
github.com/pion/logging v0.2.3/go.mod h1:z8YfknkquMe1csOrxK5kc+5/ZPAzMxbKLX5aXpbpC90=
github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM=
github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA=
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
github.com/pion/rtcp v1.2.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo=
github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0=
github.com/pion/rtp v1.8.18 h1:yEAb4+4a8nkPCecWzQB6V/uEU18X1lQCGAQCjP+pyvU=
github.com/pion/rtp v1.8.18/go.mod h1:bAu2UFKScgzyFqvUKmbvzSdPr+NGbZtv6UB2hesqXBk=
github.com/pion/sctp v1.8.33 h1:dSE4wX6uTJBcNm8+YlMg7lw1wqyKHggsP5uKbdj+NZw=
github.com/pion/sctp v1.8.33/go.mod h1:beTnqSzewI53KWoG3nqB282oDMGrhNxBdb+JZnkCwRM=
github.com/pion/sdp/v3 v3.0.9 h1:pX++dCHoHUwq43kuwf3PyJfHlwIj4hXA7Vrifiq0IJY=
github.com/pion/sdp/v3 v3.0.9/go.mod h1:B5xmvENq5IXJimIO4zfp6LAe1fD9N+kFv+V/1lOdz8M=
github.com/pion/srtp/v3 v3.0.4 h1:2Z6vDVxzrX3UHEgrUyIGM4rRouoC7v+NiF1IHtp9B5M=
github.com/pion/srtp/v3 v3.0.4/go.mod h1:1Jx3FwDoxpRaTh1oRV8A/6G1BnFL+QI82eK4ms8EEJQ=
github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw=
github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU=
github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0=
github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo=
github.com/pion/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM=
github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA=
github.com/pion/webrtc/v4 v4.0.0 h1:x8ec7uJQPP3D1iI8ojPAiTOylPI7Fa7QgqZrhpLyqZ8=
github.com/pion/webrtc/v4 v4.0.0/go.mod h1:SfNn8CcFxR6OUVjLXVslAQ3a3994JhyE3Hw1jAuqEto=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
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/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs=
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
github.com/prometheus/client_golang v1.5.1/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4=
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
github.com/prometheus/procfs v0.0.11/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
github.com/protolambda/ctxlock v0.1.0 h1:rCUY3+vRdcdZXqT07iXgyr744J2DU2LCBIXowYAjBCE=
github.com/protolambda/ctxlock v0.1.0/go.mod h1:vefhX6rIZH8rsg5ZpOJfEDYQOppZi19SfPiGOFrNnwM=
github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
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/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/dnscache v0.0.0-20211102005908-e0241e321417 h1:Lt9DzQALzHoDwMBGJ6v8ObDPR0dzr2a6sXTB1Fq7IHs=
github.com/rs/dnscache v0.0.0-20211102005908-e0241e321417/go.mod h1:qe5TWALJ8/a1Lqznoc5BDHpYX/8HU60Hm2AwRmqzxqA=
github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 h1:GHRpF1pTW19a8tTFrMLUcfWwyC0pnifVo2ClaLq+hP8=
github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/assertions v0.0.0-20190215210624-980c5ac6f3ac/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s=
github.com/smartystreets/goconvey v0.0.0-20190306220146-200a235640ff/go.mod h1:KSQcGKpxUMHk3nbYzs/tIBAM2iDooCn0BmttHOJEbLs=
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/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.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
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.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.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/tidwall/btree v1.8.1 h1:27ehoXvm5AG/g+1VxLS1SD3vRhp/H7LuEfwNvddEdmA=
github.com/tidwall/btree v1.8.1/go.mod h1:jBbTdUWhSZClZWoDg54VnvV7/54modSOzDN7VXftj1A=
github.com/tinylib/msgp v1.0.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
github.com/tinylib/msgp v1.1.0/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
github.com/tinylib/msgp v1.1.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
github.com/willf/bitset v1.1.9/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
github.com/willf/bitset v1.1.10/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
github.com/wlynxg/anet v0.0.3 h1:PvR53psxFXstc12jelG6f1Lv4MWqE0tI76/hHGjh9rg=
github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.etcd.io/bbolt v1.3.6 h1:/ecaJf0sk1l4l6V4awd65v2C3ILy7MSj+s/x1ADCIMU=
go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4=
go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
go.opencensus.io v0.20.2/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20220428152302-39d4317da171/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
golang.org/x/mod v0.6.0-dev.0.20211013180041-c96bc1413d57/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190125091013-d26f9f9a57f3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200413165638-669c56c373c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.8-0.20211029000441-d6a9af8af023/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
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=
honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
lukechampine.com/blake3 v1.1.6 h1:H3cROdztr7RCfoaTpGZFQsrqvweFLrqS73j7L7cmR5c=
lukechampine.com/blake3 v1.1.6/go.mod h1:tkKEOtDkNtklkXtLNEOGNq5tcV90tJiA1vAA12R78LA=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
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/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.48.1 h1:S85iToyU6cgeojybE2XJlSbcsvcWkQ6qqNXJHtW5hWA=
modernc.org/sqlite v1.48.1/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
zombiezen.com/go/sqlite v0.13.1 h1:qDzxyWWmMtSSEH5qxamqBFmqA2BLSSbtODi3ojaE02o=
zombiezen.com/go/sqlite v0.13.1/go.mod h1:Ht/5Rg3Ae2hoyh1I7gbWtWAl89CNocfqeb/aAMTkJr4=

View File

@@ -25,6 +25,8 @@ type Anime struct {
TitleEnglish sql.NullString `json:"title_english"`
TitleJapanese sql.NullString `json:"title_japanese"`
Airing sql.NullBool `json:"airing"`
MagnetLink sql.NullString `json:"magnet_link"`
TorrentHash sql.NullString `json:"torrent_hash"`
}
type Session struct {

View File

@@ -24,14 +24,16 @@ DELETE FROM session WHERE id = ?;
DELETE FROM session WHERE user_id = ?;
-- name: UpsertAnime :one
INSERT INTO anime (id, title_original, title_english, title_japanese, image_url, airing)
VALUES (?, ?, ?, ?, ?, ?)
INSERT INTO anime (id, title_original, title_english, title_japanese, image_url, airing, magnet_link, torrent_hash)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET
title_original = excluded.title_original,
title_english = excluded.title_english,
title_japanese = excluded.title_japanese,
image_url = excluded.image_url,
airing = excluded.airing
airing = excluded.airing,
magnet_link = excluded.magnet_link,
torrent_hash = excluded.torrent_hash
RETURNING *;
-- name: GetAnime :one
@@ -56,7 +58,9 @@ SELECT
a.title_english,
a.title_japanese,
a.image_url,
a.airing
a.airing,
a.magnet_link,
a.torrent_hash
FROM watch_list_entry e
JOIN anime a ON e.anime_id = a.id
WHERE e.user_id = ?

View File

@@ -93,7 +93,7 @@ func (q *Queries) DeleteWatchListEntry(ctx context.Context, arg DeleteWatchListE
}
const getAnime = `-- name: GetAnime :one
SELECT id, title_original, image_url, created_at, title_english, title_japanese, airing FROM anime WHERE id = ? LIMIT 1
SELECT id, title_original, image_url, created_at, title_english, title_japanese, airing, magnet_link, torrent_hash FROM anime WHERE id = ? LIMIT 1
`
func (q *Queries) GetAnime(ctx context.Context, id int64) (Anime, error) {
@@ -107,6 +107,8 @@ func (q *Queries) GetAnime(ctx context.Context, id int64) (Anime, error) {
&i.TitleEnglish,
&i.TitleJapanese,
&i.Airing,
&i.MagnetLink,
&i.TorrentHash,
)
return i, err
}
@@ -166,7 +168,9 @@ SELECT
a.title_english,
a.title_japanese,
a.image_url,
a.airing
a.airing,
a.magnet_link,
a.torrent_hash
FROM watch_list_entry e
JOIN anime a ON e.anime_id = a.id
WHERE e.user_id = ?
@@ -185,6 +189,8 @@ type GetUserWatchListRow struct {
TitleJapanese sql.NullString `json:"title_japanese"`
ImageUrl string `json:"image_url"`
Airing sql.NullBool `json:"airing"`
MagnetLink sql.NullString `json:"magnet_link"`
TorrentHash sql.NullString `json:"torrent_hash"`
}
func (q *Queries) GetUserWatchList(ctx context.Context, userID string) ([]GetUserWatchListRow, error) {
@@ -208,6 +214,8 @@ func (q *Queries) GetUserWatchList(ctx context.Context, userID string) ([]GetUse
&i.TitleJapanese,
&i.ImageUrl,
&i.Airing,
&i.MagnetLink,
&i.TorrentHash,
); err != nil {
return nil, err
}
@@ -247,15 +255,17 @@ func (q *Queries) GetWatchListEntry(ctx context.Context, arg GetWatchListEntryPa
}
const upsertAnime = `-- name: UpsertAnime :one
INSERT INTO anime (id, title_original, title_english, title_japanese, image_url, airing)
VALUES (?, ?, ?, ?, ?, ?)
INSERT INTO anime (id, title_original, title_english, title_japanese, image_url, airing, magnet_link, torrent_hash)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET
title_original = excluded.title_original,
title_english = excluded.title_english,
title_japanese = excluded.title_japanese,
image_url = excluded.image_url,
airing = excluded.airing
RETURNING id, title_original, image_url, created_at, title_english, title_japanese, airing
airing = excluded.airing,
magnet_link = excluded.magnet_link,
torrent_hash = excluded.torrent_hash
RETURNING id, title_original, image_url, created_at, title_english, title_japanese, airing, magnet_link, torrent_hash
`
type UpsertAnimeParams struct {
@@ -265,6 +275,8 @@ type UpsertAnimeParams struct {
TitleJapanese sql.NullString `json:"title_japanese"`
ImageUrl string `json:"image_url"`
Airing sql.NullBool `json:"airing"`
MagnetLink sql.NullString `json:"magnet_link"`
TorrentHash sql.NullString `json:"torrent_hash"`
}
func (q *Queries) UpsertAnime(ctx context.Context, arg UpsertAnimeParams) (Anime, error) {
@@ -275,6 +287,8 @@ func (q *Queries) UpsertAnime(ctx context.Context, arg UpsertAnimeParams) (Anime
arg.TitleJapanese,
arg.ImageUrl,
arg.Airing,
arg.MagnetLink,
arg.TorrentHash,
)
var i Anime
err := row.Scan(
@@ -285,6 +299,8 @@ func (q *Queries) UpsertAnime(ctx context.Context, arg UpsertAnimeParams) (Anime
&i.TitleEnglish,
&i.TitleJapanese,
&i.Airing,
&i.MagnetLink,
&i.TorrentHash,
)
return i, err
}

View File

@@ -109,15 +109,7 @@ func (h *Handler) HandleAnimeDetails(w http.ResponseWriter, r *http.Request) {
}
func (h *Handler) HandleAPIAnimeRelations(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path[len("/api/anime/"):]
idStr := ""
for i, c := range path {
if c == '/' {
idStr = path[:i]
break
}
}
idStr := r.PathValue("id")
id, _ := strconv.Atoi(idStr)
if id <= 0 {
http.Error(w, "invalid id", http.StatusBadRequest)
@@ -172,3 +164,29 @@ func (h *Handler) HandleQuickSearch(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(output)
}
func (h *Handler) HandleAPIAnimeEpisodes(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
id, _ := strconv.Atoi(idStr)
if id <= 0 {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
anime, err := h.svc.GetAnime(id)
if err != nil {
log.Printf("anime fetch error: %v", err)
http.Error(w, "Failed to fetch anime", http.StatusInternalServerError)
return
}
episodes, err := h.svc.GetEpisodes(id, 1)
if err != nil {
log.Printf("episodes fetch error: %v", err)
// Return empty episodes instead of error
templates.EpisodesList(id, anime.Title, nil, anime.Episodes).Render(r.Context(), w)
return
}
templates.EpisodesList(id, anime.Title, episodes.Episodes, anime.Episodes).Render(r.Context(), w)
}

View File

@@ -51,3 +51,15 @@ func (s *Service) GetAnimeDetails(ctx context.Context, id int, userID string) (j
func (s *Service) GetRelations(id int) []jikan.RelationEntry {
return s.jikanClient.GetFullRelations(id)
}
func (s *Service) GetEpisodes(id int, page int) (jikan.EpisodesResult, error) {
return s.jikanClient.GetEpisodes(id, page)
}
func (s *Service) GetAllEpisodes(id int) ([]jikan.Episode, error) {
return s.jikanClient.GetAllEpisodes(id)
}
func (s *Service) GetAnime(id int) (jikan.Anime, error) {
return s.jikanClient.GetAnimeByID(id)
}

View File

@@ -16,6 +16,7 @@ type Client struct {
topCache *expirable.LRU[int, TopAnimeResult]
animeCache *expirable.LRU[int, Anime]
relationsCache *expirable.LRU[int, JikanRelationsResponse]
episodesCache *expirable.LRU[string, EpisodesResult]
}
func NewClient() *Client {
@@ -23,6 +24,7 @@ func NewClient() *Client {
topCache := expirable.NewLRU[int, TopAnimeResult](100, nil, time.Hour*1)
animeCache := expirable.NewLRU[int, Anime](1000, nil, time.Hour*24)
relationsCache := expirable.NewLRU[int, JikanRelationsResponse](1000, nil, time.Hour*24)
episodesCache := expirable.NewLRU[string, EpisodesResult](500, nil, time.Hour*6)
return &Client{
httpClient: &http.Client{Timeout: 10 * time.Second},
@@ -31,6 +33,7 @@ func NewClient() *Client {
topCache: topCache,
animeCache: animeCache,
relationsCache: relationsCache,
episodesCache: episodesCache,
}
}
@@ -63,3 +66,44 @@ func (c *Client) fetchWithRetry(urlStr string, out interface{}) error {
}
return fmt.Errorf("max retries exceeded for %s", urlStr)
}
// GetEpisodes fetches episodes for an anime (paginated, 100 per page)
func (c *Client) GetEpisodes(animeID int, page int) (EpisodesResult, error) {
cacheKey := fmt.Sprintf("%d-%d", animeID, page)
if cached, ok := c.episodesCache.Get(cacheKey); ok {
return cached, nil
}
url := fmt.Sprintf("%s/anime/%d/episodes?page=%d", c.baseURL, animeID, page)
var resp EpisodesResponse
if err := c.fetchWithRetry(url, &resp); err != nil {
return EpisodesResult{}, err
}
result := EpisodesResult{
Episodes: resp.Data,
HasNextPage: resp.Pagination.HasNextPage,
}
c.episodesCache.Add(cacheKey, result)
return result, nil
}
// GetAllEpisodes fetches all episodes for an anime (handles pagination)
func (c *Client) GetAllEpisodes(animeID int) ([]Episode, error) {
var allEpisodes []Episode
page := 1
for {
result, err := c.GetEpisodes(animeID, page)
if err != nil {
return nil, err
}
allEpisodes = append(allEpisodes, result.Episodes...)
if !result.HasNextPage {
break
}
page++
}
return allEpisodes, nil
}

View File

@@ -169,3 +169,24 @@ func (a Anime) DisplayTitle() string {
}
return a.Title
}
// Episode represents a single anime episode from Jikan API
type Episode struct {
MalID int `json:"mal_id"`
Title string `json:"title"`
TitleJP string `json:"title_japanese"`
TitleRom string `json:"title_romanji"`
Aired string `json:"aired"`
Filler bool `json:"filler"`
Recap bool `json:"recap"`
}
type EpisodesResponse struct {
Data []Episode `json:"data"`
Pagination Pagination `json:"pagination"`
}
type EpisodesResult struct {
Episodes []Episode
HasNextPage bool
}

285
internal/nyaa/client.go Normal file
View File

@@ -0,0 +1,285 @@
package nyaa
import (
"encoding/xml"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"time"
"github.com/hashicorp/golang-lru/v2/expirable"
)
type Client struct {
httpClient *http.Client
baseURL string
cache *expirable.LRU[string, []Torrent]
magnetCache *expirable.LRU[string, string]
}
type Torrent struct {
Title string `json:"title"`
Magnet string `json:"magnet"`
Size string `json:"size"`
Seeders int `json:"seeders"`
Leechers int `json:"leechers"`
Date string `json:"date"`
Episode int `json:"episode"`
ViewURL string `json:"view_url"`
}
// RSS feed structures
type rssResponse struct {
XMLName xml.Name `xml:"rss"`
Channel rssChannel `xml:"channel"`
}
type rssChannel struct {
Items []rssItem `xml:"item"`
}
type rssItem struct {
Title string `xml:"title"`
Link string `xml:"link"`
GUID string `xml:"guid"`
PubDate string `xml:"pubDate"`
Seeders string `xml:"seeders"`
Leechers string `xml:"leechers"`
Size string `xml:"size"`
}
func NewClient() *Client {
cache := expirable.NewLRU[string, []Torrent](200, nil, time.Minute*15)
magnetCache := expirable.NewLRU[string, string](500, nil, time.Hour*24)
return &Client{
httpClient: &http.Client{Timeout: 15 * time.Second},
baseURL: "https://nyaa.si",
cache: cache,
magnetCache: magnetCache,
}
}
// SearchAnime searches for anime torrents on nyaa.si
// category 1_2 = Anime - English-translated
func (c *Client) SearchAnime(query string) ([]Torrent, error) {
if cached, ok := c.cache.Get(query); ok {
return cached, nil
}
// Build search URL with RSS feed
params := url.Values{}
params.Set("f", "0") // filter: no filter
params.Set("c", "1_2") // category: Anime - English-translated
params.Set("q", query)
params.Set("s", "seeders") // sort by seeders
params.Set("o", "desc") // descending order
params.Set("page", "rss") // RSS format
reqURL := fmt.Sprintf("%s/?%s", c.baseURL, params.Encode())
resp, err := c.httpClient.Get(reqURL)
if err != nil {
return nil, fmt.Errorf("nyaa request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("nyaa returned status %d", resp.StatusCode)
}
var rss rssResponse
if err := xml.NewDecoder(resp.Body).Decode(&rss); err != nil {
return nil, fmt.Errorf("failed to parse nyaa rss: %w", err)
}
torrents := make([]Torrent, 0, len(rss.Channel.Items))
for _, item := range rss.Channel.Items {
seeders, _ := strconv.Atoi(item.Seeders)
leechers, _ := strconv.Atoi(item.Leechers)
// Extract torrent ID from download link
// Link format: https://nyaa.si/download/1234567.torrent
viewURL := extractViewURL(item.Link)
t := Torrent{
Title: item.Title,
Magnet: "", // Will be fetched on demand
Size: item.Size,
Seeders: seeders,
Leechers: leechers,
Date: item.PubDate,
Episode: parseEpisodeNumber(item.Title),
ViewURL: viewURL,
}
// Check if GUID is already a magnet link
if strings.HasPrefix(item.GUID, "magnet:") {
t.Magnet = item.GUID
}
torrents = append(torrents, t)
}
// Fetch magnets for top results (limit to avoid rate limiting)
for i := range torrents {
if i >= 20 {
break
}
if torrents[i].Magnet == "" && torrents[i].ViewURL != "" {
magnet, err := c.fetchMagnet(torrents[i].ViewURL)
if err == nil {
torrents[i].Magnet = magnet
}
}
}
c.cache.Add(query, torrents)
return torrents, nil
}
// fetchMagnet scrapes the nyaa view page to get the magnet link
func (c *Client) fetchMagnet(viewURL string) (string, error) {
if cached, ok := c.magnetCache.Get(viewURL); ok {
return cached, nil
}
resp, err := c.httpClient.Get(viewURL)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("nyaa returned status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
// Find magnet link in page
// Pattern: href="magnet:?xt=urn:btih:..."
magnetRe := regexp.MustCompile(`href="(magnet:\?xt=urn:btih:[^"]+)"`)
matches := magnetRe.FindSubmatch(body)
if len(matches) < 2 {
return "", fmt.Errorf("magnet link not found")
}
magnet := string(matches[1])
c.magnetCache.Add(viewURL, magnet)
return magnet, nil
}
// extractViewURL converts download URL to view URL
func extractViewURL(downloadURL string) string {
// https://nyaa.si/download/1234567.torrent -> https://nyaa.si/view/1234567
re := regexp.MustCompile(`/download/(\d+)\.torrent`)
matches := re.FindStringSubmatch(downloadURL)
if len(matches) < 2 {
return ""
}
return fmt.Sprintf("https://nyaa.si/view/%s", matches[1])
}
// SearchEpisode searches for a specific episode of an anime
func (c *Client) SearchEpisode(animeTitle string, episode int) ([]Torrent, error) {
// Format episode number with leading zero for single digits
epStr := fmt.Sprintf("%02d", episode)
query := fmt.Sprintf("%s %s", animeTitle, epStr)
torrents, err := c.SearchAnime(query)
if err != nil {
return nil, err
}
// Filter to torrents that match the episode number
var filtered []Torrent
for _, t := range torrents {
if t.Episode == episode || t.Episode == 0 {
// Episode 0 means we couldn't parse it, include anyway
filtered = append(filtered, t)
}
}
// If no filtered results, return all (search might be specific enough)
if len(filtered) == 0 {
return torrents, nil
}
return filtered, nil
}
// parseEpisodeNumber tries to extract episode number from torrent title
func parseEpisodeNumber(title string) int {
patterns := []*regexp.Regexp{
regexp.MustCompile(`(?i)[-]\s*(\d{1,4})(?:v\d)?(?:\s|\[|$)`), // - 01 or - 01v2
regexp.MustCompile(`(?i)\bE(\d{1,4})(?:v\d)?(?:\s|\[|$)`), // E01
regexp.MustCompile(`(?i)S\d{1,2}E(\d{1,4})(?:v\d)?(?:\s|\[|$)`), // S01E01
regexp.MustCompile(`(?i)Episode\s*(\d{1,4})(?:v\d)?(?:\s|\[|$)`), // Episode 01
regexp.MustCompile(`(?i)\s(\d{2,4})(?:v\d)?\s*[\[\(]`), // 01 [quality]
}
for _, re := range patterns {
if matches := re.FindStringSubmatch(title); len(matches) > 1 {
if ep, err := strconv.Atoi(matches[1]); err == nil {
return ep
}
}
}
return 0
}
// FilterByQuality returns torrents matching the quality preference
func FilterByQuality(torrents []Torrent, quality string) []Torrent {
if quality == "" {
return torrents
}
var filtered []Torrent
qualityLower := strings.ToLower(quality)
for _, t := range torrents {
titleLower := strings.ToLower(t.Title)
if strings.Contains(titleLower, qualityLower) {
filtered = append(filtered, t)
}
}
if len(filtered) == 0 {
return torrents
}
return filtered
}
// BestTorrent returns the torrent with the most seeders that has a magnet
func BestTorrent(torrents []Torrent) *Torrent {
if len(torrents) == 0 {
return nil
}
var best *Torrent
for i := range torrents {
if torrents[i].Magnet == "" {
continue
}
if best == nil || torrents[i].Seeders > best.Seeders {
best = &torrents[i]
}
}
// Fallback to first with magnet
if best == nil {
for i := range torrents {
if torrents[i].Magnet != "" {
return &torrents[i]
}
}
}
return best
}

View File

@@ -1,7 +1,9 @@
package server
import (
"log/slog"
"net/http"
"os"
"mal/internal/database"
"mal/internal/features/anime"
@@ -9,6 +11,7 @@ import (
"mal/internal/features/watchlist"
"mal/internal/jikan"
"mal/internal/shared/middleware"
"mal/internal/streaming"
)
type Config struct {
@@ -28,6 +31,13 @@ func NewRouter(cfg Config) http.Handler {
animeSvc := anime.NewService(cfg.JikanClient, cfg.DB)
animeHandler := anime.NewHandler(animeSvc)
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
streamSvc, err := streaming.NewService(logger)
if err != nil {
panic("failed to initialize streaming service: " + err.Error())
}
streamHandler := streaming.NewHandler(streamSvc, cfg.JikanClient)
// Serve static files
fs := http.FileServer(http.Dir("./static"))
mux.Handle("/static/", http.StripPrefix("/static/", fs))
@@ -39,7 +49,8 @@ func NewRouter(cfg Config) http.Handler {
mux.HandleFunc("/api/search-quick", animeHandler.HandleQuickSearch)
mux.HandleFunc("/api/catalog", animeHandler.HandleAPICatalog)
mux.HandleFunc("/anime/", animeHandler.HandleAnimeDetails)
mux.HandleFunc("/api/anime/", animeHandler.HandleAPIAnimeRelations)
mux.HandleFunc("/api/anime/{id}/relations", animeHandler.HandleAPIAnimeRelations)
mux.HandleFunc("/api/anime/{id}/episodes", animeHandler.HandleAPIAnimeEpisodes)
// Auth Endpoints
mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
@@ -51,6 +62,9 @@ func NewRouter(cfg Config) http.Handler {
})
mux.HandleFunc("/logout", authHandler.HandleLogout)
// Streaming endpoints
streamHandler.RegisterRoutes(mux)
// Watchlist POST endpoint (Protected)
mux.Handle("/api/watchlist/export", middleware.RequireAuth(http.HandlerFunc(watchlistHandler.HandleExportWatchlist)))
mux.Handle("/api/watchlist/import", middleware.RequireAuth(http.HandlerFunc(watchlistHandler.HandleImportWatchlist)))

View File

@@ -61,10 +61,12 @@ func RequireAuth(next http.Handler) http.Handler {
// RequireGlobalAuth ensures that a valid user is in the context for all routes except login and static
func RequireGlobalAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Allow unauthenticated access to login, search, and static files
// Allow unauthenticated access to login, search, static files, and streaming
if r.URL.Path == "/login" || strings.HasPrefix(r.URL.Path, "/static/") ||
r.URL.Path == "/search" || r.URL.Path == "/api/search" || r.URL.Path == "/api/search-quick" ||
r.URL.Path == "/" {
r.URL.Path == "/" ||
strings.HasPrefix(r.URL.Path, "/watch/") || strings.HasPrefix(r.URL.Path, "/api/stream/") ||
strings.HasPrefix(r.URL.Path, "/anime/") || strings.HasPrefix(r.URL.Path, "/api/anime/") {
next.ServeHTTP(w, r)
return
}

View File

@@ -0,0 +1,349 @@
package streaming
import (
"encoding/json"
"fmt"
"html"
"log"
"net/http"
"strconv"
"mal/internal/jikan"
"mal/internal/nyaa"
"mal/internal/templates"
)
type Handler struct {
svc *Service
jikan *jikan.Client
}
func NewHandler(svc *Service, jikanClient *jikan.Client) *Handler {
return &Handler{svc: svc, jikan: jikanClient}
}
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
// Watch page
mux.HandleFunc("GET /watch/{animeID}/{episode}", h.HandleWatchPage)
// Search endpoints
mux.HandleFunc("GET /api/stream/search", h.HandleSearch)
mux.HandleFunc("GET /api/stream/search/episode", h.HandleSearchEpisode)
mux.HandleFunc("GET /api/stream/search-htmx", h.HandleSearchHTMX)
// Streaming endpoints
mux.HandleFunc("POST /api/stream/start", h.HandleStartStream)
mux.HandleFunc("GET /api/stream/video/{hash}", h.HandleStreamVideo)
mux.HandleFunc("GET /api/stream/info/{hash}", h.HandleStreamInfo)
mux.HandleFunc("DELETE /api/stream/{hash}", h.HandleDropStream)
// HLS endpoints
mux.HandleFunc("POST /api/stream/hls/{hash}", h.HandleStartHLS)
mux.HandleFunc("GET /api/stream/hls/{hash}/playlist.m3u8", h.HandleHLSPlaylist)
mux.HandleFunc("GET /api/stream/hls/{hash}/{segment}", h.HandleHLSSegment)
}
// HandleWatchPage renders the video player page for an episode
func (h *Handler) HandleWatchPage(w http.ResponseWriter, r *http.Request) {
animeIDStr := r.PathValue("animeID")
episodeStr := r.PathValue("episode")
animeID, err := strconv.Atoi(animeIDStr)
if err != nil || animeID <= 0 {
http.NotFound(w, r)
return
}
episode, err := strconv.Atoi(episodeStr)
if err != nil || episode <= 0 {
http.NotFound(w, r)
return
}
anime, err := h.jikan.GetAnimeByID(animeID)
if err != nil {
log.Printf("failed to get anime %d: %v", animeID, err)
http.Error(w, "Failed to fetch anime", http.StatusInternalServerError)
return
}
// Search for torrents for this episode
torrents, err := h.svc.SearchEpisode(anime.Title, episode)
if err != nil {
log.Printf("torrent search error: %v", err)
torrents = nil
}
// Filter to 1080p by default, fallback to all
filtered := nyaa.FilterByQuality(torrents, "1080p")
if len(filtered) == 0 {
filtered = torrents
}
// Limit to top 10
if len(filtered) > 10 {
filtered = filtered[:10]
}
templates.WatchPage(anime, episode, filtered).Render(r.Context(), w)
}
// HandleSearch searches nyaa for anime torrents
func (h *Handler) HandleSearch(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("q")
if query == "" {
http.Error(w, "query required", http.StatusBadRequest)
return
}
torrents, err := h.svc.SearchAnime(query)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
quality := r.URL.Query().Get("quality")
if quality != "" {
torrents = nyaa.FilterByQuality(torrents, quality)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(torrents)
}
// HandleSearchEpisode searches nyaa for a specific episode
func (h *Handler) HandleSearchEpisode(w http.ResponseWriter, r *http.Request) {
title := r.URL.Query().Get("title")
episodeStr := r.URL.Query().Get("episode")
if title == "" || episodeStr == "" {
http.Error(w, "title and episode required", http.StatusBadRequest)
return
}
episode, err := strconv.Atoi(episodeStr)
if err != nil {
http.Error(w, "invalid episode number", http.StatusBadRequest)
return
}
torrents, err := h.svc.SearchEpisode(title, episode)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
quality := r.URL.Query().Get("quality")
if quality != "" {
torrents = nyaa.FilterByQuality(torrents, quality)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(torrents)
}
type startStreamRequest struct {
Magnet string `json:"magnet"`
}
type startStreamResponse struct {
InfoHash string `json:"info_hash"`
Name string `json:"name"`
Size int64 `json:"size"`
}
// HandleStartStream starts streaming a torrent from a magnet link
func (h *Handler) HandleStartStream(w http.ResponseWriter, r *http.Request) {
var req startStreamRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
if req.Magnet == "" {
http.Error(w, "magnet required", http.StatusBadRequest)
return
}
info, err := h.svc.AddMagnet(r.Context(), req.Magnet)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(startStreamResponse{
InfoHash: info.InfoHash,
Name: info.Name,
Size: info.Size,
})
}
// HandleStreamVideo streams the video file from a torrent
func (h *Handler) HandleStreamVideo(w http.ResponseWriter, r *http.Request) {
hash := r.PathValue("hash")
if hash == "" {
http.Error(w, "hash required", http.StatusBadRequest)
return
}
if err := h.svc.StreamVideo(w, r, hash); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
// HandleStreamInfo returns info about an active stream
func (h *Handler) HandleStreamInfo(w http.ResponseWriter, r *http.Request) {
hash := r.PathValue("hash")
if hash == "" {
http.Error(w, "hash required", http.StatusBadRequest)
return
}
info, err := h.svc.GetStreamInfo(hash)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(info)
}
// HandleDropStream stops and removes a stream
func (h *Handler) HandleDropStream(w http.ResponseWriter, r *http.Request) {
hash := r.PathValue("hash")
if hash == "" {
http.Error(w, "hash required", http.StatusBadRequest)
return
}
h.svc.DropTorrent(hash)
w.WriteHeader(http.StatusNoContent)
}
// TorrentResult for HTMX responses
type TorrentResult struct {
Torrents []nyaa.Torrent
Query string
Episode int
}
// HandleSearchHTMX returns HTML for torrent search results
func (h *Handler) HandleSearchHTMX(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("q")
if query == "" {
fmt.Fprint(w, `<div class="search-empty">enter a search query</div>`)
return
}
torrents, err := h.svc.SearchAnime(query)
if err != nil {
fmt.Fprintf(w, `<div class="search-error">error: %s</div>`, html.EscapeString(err.Error()))
return
}
quality := r.URL.Query().Get("quality")
if quality != "" {
torrents = nyaa.FilterByQuality(torrents, quality)
}
// Filter to only torrents with magnet links
var withMagnets []nyaa.Torrent
for _, t := range torrents {
if t.Magnet != "" {
withMagnets = append(withMagnets, t)
}
}
if len(withMagnets) == 0 {
fmt.Fprint(w, `<div class="search-empty">no torrents found (or no magnet links available)</div>`)
return
}
// Return simple HTML list
fmt.Fprint(w, `<div class="torrent-list">`)
for _, t := range withMagnets {
fmt.Fprintf(w, `
<div class="torrent-item" data-magnet="%s">
<div class="torrent-title">%s</div>
<div class="torrent-meta">
<span class="torrent-size">%s</span>
<span class="torrent-seeds">%d seeds</span>
</div>
</div>
`, html.EscapeString(t.Magnet), html.EscapeString(t.Title), html.EscapeString(t.Size), t.Seeders)
}
fmt.Fprint(w, `</div>`)
}
// HandleStartHLS starts HLS transcoding for a torrent
func (h *Handler) HandleStartHLS(w http.ResponseWriter, r *http.Request) {
hash := r.PathValue("hash")
if hash == "" {
http.Error(w, "hash required", http.StatusBadRequest)
return
}
// Check torrent exists
if _, ok := h.svc.GetTorrent(hash); !ok {
http.Error(w, "torrent not found - start stream first", http.StatusNotFound)
return
}
session, err := h.svc.StartHLS(r.Context(), hash)
if err != nil {
log.Printf("HLS start error: %v", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"playlist": fmt.Sprintf("/api/stream/hls/%s/playlist.m3u8", hash),
"status": "ready",
"output": session.OutputDir,
})
}
// HandleHLSPlaylist serves the HLS playlist file
func (h *Handler) HandleHLSPlaylist(w http.ResponseWriter, r *http.Request) {
hash := r.PathValue("hash")
if hash == "" {
http.Error(w, "hash required", http.StatusBadRequest)
return
}
session, ok := h.svc.GetHLSSession(hash)
if !ok {
http.Error(w, "HLS session not found - start transcoding first", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
w.Header().Set("Cache-Control", "no-cache")
http.ServeFile(w, r, session.Playlist)
}
// HandleHLSSegment serves an HLS segment file
func (h *Handler) HandleHLSSegment(w http.ResponseWriter, r *http.Request) {
hash := r.PathValue("hash")
segment := r.PathValue("segment")
if hash == "" || segment == "" {
http.Error(w, "hash and segment required", http.StatusBadRequest)
return
}
session, ok := h.svc.GetHLSSession(hash)
if !ok {
http.Error(w, "HLS session not found", http.StatusNotFound)
return
}
// Serve the segment file
segmentPath := fmt.Sprintf("%s/%s", session.OutputDir, segment)
w.Header().Set("Content-Type", "video/mp2t")
http.ServeFile(w, r, segmentPath)
}

248
internal/streaming/hls.go Normal file
View File

@@ -0,0 +1,248 @@
package streaming
import (
"context"
"fmt"
"io"
"log/slog"
"os"
"os/exec"
"path/filepath"
"sync"
"time"
)
// HLSTranscoder manages on-demand HLS transcoding sessions
type HLSTranscoder struct {
logger *slog.Logger
sessions map[string]*HLSSession
mu sync.RWMutex
baseDir string
}
// HLSSession represents an active transcoding session
type HLSSession struct {
InfoHash string
OutputDir string
Playlist string
cmd *exec.Cmd
cancel context.CancelFunc
ready chan struct{}
err error
lastAccess time.Time
mu sync.Mutex
}
// NewHLSTranscoder creates a new HLS transcoder
func NewHLSTranscoder(logger *slog.Logger) (*HLSTranscoder, error) {
// Check ffmpeg is available
if _, err := exec.LookPath("ffmpeg"); err != nil {
return nil, fmt.Errorf("ffmpeg not found in PATH: %w", err)
}
baseDir := filepath.Join(os.TempDir(), "mal-hls")
if err := os.MkdirAll(baseDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create HLS temp dir: %w", err)
}
t := &HLSTranscoder{
logger: logger,
sessions: make(map[string]*HLSSession),
baseDir: baseDir,
}
// Start cleanup goroutine
go t.cleanupLoop()
return t, nil
}
// StartSessionWithReader starts transcoding from a reader (piped input) to HLS
func (t *HLSTranscoder) StartSessionWithReader(infoHash string, reader io.Reader) (*HLSSession, error) {
t.mu.Lock()
defer t.mu.Unlock()
// Return existing session if available
if session, ok := t.sessions[infoHash]; ok {
session.mu.Lock()
session.lastAccess = time.Now()
session.mu.Unlock()
return session, nil
}
// Create output directory
outputDir := filepath.Join(t.baseDir, infoHash)
if err := os.MkdirAll(outputDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create output dir: %w", err)
}
playlist := filepath.Join(outputDir, "stream.m3u8")
ctx, cancel := context.WithCancel(context.Background())
session := &HLSSession{
InfoHash: infoHash,
OutputDir: outputDir,
Playlist: playlist,
cancel: cancel,
ready: make(chan struct{}),
lastAccess: time.Now(),
}
// Start ffmpeg with pipe input
// Use browser-compatible H.264 baseline/main profile and AAC-LC
cmd := exec.CommandContext(ctx, "ffmpeg",
"-i", "pipe:0", // Read from stdin
// Video: H.264 with browser-compatible settings
"-c:v", "libx264",
"-preset", "veryfast",
"-crf", "23",
"-profile:v", "main",
"-level", "4.0",
"-pix_fmt", "yuv420p",
// Audio: AAC-LC stereo
"-c:a", "aac",
"-b:a", "128k",
"-ac", "2",
"-ar", "44100",
// HLS output
"-f", "hls",
"-hls_time", "4",
"-hls_list_size", "0",
"-hls_flags", "append_list+independent_segments",
"-hls_segment_type", "mpegts",
"-hls_segment_filename", filepath.Join(outputDir, "segment_%03d.ts"),
"-start_number", "0",
playlist,
)
// Pipe the reader to ffmpeg's stdin
cmd.Stdin = reader
session.cmd = cmd
// Capture stderr for debugging
cmd.Stderr = &ffmpegLogger{logger: t.logger, infoHash: infoHash}
if err := cmd.Start(); err != nil {
cancel()
os.RemoveAll(outputDir)
return nil, fmt.Errorf("failed to start ffmpeg: %w", err)
}
t.sessions[infoHash] = session
// Wait for playlist to be created
go func() {
defer close(session.ready)
for i := 0; i < 120; i++ { // Wait up to 60 seconds
if _, err := os.Stat(playlist); err == nil {
// Check if at least one segment exists
segments, _ := filepath.Glob(filepath.Join(outputDir, "segment_*.ts"))
if len(segments) > 0 {
t.logger.Info("HLS segments ready", "hash", infoHash, "segments", len(segments))
return
}
}
time.Sleep(500 * time.Millisecond)
}
session.err = fmt.Errorf("timeout waiting for HLS segments")
cancel()
}()
// Monitor process
go func() {
err := cmd.Wait()
if err != nil && ctx.Err() == nil {
t.logger.Error("ffmpeg exited with error", "hash", infoHash, "error", err)
} else {
t.logger.Info("ffmpeg finished", "hash", infoHash)
}
}()
return session, nil
}
// GetSession returns an existing session
func (t *HLSTranscoder) GetSession(infoHash string) (*HLSSession, bool) {
t.mu.RLock()
defer t.mu.RUnlock()
session, ok := t.sessions[infoHash]
if ok {
session.mu.Lock()
session.lastAccess = time.Now()
session.mu.Unlock()
}
return session, ok
}
// StopSession stops a transcoding session
func (t *HLSTranscoder) StopSession(infoHash string) {
t.mu.Lock()
defer t.mu.Unlock()
if session, ok := t.sessions[infoHash]; ok {
session.cancel()
os.RemoveAll(session.OutputDir)
delete(t.sessions, infoHash)
}
}
// WaitReady waits for the session to have segments ready
func (s *HLSSession) WaitReady(ctx context.Context) error {
select {
case <-s.ready:
return s.err
case <-ctx.Done():
return ctx.Err()
}
}
// cleanupLoop removes stale sessions
func (t *HLSTranscoder) cleanupLoop() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for range ticker.C {
t.mu.Lock()
now := time.Now()
for hash, session := range t.sessions {
session.mu.Lock()
if now.Sub(session.lastAccess) > 10*time.Minute {
session.cancel()
os.RemoveAll(session.OutputDir)
delete(t.sessions, hash)
t.logger.Info("cleaned up stale HLS session", "hash", hash)
}
session.mu.Unlock()
}
t.mu.Unlock()
}
}
// Shutdown stops all sessions
func (t *HLSTranscoder) Shutdown() {
t.mu.Lock()
defer t.mu.Unlock()
for hash, session := range t.sessions {
session.cancel()
os.RemoveAll(session.OutputDir)
delete(t.sessions, hash)
}
os.RemoveAll(t.baseDir)
}
// ffmpegLogger logs ffmpeg stderr output
type ffmpegLogger struct {
logger *slog.Logger
infoHash string
}
func (l *ffmpegLogger) Write(p []byte) (n int, err error) {
l.logger.Debug("ffmpeg", "hash", l.infoHash, "output", string(p))
return len(p), nil
}

View File

@@ -0,0 +1,423 @@
package streaming
import (
"context"
"fmt"
"log/slog"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/anacrolix/torrent"
"github.com/anacrolix/torrent/metainfo"
"golang.org/x/net/proxy"
"mal/internal/nyaa"
)
type Service struct {
client *torrent.Client
nyaa *nyaa.Client
hls *HLSTranscoder
mu sync.RWMutex
activeTorrents map[string]*torrent.Torrent
logger *slog.Logger
}
type StreamInfo struct {
InfoHash string
Name string
Size int64
Files []FileInfo
Progress float64
DownloadRate int64
Peers int
}
type FileInfo struct {
Index int
Path string
Size int64
}
func NewService(logger *slog.Logger) (*Service, error) {
cfg := torrent.NewDefaultClientConfig()
cfg.ListenPort = 42069
cfg.Seed = false
cfg.NoUpload = true
// Use temp directory for downloads
cfg.DataDir = filepath.Join("/tmp", "mal-streams")
// Configure SOCKS5 proxy if TORRENT_PROXY is set
// Usage: export TORRENT_PROXY=socks5://127.0.0.1:1080
// Start with: ssh -D 1080 -N user@your-vps
if proxyURL := os.Getenv("TORRENT_PROXY"); proxyURL != "" {
parsed, err := url.Parse(proxyURL)
if err != nil {
return nil, fmt.Errorf("invalid TORRENT_PROXY url: %w", err)
}
dialer, err := proxy.FromURL(parsed, proxy.Direct)
if err != nil {
return nil, fmt.Errorf("failed to create proxy dialer: %w", err)
}
// Proxy HTTP requests (trackers, webseeds)
cfg.HTTPProxy = func(*http.Request) (*url.URL, error) {
return parsed, nil
}
// Proxy peer connections via DialContext
if contextDialer, ok := dialer.(proxy.ContextDialer); ok {
cfg.HTTPDialContext = contextDialer.DialContext
}
logger.Info("torrent proxy configured", "proxy", proxyURL)
}
client, err := torrent.NewClient(cfg)
if err != nil {
return nil, fmt.Errorf("failed to create torrent client: %w", err)
}
hls, err := NewHLSTranscoder(logger)
if err != nil {
logger.Warn("HLS transcoding unavailable", "error", err)
// Continue without HLS - will fall back to direct streaming
}
return &Service{
client: client,
nyaa: nyaa.NewClient(),
hls: hls,
activeTorrents: make(map[string]*torrent.Torrent),
logger: logger,
}, nil
}
// SearchEpisode searches nyaa for torrents of a specific episode
func (s *Service) SearchEpisode(animeTitle string, episode int) ([]nyaa.Torrent, error) {
return s.nyaa.SearchEpisode(animeTitle, episode)
}
// SearchAnime searches nyaa for torrents of an anime
func (s *Service) SearchAnime(query string) ([]nyaa.Torrent, error) {
return s.nyaa.SearchAnime(query)
}
// AddMagnet adds a magnet link and returns stream info
func (s *Service) AddMagnet(ctx context.Context, magnetURI string) (*StreamInfo, error) {
t, err := s.client.AddMagnet(magnetURI)
if err != nil {
return nil, fmt.Errorf("failed to add magnet: %w", err)
}
// Wait for metadata with timeout
select {
case <-t.GotInfo():
case <-ctx.Done():
t.Drop()
return nil, ctx.Err()
case <-time.After(60 * time.Second):
t.Drop()
return nil, fmt.Errorf("timeout waiting for torrent metadata")
}
infoHash := t.InfoHash().HexString()
s.mu.Lock()
s.activeTorrents[infoHash] = t
s.mu.Unlock()
return s.getStreamInfo(t), nil
}
// GetTorrent returns an active torrent by info hash
func (s *Service) GetTorrent(infoHash string) (*torrent.Torrent, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
t, ok := s.activeTorrents[infoHash]
return t, ok
}
// StreamFile streams a specific file from a torrent
func (s *Service) StreamFile(w http.ResponseWriter, r *http.Request, infoHash string, fileIdx int) error {
t, ok := s.GetTorrent(infoHash)
if !ok {
return fmt.Errorf("torrent not found: %s", infoHash)
}
files := t.Files()
if fileIdx < 0 || fileIdx >= len(files) {
return fmt.Errorf("invalid file index: %d", fileIdx)
}
file := files[fileIdx]
reader := file.NewReader()
reader.SetReadahead(file.Length() / 100) // 1% readahead
reader.SetResponsive()
// Determine content type
contentType := "application/octet-stream"
ext := strings.ToLower(filepath.Ext(file.Path()))
switch ext {
case ".mp4":
contentType = "video/mp4"
case ".mkv":
contentType = "video/x-matroska"
case ".webm":
contentType = "video/webm"
case ".avi":
contentType = "video/x-msvideo"
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Accept-Ranges", "bytes")
// Handle range requests for seeking
http.ServeContent(w, r, file.Path(), time.Time{}, reader)
return nil
}
// StreamVideo finds and streams the main video file from a torrent
func (s *Service) StreamVideo(w http.ResponseWriter, r *http.Request, infoHash string) error {
t, ok := s.GetTorrent(infoHash)
if !ok {
return fmt.Errorf("torrent not found: %s", infoHash)
}
// Find the largest video file
var bestFile *torrent.File
var bestIdx int
for i, f := range t.Files() {
if isVideoFile(f.Path()) {
if bestFile == nil || f.Length() > bestFile.Length() {
bestFile = f
bestIdx = i
}
}
}
if bestFile == nil {
return fmt.Errorf("no video file found in torrent")
}
return s.StreamFile(w, r, infoHash, bestIdx)
}
// GetStreamInfo returns info about an active torrent
func (s *Service) GetStreamInfo(infoHash string) (*StreamInfo, error) {
t, ok := s.GetTorrent(infoHash)
if !ok {
return nil, fmt.Errorf("torrent not found: %s", infoHash)
}
return s.getStreamInfo(t), nil
}
func (s *Service) getStreamInfo(t *torrent.Torrent) *StreamInfo {
info := &StreamInfo{
InfoHash: t.InfoHash().HexString(),
Name: t.Name(),
Peers: t.Stats().ActivePeers,
}
var totalLength, completed int64
for i, f := range t.Files() {
info.Files = append(info.Files, FileInfo{
Index: i,
Path: f.Path(),
Size: f.Length(),
})
totalLength += f.Length()
completed += f.BytesCompleted()
}
info.Size = totalLength
if totalLength > 0 {
info.Progress = float64(completed) / float64(totalLength) * 100
}
stats := t.Stats()
info.DownloadRate = stats.ConnStats.BytesReadData.Int64()
return info
}
// DropTorrent removes a torrent from the client
func (s *Service) DropTorrent(infoHash string) {
s.mu.Lock()
defer s.mu.Unlock()
if t, ok := s.activeTorrents[infoHash]; ok {
t.Drop()
delete(s.activeTorrents, infoHash)
}
}
// Close shuts down the torrent client
func (s *Service) Close() {
s.mu.Lock()
for _, t := range s.activeTorrents {
t.Drop()
}
s.activeTorrents = nil
s.mu.Unlock()
if s.hls != nil {
s.hls.Shutdown()
}
s.client.Close()
}
func isVideoFile(path string) bool {
videoExts := []string{".mp4", ".mkv", ".avi", ".mov", ".webm", ".wmv", ".flv"}
lower := strings.ToLower(path)
for _, ext := range videoExts {
if strings.HasSuffix(lower, ext) {
return true
}
}
return false
}
// ParseMagnetHash extracts info hash from a magnet URI
func ParseMagnetHash(magnetURI string) (string, error) {
spec, err := torrent.TorrentSpecFromMagnetUri(magnetURI)
if err != nil {
return "", err
}
return spec.InfoHash.HexString(), nil
}
// MagnetFromHash creates a minimal magnet URI from an info hash
func MagnetFromHash(infoHash string) (string, error) {
var ih metainfo.Hash
if err := ih.FromHexString(infoHash); err != nil {
return "", err
}
return fmt.Sprintf("magnet:?xt=urn:btih:%s", ih.HexString()), nil
}
// GetVideoFilePath returns the filesystem path to the main video file
func (s *Service) GetVideoFilePath(infoHash string) (string, error) {
t, ok := s.GetTorrent(infoHash)
if !ok {
return "", fmt.Errorf("torrent not found: %s", infoHash)
}
// Find the largest video file
var bestFile *torrent.File
for _, f := range t.Files() {
if isVideoFile(f.Path()) {
if bestFile == nil || f.Length() > bestFile.Length() {
bestFile = f
}
}
}
if bestFile == nil {
return "", fmt.Errorf("no video file found in torrent")
}
// Return path relative to data dir
return filepath.Join("/tmp", "mal-streams", bestFile.Path()), nil
}
// StartHLS starts HLS transcoding for a torrent
func (s *Service) StartHLS(ctx context.Context, infoHash string) (*HLSSession, error) {
if s.hls == nil {
return nil, fmt.Errorf("HLS transcoding not available (ffmpeg not found)")
}
// Check if session already exists
if session, ok := s.hls.GetSession(infoHash); ok {
return session, nil
}
// Get torrent and video file
t, ok := s.GetTorrent(infoHash)
if !ok {
return nil, fmt.Errorf("torrent not found: %s", infoHash)
}
// Find the largest video file
var videoFile *torrent.File
for _, f := range t.Files() {
if isVideoFile(f.Path()) {
if videoFile == nil || f.Length() > videoFile.Length() {
videoFile = f
}
}
}
if videoFile == nil {
return nil, fmt.Errorf("no video file found in torrent")
}
// Prioritize downloading the beginning of the file for ffmpeg
videoFile.Download()
reader := videoFile.NewReader()
reader.SetReadahead(10 * 1024 * 1024) // 10MB readahead
reader.SetResponsive()
// Wait for at least 2MB to be available before starting ffmpeg
minBytes := int64(2 * 1024 * 1024)
s.logger.Info("waiting for initial data", "hash", infoHash, "need", minBytes)
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
timeout := time.After(60 * time.Second)
for {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-timeout:
return nil, fmt.Errorf("timeout waiting for video data")
case <-ticker.C:
completed := videoFile.BytesCompleted()
if completed >= minBytes {
s.logger.Info("got enough data, starting transcoding", "hash", infoHash, "bytes", completed)
goto ready
}
s.logger.Debug("waiting for data", "hash", infoHash, "completed", completed, "need", minBytes)
}
}
ready:
// Start transcoding with the reader piped to ffmpeg
session, err := s.hls.StartSessionWithReader(infoHash, reader)
if err != nil {
return nil, err
}
// Wait for first segments to be ready
waitCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
if err := session.WaitReady(waitCtx); err != nil {
s.hls.StopSession(infoHash)
return nil, fmt.Errorf("HLS not ready: %w", err)
}
return session, nil
}
// GetHLSSession returns an existing HLS session
func (s *Service) GetHLSSession(infoHash string) (*HLSSession, bool) {
if s.hls == nil {
return nil, false
}
return s.hls.GetSession(infoHash)
}
// HasHLS returns whether HLS transcoding is available
func (s *Service) HasHLS() bool {
return s.hls != nil
}

View File

@@ -56,6 +56,14 @@ templ AnimeDetails(anime jikan.Anime, currentStatus string) {
<span>loading relations</span>
</div>
</section>
<section class="anime-episodes" hx-get={ string(templ.URL(fmt.Sprintf("/api/anime/%d/episodes", anime.MalID))) } hx-trigger="load">
<div class="loading-indicator">
<div class="loading-dot"></div>
<div class="loading-dot"></div>
<div class="loading-dot"></div>
<span>loading episodes</span>
</div>
</section>
</div>
<aside class="anime-sidebar">
if anime.TitleJapanese != "" {

View File

@@ -251,349 +251,362 @@ func AnimeDetails(anime jikan.Anime, currentStatus string) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\" hx-trigger=\"load\"><div class=\"loading-indicator\"><div class=\"loading-dot\"></div><div class=\"loading-dot\"></div><div class=\"loading-dot\"></div><span>loading relations</span></div></section></div><aside class=\"anime-sidebar\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if anime.TitleJapanese != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<div class=\"sidebar-row\"><span class=\"sidebar-label\">Japanese</span> <span class=\"sidebar-value\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\" hx-trigger=\"load\"><div class=\"loading-indicator\"><div class=\"loading-dot\"></div><div class=\"loading-dot\"></div><div class=\"loading-dot\"></div><span>loading relations</span></div></section><section class=\"anime-episodes\" hx-get=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(anime.TitleJapanese)
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(string(templ.URL(fmt.Sprintf("/api/anime/%d/episodes", anime.MalID))))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 64, Col: 55}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 59, Col: 114}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</span></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\" hx-trigger=\"load\"><div class=\"loading-indicator\"><div class=\"loading-dot\"></div><div class=\"loading-dot\"></div><div class=\"loading-dot\"></div><span>loading episodes</span></div></section></div><aside class=\"anime-sidebar\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if len(anime.TitleSynonyms) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<div class=\"sidebar-row\"><span class=\"sidebar-label\">Synonyms</span> <span class=\"sidebar-value\">")
if anime.TitleJapanese != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<div class=\"sidebar-row\"><span class=\"sidebar-label\">Japanese</span> <span class=\"sidebar-value\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(strings.Join(anime.TitleSynonyms, ", "))
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(anime.TitleJapanese)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 70, Col: 75}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 72, Col: 55}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</span></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</span></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if anime.Aired.String != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<div class=\"sidebar-row\"><span class=\"sidebar-label\">Aired</span> <span class=\"sidebar-value\">")
if len(anime.TitleSynonyms) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<div class=\"sidebar-row\"><span class=\"sidebar-label\">Synonyms</span> <span class=\"sidebar-value\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(anime.Aired.String)
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(strings.Join(anime.TitleSynonyms, ", "))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 76, Col: 54}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 78, Col: 75}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</span></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</span></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if anime.Premiered() != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<div class=\"sidebar-row\"><span class=\"sidebar-label\">Premiered</span> <span class=\"sidebar-value\">")
if anime.Aired.String != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<div class=\"sidebar-row\"><span class=\"sidebar-label\">Aired</span> <span class=\"sidebar-value\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(anime.Premiered())
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(anime.Aired.String)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 82, Col: 53}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 84, Col: 54}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</span></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</span></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if anime.Duration != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<div class=\"sidebar-row\"><span class=\"sidebar-label\">Duration</span> <span class=\"sidebar-value\">")
if anime.Premiered() != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<div class=\"sidebar-row\"><span class=\"sidebar-label\">Premiered</span> <span class=\"sidebar-value\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(anime.Duration)
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(anime.Premiered())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 88, Col: 50}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 90, Col: 53}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</span></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</span></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if anime.Status != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<div class=\"sidebar-row\"><span class=\"sidebar-label\">Status</span> <span class=\"sidebar-value\">")
if anime.Duration != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<div class=\"sidebar-row\"><span class=\"sidebar-label\">Duration</span> <span class=\"sidebar-value\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(anime.Status)
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(anime.Duration)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 94, Col: 48}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 96, Col: 50}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</span></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</span></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if anime.Score > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<div class=\"sidebar-row\"><span class=\"sidebar-label\">MAL Score</span> <span class=\"sidebar-value\">")
if anime.Status != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<div class=\"sidebar-row\"><span class=\"sidebar-label\">Status</span> <span class=\"sidebar-value\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.2f", anime.Score))
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(anime.Status)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 100, Col: 68}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 102, Col: 48}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</span></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</span></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if len(anime.Genres) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<div class=\"sidebar-row sidebar-row-wrap\"><span class=\"sidebar-label\">Genres</span><div class=\"sidebar-tags\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, g := range anime.Genres {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<span class=\"sidebar-tag\">")
if anime.Score > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<div class=\"sidebar-row\"><span class=\"sidebar-label\">MAL Score</span> <span class=\"sidebar-value\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(g.Name)
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.2f", anime.Score))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 108, Col: 42}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 108, Col: 68}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</span>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</span></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</div></div>")
if len(anime.Genres) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<div class=\"sidebar-row sidebar-row-wrap\"><span class=\"sidebar-label\">Genres</span><div class=\"sidebar-tags\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if len(anime.Studios) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<div class=\"sidebar-row\"><span class=\"sidebar-label\">Studios</span> <span class=\"sidebar-value\">")
for _, g := range anime.Genres {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<span class=\"sidebar-tag\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(joinNames(anime.Studios))
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(g.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 116, Col: 60}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 116, Col: 42}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</span></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if len(anime.Producers) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<div class=\"sidebar-row\"><span class=\"sidebar-label\">Producers</span> <span class=\"sidebar-value\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if len(anime.Studios) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<div class=\"sidebar-row\"><span class=\"sidebar-label\">Studios</span> <span class=\"sidebar-value\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(joinNames(anime.Producers))
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(joinNames(anime.Studios))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 122, Col: 61}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 124, Col: 60}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</span></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</span></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if anime.Source != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<div class=\"sidebar-row\"><span class=\"sidebar-label\">Source</span> <span class=\"sidebar-value\">")
if len(anime.Producers) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<div class=\"sidebar-row\"><span class=\"sidebar-label\">Producers</span> <span class=\"sidebar-value\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(anime.Source)
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(joinNames(anime.Producers))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 128, Col: 47}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 130, Col: 61}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</span></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</span></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if len(anime.Demographics) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<div class=\"sidebar-row sidebar-row-wrap\"><span class=\"sidebar-label\">Demographics</span><div class=\"sidebar-tags\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, d := range anime.Demographics {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<span class=\"sidebar-tag\">")
if anime.Source != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<div class=\"sidebar-row\"><span class=\"sidebar-label\">Source</span> <span class=\"sidebar-value\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(d.Name)
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(anime.Source)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 136, Col: 41}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 136, Col: 47}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</span>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</span></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</div></div>")
if len(anime.Demographics) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<div class=\"sidebar-row sidebar-row-wrap\"><span class=\"sidebar-label\">Demographics</span><div class=\"sidebar-tags\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if len(anime.Themes) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<div class=\"sidebar-row sidebar-row-wrap\"><span class=\"sidebar-label\">Themes</span><div class=\"sidebar-tags\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, t := range anime.Themes {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<span class=\"sidebar-tag\">")
for _, d := range anime.Demographics {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<span class=\"sidebar-tag\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(t.Name)
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(d.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 146, Col: 41}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 144, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</span>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if anime.Broadcast.String != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<div class=\"sidebar-row\"><span class=\"sidebar-label\">Broadcast</span> <span class=\"sidebar-value\">")
if len(anime.Themes) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<div class=\"sidebar-row sidebar-row-wrap\"><span class=\"sidebar-label\">Themes</span><div class=\"sidebar-tags\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, t := range anime.Themes {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<span class=\"sidebar-tag\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var26 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(anime.Broadcast.String)
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(t.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 154, Col: 57}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 154, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</span></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if len(anime.Streaming) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "<div class=\"sidebar-row\"><span class=\"sidebar-label\">Streaming</span><div class=\"sidebar-value\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, s := range anime.Streaming {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<div><a href=\"")
}
if anime.Broadcast.String != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "<div class=\"sidebar-row\"><span class=\"sidebar-label\">Broadcast</span> <span class=\"sidebar-value\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var27 templ.SafeURL
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL(s.URL))
var templ_7745c5c3_Var27 string
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(anime.Broadcast.String)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 162, Col: 38}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 162, Col: 57}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "\" target=\"_blank\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</span></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var28 string
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(s.Name)
}
if len(anime.Streaming) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<div class=\"sidebar-row\"><span class=\"sidebar-label\">Streaming</span><div class=\"sidebar-value\">")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 162, Col: 65}
return templ_7745c5c3_Err
}
for _, s := range anime.Streaming {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "<div><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var28 templ.SafeURL
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL(s.URL))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 170, Col: 38}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "</a></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "\" target=\"_blank\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var29 string
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(s.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 170, Col: 65}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</a></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "</aside></div><script>\n\t\t\tfunction toggleDropdown() {\n\t\t\t\tdocument.getElementById('watchlist-dropdown').classList.toggle('open');\n\t\t\t}\n\t\t\tdocument.addEventListener('click', function(e) {\n\t\t\t\tconst dropdown = document.getElementById('watchlist-dropdown');\n\t\t\t\tif (dropdown && !dropdown.contains(e.target)) {\n\t\t\t\t\tdropdown.classList.remove('open');\n\t\t\t\t}\n\t\t\t});\n\t\t</script>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "</aside></div><script>\n\t\t\tfunction toggleDropdown() {\n\t\t\t\tdocument.getElementById('watchlist-dropdown').classList.toggle('open');\n\t\t\t}\n\t\t\tdocument.addEventListener('click', function(e) {\n\t\t\t\tconst dropdown = document.getElementById('watchlist-dropdown');\n\t\t\t\tif (dropdown && !dropdown.contains(e.target)) {\n\t\t\t\t\tdropdown.classList.remove('open');\n\t\t\t\t}\n\t\t\t});\n\t\t</script>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -631,36 +644,36 @@ func WatchlistDropdown(animeID int, animeTitle string, animeTitleEnglish string,
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var29 := templ.GetChildren(ctx)
if templ_7745c5c3_Var29 == nil {
templ_7745c5c3_Var29 = templ.NopComponent
templ_7745c5c3_Var30 := templ.GetChildren(ctx)
if templ_7745c5c3_Var30 == nil {
templ_7745c5c3_Var30 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<div class=\"dropdown\" id=\"watchlist-dropdown\"><button class=\"dropdown-trigger\" onclick=\"toggleDropdown()\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "<div class=\"dropdown\" id=\"watchlist-dropdown\"><button class=\"dropdown-trigger\" onclick=\"toggleDropdown()\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if currentStatus != "" {
var templ_7745c5c3_Var30 string
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(formatStatus(currentStatus))
var templ_7745c5c3_Var31 string
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(formatStatus(currentStatus))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 195, Col: 33}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 203, Col: 33}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, " ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, " ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "add to watchlist ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "add to watchlist ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "<span class=\"dropdown-arrow\">▾</span></button><div class=\"dropdown-menu\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "<span class=\"dropdown-arrow\">▾</span></button><div class=\"dropdown-menu\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -685,25 +698,25 @@ func WatchlistDropdown(animeID int, animeTitle string, animeTitleEnglish string,
return templ_7745c5c3_Err
}
if currentStatus != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "<div class=\"dropdown-divider\"></div><button class=\"dropdown-item remove\" hx-delete=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "<div class=\"dropdown-divider\"></div><button class=\"dropdown-item remove\" hx-delete=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var31 string
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(string(templ.URL(fmt.Sprintf("/api/watchlist/%d", animeID))))
var templ_7745c5c3_Var32 string
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(string(templ.URL(fmt.Sprintf("/api/watchlist/%d", animeID))))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 211, Col: 77}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 219, Col: 77}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "\" hx-target=\"#watchlist-dropdown\" hx-swap=\"outerHTML\">remove from list</button>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "\" hx-target=\"#watchlist-dropdown\" hx-swap=\"outerHTML\">remove from list</button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "</div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -727,66 +740,66 @@ func dropdownStatusOption(animeID int, animeTitle string, animeTitleEnglish stri
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var32 := templ.GetChildren(ctx)
if templ_7745c5c3_Var32 == nil {
templ_7745c5c3_Var32 = templ.NopComponent
templ_7745c5c3_Var33 := templ.GetChildren(ctx)
if templ_7745c5c3_Var33 == nil {
templ_7745c5c3_Var33 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
var templ_7745c5c3_Var33 = []any{"dropdown-item", templ.KV("active", status == currentStatus)}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var33...)
var templ_7745c5c3_Var34 = []any{"dropdown-item", templ.KV("active", status == currentStatus)}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var34...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "<button class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var34 string
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var33).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "\" hx-post=\"/api/watchlist\" hx-vals=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "<button class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var35 string
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf(`{"anime_id": "%d", "anime_title": "%s", "anime_title_english": "%s", "anime_title_japanese": "%s", "anime_image": "%s", "status": "%s", "airing": "%v"}`, animeID, animeTitle, animeTitleEnglish, animeTitleJapanese, animeImage, status, airing))
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var34).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 224, Col: 266}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "\" hx-target=\"#watchlist-dropdown\" hx-swap=\"outerHTML\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "\" hx-post=\"/api/watchlist\" hx-vals=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var36 string
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(formatStatus(status))
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf(`{"anime_id": "%d", "anime_title": "%s", "anime_title_english": "%s", "anime_title_japanese": "%s", "anime_image": "%s", "status": "%s", "airing": "%v"}`, animeID, animeTitle, animeTitleEnglish, animeTitleJapanese, animeImage, status, airing))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 228, Col: 24}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 232, Col: 266}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, " ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "\" hx-target=\"#watchlist-dropdown\" hx-swap=\"outerHTML\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var37 string
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(formatStatus(status))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 236, Col: 24}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, " ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if status == currentStatus {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "<span class=\"check\">✓</span>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "<span class=\"check\">✓</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "</button>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "</button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -810,66 +823,66 @@ func statusOption(anime jikan.Anime, status string, currentStatus string) templ.
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var37 := templ.GetChildren(ctx)
if templ_7745c5c3_Var37 == nil {
templ_7745c5c3_Var37 = templ.NopComponent
templ_7745c5c3_Var38 := templ.GetChildren(ctx)
if templ_7745c5c3_Var38 == nil {
templ_7745c5c3_Var38 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
var templ_7745c5c3_Var38 = []any{"dropdown-item", templ.KV("active", status == currentStatus)}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var38...)
var templ_7745c5c3_Var39 = []any{"dropdown-item", templ.KV("active", status == currentStatus)}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var39...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<button class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var39 string
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var38).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "\" hx-post=\"/api/watchlist\" hx-vals=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<button class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var40 string
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf(`{"anime_id": "%d", "anime_title": "%s", "anime_title_english": "%s", "anime_title_japanese": "%s", "anime_image": "%s", "status": "%s"}`, anime.MalID, anime.Title, anime.TitleEnglish, anime.TitleJapanese, anime.ImageURL(), status))
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var39).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 239, Col: 255}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "\" hx-target=\"#watchlist-dropdown\" hx-swap=\"outerHTML\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "\" hx-post=\"/api/watchlist\" hx-vals=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var41 string
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(formatStatus(status))
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf(`{"anime_id": "%d", "anime_title": "%s", "anime_title_english": "%s", "anime_title_japanese": "%s", "anime_image": "%s", "status": "%s"}`, anime.MalID, anime.Title, anime.TitleEnglish, anime.TitleJapanese, anime.ImageURL(), status))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 243, Col: 24}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 247, Col: 255}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, " ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "\" hx-target=\"#watchlist-dropdown\" hx-swap=\"outerHTML\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var42 string
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(formatStatus(status))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 251, Col: 24}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, " ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if status == currentStatus {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "<span class=\"check\">✓</span>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "<span class=\"check\">✓</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "</button>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "</button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -910,151 +923,151 @@ func AnimeRelationsList(relations []jikan.RelationEntry) templ.Component {
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var42 := templ.GetChildren(ctx)
if templ_7745c5c3_Var42 == nil {
templ_7745c5c3_Var42 = templ.NopComponent
templ_7745c5c3_Var43 := templ.GetChildren(ctx)
if templ_7745c5c3_Var43 == nil {
templ_7745c5c3_Var43 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
if len(relations) > 1 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "<h3>related</h3><div class=\"relations-grid\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "<h3>related</h3><div class=\"relations-grid\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, rel := range relations {
if rel.IsCurrent {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "<div class=\"relation-card current\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "<div class=\"relation-card current\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if rel.Anime.ImageURL() != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "<img src=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var43 string
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(rel.Anime.ImageURL())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 275, Col: 38}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "\" alt=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "<img src=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var44 string
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(rel.Anime.DisplayTitle())
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(rel.Anime.ImageURL())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 275, Col: 71}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 283, Col: 38}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "\" class=\"relation-thumb\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "<div class=\"no-image\">no image</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "<div class=\"relation-title\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "\" alt=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var45 string
templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(rel.Anime.DisplayTitle())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 279, Col: 60}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 283, Col: 71}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "</div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "\" class=\"relation-thumb\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "<a href=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "<div class=\"no-image\">no image</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var46 templ.SafeURL
templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL(fmt.Sprintf("/anime/%d", rel.Anime.MalID)))
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "<div class=\"relation-title\">")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 282, Col: 67}
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var46 string
templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(rel.Anime.DisplayTitle())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 287, Col: 60}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "\" class=\"relation-card\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if rel.Anime.ImageURL() != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "<img src=\"")
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var47 string
templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(rel.Anime.ImageURL())
var templ_7745c5c3_Var47 templ.SafeURL
templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL(fmt.Sprintf("/anime/%d", rel.Anime.MalID)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 284, Col: 38}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 290, Col: 67}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "\" alt=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "\" class=\"relation-card\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if rel.Anime.ImageURL() != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "<img src=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var48 string
templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(rel.Anime.DisplayTitle())
templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(rel.Anime.ImageURL())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 284, Col: 71}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 292, Col: 38}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "\" class=\"relation-thumb\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "<div class=\"no-image\">no image</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "<div class=\"relation-title\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "\" alt=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var49 string
templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(rel.Anime.DisplayTitle())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 288, Col: 60}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 292, Col: 71}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "</div></a>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "\" class=\"relation-thumb\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "<div class=\"no-image\">no image</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "<div class=\"relation-title\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var50 string
templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(rel.Anime.DisplayTitle())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 296, Col: 60}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "</div></a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "</div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}

View File

@@ -0,0 +1,72 @@
package templates
import "mal/internal/jikan"
import "fmt"
// EpisodesList renders a list of episodes for an anime
templ EpisodesList(animeID int, animeTitle string, episodes []jikan.Episode, totalEpisodes int) {
<section class="episodes-section">
<div class="episodes-header">
<h3>episodes</h3>
if totalEpisodes > 0 {
<span class="episode-count">{ fmt.Sprintf("%d episodes", totalEpisodes) }</span>
}
</div>
if len(episodes) == 0 {
<p class="no-episodes">no episode data available</p>
} else {
<div class="episodes-grid">
for _, ep := range episodes {
@EpisodeCard(animeID, animeTitle, ep)
}
</div>
}
</section>
}
// EpisodeCard renders a single episode card with play button
templ EpisodeCard(animeID int, animeTitle string, ep jikan.Episode) {
<div class="episode-card" data-episode={ fmt.Sprintf("%d", ep.MalID) }>
<div class="episode-number">{ fmt.Sprintf("%d", ep.MalID) }</div>
<div class="episode-info">
<div class="episode-title">
if ep.Title != "" {
{ ep.Title }
} else {
{ fmt.Sprintf("Episode %d", ep.MalID) }
}
</div>
if ep.Filler {
<span class="episode-tag filler">filler</span>
}
if ep.Recap {
<span class="episode-tag recap">recap</span>
}
</div>
<button
class="episode-play"
hx-get={ string(templ.URL(fmt.Sprintf("/watch/%d/%d", animeID, ep.MalID))) }
hx-target="body"
hx-push-url="true"
>
<svg viewBox="0 0 24 24" fill="currentColor" class="play-icon">
<path d="M8 5v14l11-7z"/>
</svg>
</button>
</div>
}
// EpisodesLoading renders a loading state for episodes
templ EpisodesLoading() {
<section class="episodes-section">
<div class="episodes-header">
<h3>episodes</h3>
</div>
<div class="loading-indicator">
<div class="loading-dot"></div>
<div class="loading-dot"></div>
<div class="loading-dot"></div>
<span>loading episodes</span>
</div>
</section>
}

View File

@@ -0,0 +1,232 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001
package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import "mal/internal/jikan"
import "fmt"
// EpisodesList renders a list of episodes for an anime
func EpisodesList(animeID int, animeTitle string, episodes []jikan.Episode, totalEpisodes int) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<section class=\"episodes-section\"><div class=\"episodes-header\"><h3>episodes</h3>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if totalEpisodes > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<span class=\"episode-count\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d episodes", totalEpisodes))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/episodes.templ`, Line: 12, Col: 75}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(episodes) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<p class=\"no-episodes\">no episode data available</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div class=\"episodes-grid\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, ep := range episodes {
templ_7745c5c3_Err = EpisodeCard(animeID, animeTitle, ep).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</section>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// EpisodeCard renders a single episode card with play button
func EpisodeCard(animeID int, animeTitle string, ep jikan.Episode) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var3 := templ.GetChildren(ctx)
if templ_7745c5c3_Var3 == nil {
templ_7745c5c3_Var3 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<div class=\"episode-card\" data-episode=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", ep.MalID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/episodes.templ`, Line: 29, Col: 69}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\"><div class=\"episode-number\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", ep.MalID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/episodes.templ`, Line: 30, Col: 59}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</div><div class=\"episode-info\"><div class=\"episode-title\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if ep.Title != "" {
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(ep.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/episodes.templ`, Line: 34, Col: 15}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("Episode %d", ep.MalID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/episodes.templ`, Line: 36, Col: 42}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if ep.Filler {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<span class=\"episode-tag filler\">filler</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if ep.Recap {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<span class=\"episode-tag recap\">recap</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</div><button class=\"episode-play\" hx-get=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(string(templ.URL(fmt.Sprintf("/watch/%d/%d", animeID, ep.MalID))))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/episodes.templ`, Line: 48, Col: 77}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" hx-target=\"body\" hx-push-url=\"true\"><svg viewBox=\"0 0 24 24\" fill=\"currentColor\" class=\"play-icon\"><path d=\"M8 5v14l11-7z\"></path></svg></button></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// EpisodesLoading renders a loading state for episodes
func EpisodesLoading() templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var9 := templ.GetChildren(ctx)
if templ_7745c5c3_Var9 == nil {
templ_7745c5c3_Var9 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<section class=\"episodes-section\"><div class=\"episodes-header\"><h3>episodes</h3></div><div class=\"loading-indicator\"><div class=\"loading-dot\"></div><div class=\"loading-dot\"></div><div class=\"loading-dot\"></div><span>loading episodes</span></div></section>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate

View File

@@ -0,0 +1,336 @@
package templates
import "mal/internal/jikan"
import "mal/internal/nyaa"
import "fmt"
// WatchPage renders the video player page
templ WatchPage(anime jikan.Anime, episode int, torrents []nyaa.Torrent) {
@Layout(fmt.Sprintf("Watch %s - Episode %d", anime.DisplayTitle(), episode)) {
<div class="watch-page">
<div class="watch-header">
<a href={ templ.URL(fmt.Sprintf("/anime/%d", anime.MalID)) } class="back-link">
<svg viewBox="0 0 24 24" fill="currentColor" class="back-icon">
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/>
</svg>
back to { anime.DisplayTitle() }
</a>
<h1>Episode { fmt.Sprintf("%d", episode) }</h1>
</div>
<div class="watch-content">
<div class="player-container" id="player-container">
<div class="player-placeholder" id="player-placeholder">
<div class="player-message">
<svg viewBox="0 0 24 24" fill="currentColor" class="player-icon">
<path d="M8 5v14l11-7z"/>
</svg>
<p>select a source to start streaming</p>
</div>
</div>
<div id="player-loading" class="player-loading hidden">
<div class="loading-stream">
<div class="loading-dot"></div>
<div class="loading-dot"></div>
<div class="loading-dot"></div>
</div>
<p id="loading-status">connecting to peers...</p>
<p id="loading-progress" class="loading-progress"></p>
</div>
<video
id="video-player"
class="video-player hidden"
controls
playsinline
>
</video>
</div>
<div class="sources-panel">
<h3>available sources</h3>
if len(torrents) == 0 {
<div class="no-sources">
<p>no torrents found for this episode</p>
<p class="hint">try searching manually</p>
</div>
} else {
<div class="sources-list">
for _, t := range torrents {
if t.Magnet != "" {
@TorrentSource(t)
}
}
</div>
}
<div class="manual-search">
<h4>manual search</h4>
<form
hx-get="/api/stream/search-htmx"
hx-target="#search-results"
hx-indicator="#search-loading"
>
<input
type="text"
name="q"
placeholder={ fmt.Sprintf("%s %02d", anime.Title, episode) }
value={ fmt.Sprintf("%s %02d", anime.Title, episode) }
class="search-input"
/>
<button type="submit" class="search-btn">search</button>
</form>
<div id="search-loading" class="htmx-indicator">searching...</div>
<div id="search-results"></div>
</div>
</div>
</div>
<div class="episode-nav">
if episode > 1 {
<a
href={ templ.URL(fmt.Sprintf("/watch/%d/%d", anime.MalID, episode-1)) }
class="nav-btn prev"
>
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
</svg>
previous
</a>
} else {
<span class="nav-btn disabled">previous</span>
}
if anime.Episodes == 0 || episode < anime.Episodes {
<a
href={ templ.URL(fmt.Sprintf("/watch/%d/%d", anime.MalID, episode+1)) }
class="nav-btn next"
>
next
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/>
</svg>
</a>
} else {
<span class="nav-btn disabled">next</span>
}
</div>
</div>
<!-- HLS.js for browser playback -->
<script src="https://cdn.jsdelivr.net/npm/hls.js@1"></script>
<script>
let currentStreamHash = null;
let progressInterval = null;
let hls = null;
function startStream(magnet) {
if (!magnet) {
showError('no magnet link available for this torrent');
return;
}
showLoading('connecting to peers...');
fetch('/api/stream/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ magnet: magnet })
})
.then(res => {
if (!res.ok) {
return res.text().then(text => { throw new Error(text); });
}
return res.json();
})
.then(data => {
if (data.info_hash) {
currentStreamHash = data.info_hash;
showLoading('starting transcoding...');
startProgressUpdates();
startHLS();
} else {
showError('invalid response from server');
}
})
.catch(err => {
showError(err.message || 'failed to start stream');
});
}
function startHLS() {
showLoading('preparing video stream...');
fetch('/api/stream/hls/' + currentStreamHash, {
method: 'POST'
})
.then(res => {
if (!res.ok) {
return res.text().then(text => { throw new Error(text); });
}
return res.json();
})
.then(data => {
playHLS(data.playlist);
})
.catch(err => {
showError('transcoding failed: ' + (err.message || 'unknown error'));
});
}
function playHLS(playlistUrl) {
const video = document.getElementById('video-player');
// Cleanup previous instance
if (hls) {
hls.destroy();
hls = null;
}
document.getElementById('player-placeholder').classList.add('hidden');
document.getElementById('player-loading').classList.add('hidden');
video.classList.remove('hidden');
if (Hls.isSupported()) {
hls = new Hls({
enableWorker: true,
lowLatencyMode: false,
backBufferLength: 90
});
hls.loadSource(playlistUrl);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, function() {
video.play().catch(e => console.log('Autoplay blocked:', e));
});
hls.on(Hls.Events.ERROR, function(event, data) {
if (data.fatal) {
switch(data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
console.error('Network error, trying to recover...');
hls.startLoad();
break;
case Hls.ErrorTypes.MEDIA_ERROR:
console.error('Media error, trying to recover...');
hls.recoverMediaError();
break;
default:
showError('Playback error: ' + data.details);
hls.destroy();
break;
}
}
});
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
// Native HLS support (Safari)
video.src = playlistUrl;
video.addEventListener('loadedmetadata', function() {
video.play().catch(e => console.log('Autoplay blocked:', e));
});
} else {
showError('HLS playback not supported in this browser');
}
}
function showLoading(status) {
document.getElementById('player-placeholder').classList.add('hidden');
document.getElementById('video-player').classList.add('hidden');
document.getElementById('player-loading').classList.remove('hidden');
document.getElementById('loading-status').textContent = status;
document.getElementById('loading-progress').textContent = '';
}
function showError(message) {
stopProgressUpdates();
document.getElementById('player-loading').classList.add('hidden');
document.getElementById('video-player').classList.add('hidden');
const placeholder = document.getElementById('player-placeholder');
placeholder.classList.remove('hidden');
placeholder.innerHTML = '<div class="player-error">' + escapeHtml(message) + '</div>';
}
function startProgressUpdates() {
if (progressInterval) clearInterval(progressInterval);
progressInterval = setInterval(updateProgress, 2000);
updateProgress();
}
function stopProgressUpdates() {
if (progressInterval) {
clearInterval(progressInterval);
progressInterval = null;
}
}
function updateProgress() {
if (!currentStreamHash) return;
fetch('/api/stream/info/' + currentStreamHash)
.then(res => res.json())
.then(data => {
const progress = data.progress.toFixed(1);
const peers = data.peers;
const progressEl = document.getElementById('loading-progress');
if (progressEl) {
progressEl.textContent = progress + '% downloaded | ' + peers + ' peers';
}
})
.catch(() => {});
}
function formatSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Handle torrent selection from search results and source list
document.addEventListener('click', function(e) {
const item = e.target.closest('[data-magnet]');
if (item && item.dataset.magnet) {
e.preventDefault();
// Mark selected
document.querySelectorAll('[data-magnet]').forEach(el => el.classList.remove('selected'));
item.classList.add('selected');
startStream(item.dataset.magnet);
}
});
// Cleanup on page leave
window.addEventListener('beforeunload', function() {
stopProgressUpdates();
if (hls) {
hls.destroy();
}
});
</script>
}
}
// TorrentSource renders a single torrent source option
templ TorrentSource(t nyaa.Torrent) {
<div class="source-item" data-magnet={ t.Magnet }>
<div class="source-title">{ truncateTitle(t.Title, 60) }</div>
<div class="source-meta">
<span class="source-size">{ t.Size }</span>
<span class="source-seeds">{ fmt.Sprintf("%d", t.Seeders) } seeds</span>
</div>
</div>
}
func truncateTitle(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max-3] + "..."
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,3 @@
-- name: AddAnimeTorrentFields :exec
ALTER TABLE anime ADD COLUMN magnet_link TEXT;
ALTER TABLE anime ADD COLUMN torrent_hash TEXT;

View File

@@ -973,3 +973,398 @@ a:visited {
gap: 8px;
}
}
/* Episodes Section */
.episodes-section {
margin-top: 24px;
}
.episodes-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.episodes-header h3 {
font-size: 12px;
font-weight: 600;
color: var(--text);
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0;
}
.episode-count {
font-size: 11px;
color: var(--text-muted);
}
.no-episodes {
color: var(--text-muted);
font-size: 13px;
}
.episodes-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 8px;
}
.episode-card {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
background: var(--surface);
border: 1px solid var(--border);
transition: border-color 0.15s;
}
.episode-card:hover {
border-color: var(--link);
}
.episode-number {
font-size: 14px;
font-weight: 600;
color: var(--text);
min-width: 32px;
}
.episode-info {
flex: 1;
min-width: 0;
}
.episode-title {
font-size: 13px;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.episode-tag {
display: inline-block;
font-size: 10px;
padding: 1px 4px;
background: var(--surface-hover);
border: 1px solid var(--border);
color: var(--text-muted);
margin-top: 4px;
}
.episode-tag.filler {
color: #996600;
border-color: #996600;
}
.episode-tag.recap {
color: #666699;
border-color: #666699;
}
.episode-play {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: var(--surface-hover);
border: 1px solid var(--border);
color: var(--link);
cursor: pointer;
transition: all 0.15s;
}
.episode-play:hover {
background: var(--link);
border-color: var(--link);
color: white;
}
.play-icon {
width: 16px;
height: 16px;
}
/* Watch Page */
.watch-page {
max-width: 1200px;
margin: 0 auto;
}
.watch-header {
margin-bottom: 16px;
}
.back-link {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--link);
margin-bottom: 8px;
}
.back-icon {
width: 16px;
height: 16px;
}
.watch-header h1 {
font-size: 18px;
font-weight: 600;
margin: 0;
}
.watch-content {
display: grid;
grid-template-columns: 1fr 320px;
gap: 16px;
}
.player-container {
background: #000;
aspect-ratio: 16/9;
position: relative;
}
.player-placeholder {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--surface);
}
.player-message {
text-align: center;
color: var(--text-muted);
}
.player-icon {
width: 48px;
height: 48px;
opacity: 0.5;
margin-bottom: 12px;
}
.player-message p {
margin: 0;
font-size: 13px;
}
.video-player {
width: 100%;
height: 100%;
background: #000;
}
.video-player.hidden,
.player-placeholder.hidden {
display: none;
}
.loading-stream {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-muted);
font-size: 13px;
}
.player-error {
color: #cc0000;
font-size: 13px;
}
/* Sources Panel */
.sources-panel {
background: var(--surface);
border: 1px solid var(--border);
padding: 12px;
height: fit-content;
}
.sources-panel h3 {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
margin: 0 0 12px 0;
}
.no-sources {
color: var(--text-muted);
font-size: 13px;
}
.no-sources .hint {
font-size: 11px;
margin-top: 4px;
}
.sources-list {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 16px;
}
.source-item {
padding: 10px;
background: var(--surface-hover);
border: 1px solid var(--border);
cursor: pointer;
transition: border-color 0.15s;
}
.source-item:hover {
border-color: var(--link);
}
.source-title {
font-size: 12px;
color: var(--text);
margin-bottom: 4px;
word-break: break-word;
}
.source-meta {
display: flex;
gap: 12px;
font-size: 11px;
color: var(--text-muted);
}
/* Manual Search */
.manual-search {
border-top: 1px solid var(--border);
padding-top: 12px;
}
.manual-search h4 {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
color: var(--text-muted);
margin: 0 0 8px 0;
}
.manual-search form {
display: flex;
gap: 8px;
}
.manual-search .search-input {
flex: 1;
font-size: 12px;
padding: 6px 8px;
}
.manual-search .search-btn {
padding: 6px 12px;
background: var(--surface-hover);
border: 1px solid var(--border);
color: var(--text);
font-size: 12px;
cursor: pointer;
}
.manual-search .search-btn:hover {
border-color: var(--link);
}
#search-results {
margin-top: 8px;
}
/* Torrent List (from search) */
.torrent-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.torrent-item {
padding: 8px;
background: var(--surface-hover);
border: 1px solid var(--border);
cursor: pointer;
}
.torrent-item:hover {
border-color: var(--link);
}
.torrent-title {
font-size: 11px;
color: var(--text);
margin-bottom: 2px;
word-break: break-word;
}
.torrent-meta {
display: flex;
gap: 8px;
font-size: 10px;
color: var(--text-muted);
}
/* Episode Navigation */
.episode-nav {
display: flex;
justify-content: space-between;
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--border);
}
.nav-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
background: var(--surface);
border: 1px solid var(--border);
color: var(--text);
font-size: 13px;
text-decoration: none;
transition: all 0.15s;
}
.nav-btn:hover {
border-color: var(--link);
color: var(--link);
text-decoration: none;
}
.nav-btn.disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
.nav-btn svg {
width: 16px;
height: 16px;
}
/* Responsive Watch Page */
@media (max-width: 900px) {
.watch-content {
grid-template-columns: 1fr;
}
.sources-panel {
order: 2;
}
}
@media (max-width: 600px) {
.episodes-grid {
grid-template-columns: 1fr;
}
}