diff --git a/Dockerfile b/Dockerfile index a3490ee..f4bcf9c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/cmd/create-user/main.go b/cmd/create-user/main.go index 54ea94d..a49ae65 100644 --- a/cmd/create-user/main.go +++ b/cmd/create-user/main.go @@ -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) } diff --git a/cmd/server/main.go b/cmd/server/main.go index f052b7f..f29e18b 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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) } diff --git a/create-user b/create-user new file mode 100755 index 0000000..decc463 Binary files /dev/null and b/create-user differ diff --git a/go.mod b/go.mod index d829a25..809b0d1 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index f2490e2..b1e7b28 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/database/models.go b/internal/database/models.go index ef4861d..a8529b3 100644 --- a/internal/database/models.go +++ b/internal/database/models.go @@ -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 { diff --git a/internal/database/queries.sql b/internal/database/queries.sql index e35f9f9..142c299 100644 --- a/internal/database/queries.sql +++ b/internal/database/queries.sql @@ -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 = ? diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index 735d106..61a9336 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -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 } diff --git a/internal/features/anime/handler.go b/internal/features/anime/handler.go index 953eb03..f18df87 100644 --- a/internal/features/anime/handler.go +++ b/internal/features/anime/handler.go @@ -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) +} diff --git a/internal/features/anime/service.go b/internal/features/anime/service.go index e53d997..829cf7f 100644 --- a/internal/features/anime/service.go +++ b/internal/features/anime/service.go @@ -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) +} diff --git a/internal/jikan/client.go b/internal/jikan/client.go index 41f9b69..5dabfb7 100644 --- a/internal/jikan/client.go +++ b/internal/jikan/client.go @@ -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 +} diff --git a/internal/jikan/types.go b/internal/jikan/types.go index 627ddf4..25f8063 100644 --- a/internal/jikan/types.go +++ b/internal/jikan/types.go @@ -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 +} diff --git a/internal/nyaa/client.go b/internal/nyaa/client.go new file mode 100644 index 0000000..b763d94 --- /dev/null +++ b/internal/nyaa/client.go @@ -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 +} diff --git a/internal/server/routes.go b/internal/server/routes.go index d7adfa1..1856f88 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -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))) diff --git a/internal/shared/middleware/auth.go b/internal/shared/middleware/auth.go index 567d25d..20c349a 100644 --- a/internal/shared/middleware/auth.go +++ b/internal/shared/middleware/auth.go @@ -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 } diff --git a/internal/streaming/handler.go b/internal/streaming/handler.go new file mode 100644 index 0000000..f9a9305 --- /dev/null +++ b/internal/streaming/handler.go @@ -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, `
enter a search query
`) + return + } + + torrents, err := h.svc.SearchAnime(query) + if err != nil { + fmt.Fprintf(w, `
error: %s
`, 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, `
no torrents found (or no magnet links available)
`) + return + } + + // Return simple HTML list + fmt.Fprint(w, `
`) + for _, t := range withMagnets { + fmt.Fprintf(w, ` +
+
%s
+
+ %s + %d seeds +
+
+ `, html.EscapeString(t.Magnet), html.EscapeString(t.Title), html.EscapeString(t.Size), t.Seeders) + } + fmt.Fprint(w, `
`) +} + +// 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) +} diff --git a/internal/streaming/hls.go b/internal/streaming/hls.go new file mode 100644 index 0000000..016239e --- /dev/null +++ b/internal/streaming/hls.go @@ -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 +} diff --git a/internal/streaming/service.go b/internal/streaming/service.go new file mode 100644 index 0000000..ebadf3d --- /dev/null +++ b/internal/streaming/service.go @@ -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 +} diff --git a/internal/templates/anime.templ b/internal/templates/anime.templ index 36c6057..7443383 100644 --- a/internal/templates/anime.templ +++ b/internal/templates/anime.templ @@ -56,6 +56,14 @@ templ AnimeDetails(anime jikan.Anime, currentStatus string) { loading relations +
+
+
+
+
+ loading episodes +
+