From f9861e3b54b49281ad902e90c332eb89d11f3576 Mon Sep 17 00:00:00 2001 From: Mukhtar Akere Date: Fri, 1 Aug 2025 15:27:24 +0100 Subject: [PATCH] Implementing a streaming setup with Usenet --- .dockerignore | 3 +- .gitignore | 1 + cmd/decypharr/main.go | 31 +- go.mod | 11 +- go.sum | 20 +- internal/config/config.go | 180 +++- internal/nntp/client.go | 178 ++++ internal/nntp/conns.go | 394 ++++++++ internal/nntp/decoder.go | 116 +++ internal/nntp/errors.go | 195 ++++ internal/nntp/pool.go | 299 ++++++ internal/request/request.go | 31 +- internal/utils/misc.go | 139 +++ internal/utils/regex.go | 12 + pkg/qbit/context.go | 44 +- pkg/qbit/qbit.go | 15 +- pkg/rar/rarar.go | 15 - pkg/repair/repair.go | 1 - pkg/sabnzbd/config.go | 171 ++++ pkg/sabnzbd/context.go | 121 +++ pkg/sabnzbd/handlers.go | 476 ++++++++++ pkg/sabnzbd/routes.go | 24 + pkg/sabnzbd/sabnzbd.go | 116 +++ pkg/sabnzbd/types.go | 150 +++ pkg/server/debug.go | 19 + pkg/server/server.go | 5 +- pkg/store/store.go | 14 +- pkg/usenet/cache.go | 141 +++ pkg/usenet/downloader.go | 281 ++++++ pkg/usenet/errors.go | 353 +++++++ pkg/usenet/misc.go | 83 ++ pkg/usenet/nzb.go | 152 +++ pkg/usenet/parser.go | 863 ++++++++++++++++++ pkg/usenet/processor.go | 145 +++ pkg/usenet/rar.go | 336 +++++++ pkg/usenet/store.go | 619 +++++++++++++ pkg/usenet/stream.go | 383 ++++++++ pkg/usenet/types.go | 239 +++++ pkg/usenet/usenet.go | 180 ++++ pkg/web/assets/build/css/styles.css | 2 +- pkg/web/assets/build/js/common.js | 2 +- pkg/web/assets/build/js/config.js | 2 +- pkg/web/assets/build/js/dashboard.js | 2 +- pkg/web/assets/build/js/download.js | 2 +- pkg/web/assets/js/common.js | 2 +- pkg/web/assets/js/config.js | 275 +++++- pkg/web/assets/js/dashboard.js | 707 ++++++++++++-- pkg/web/assets/js/download.js | 227 ++++- pkg/web/{api.go => handlers.go} | 210 ++++- pkg/web/routes.go | 3 + pkg/web/templates/config.html | 148 +++ pkg/web/templates/download.html | 56 +- pkg/web/templates/index.html | 87 +- pkg/web/ui.go | 6 +- pkg/web/web.go | 5 +- pkg/webdav/file.go | 474 +--------- pkg/webdav/misc.go | 25 + pkg/webdav/propfind.go | 9 +- pkg/webdav/torrent_file.go | 472 ++++++++++ pkg/webdav/{handler.go => torrent_handler.go} | 220 +++-- pkg/webdav/usenet_file.go | 263 ++++++ pkg/webdav/usenet_handler.go | 529 +++++++++++ pkg/webdav/webdav.go | 69 +- scripts/download-assets.js | 4 +- scripts/minify-js.js | 4 +- 65 files changed, 9437 insertions(+), 924 deletions(-) create mode 100644 internal/nntp/client.go create mode 100644 internal/nntp/conns.go create mode 100644 internal/nntp/decoder.go create mode 100644 internal/nntp/errors.go create mode 100644 internal/nntp/pool.go create mode 100644 pkg/sabnzbd/config.go create mode 100644 pkg/sabnzbd/context.go create mode 100644 pkg/sabnzbd/handlers.go create mode 100644 pkg/sabnzbd/routes.go create mode 100644 pkg/sabnzbd/sabnzbd.go create mode 100644 pkg/sabnzbd/types.go create mode 100644 pkg/usenet/cache.go create mode 100644 pkg/usenet/downloader.go create mode 100644 pkg/usenet/errors.go create mode 100644 pkg/usenet/misc.go create mode 100644 pkg/usenet/nzb.go create mode 100644 pkg/usenet/parser.go create mode 100644 pkg/usenet/processor.go create mode 100644 pkg/usenet/rar.go create mode 100644 pkg/usenet/store.go create mode 100644 pkg/usenet/stream.go create mode 100644 pkg/usenet/types.go create mode 100644 pkg/usenet/usenet.go rename pkg/web/{api.go => handlers.go} (65%) create mode 100644 pkg/webdav/torrent_file.go rename pkg/webdav/{handler.go => torrent_handler.go} (68%) create mode 100644 pkg/webdav/usenet_file.go create mode 100644 pkg/webdav/usenet_handler.go diff --git a/.dockerignore b/.dockerignore index 59c9e2f..313fe2d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -25,4 +25,5 @@ node_modules/ # Build artifacts decypharr healthcheck -*.exe \ No newline at end of file +*.exe +.venv/ \ No newline at end of file diff --git a/.gitignore b/.gitignore index aecb5c6..6dc53ff 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,5 @@ logs/** auth.json .ven/ .env +.venv/ node_modules/ \ No newline at end of file diff --git a/cmd/decypharr/main.go b/cmd/decypharr/main.go index 2bfa3e9..5b32d25 100644 --- a/cmd/decypharr/main.go +++ b/cmd/decypharr/main.go @@ -6,8 +6,10 @@ import ( "github.com/sirrobot01/decypharr/internal/config" "github.com/sirrobot01/decypharr/internal/logger" "github.com/sirrobot01/decypharr/pkg/qbit" + "github.com/sirrobot01/decypharr/pkg/sabnzbd" "github.com/sirrobot01/decypharr/pkg/server" "github.com/sirrobot01/decypharr/pkg/store" + "github.com/sirrobot01/decypharr/pkg/usenet" "github.com/sirrobot01/decypharr/pkg/version" "github.com/sirrobot01/decypharr/pkg/web" "github.com/sirrobot01/decypharr/pkg/webdav" @@ -58,20 +60,30 @@ func Start(ctx context.Context) error { `, version.GetInfo(), cfg.LogLevel) // Initialize services - qb := qbit.New() - wd := webdav.New() + _usenet := usenet.New() + debridCaches := store.Get().Debrid().Caches() + wd := webdav.New(debridCaches, _usenet) + var sb *sabnzbd.SABnzbd - ui := web.New().Routes() + ui := web.New(_usenet).Routes() webdavRoutes := wd.Routes() - qbitRoutes := qb.Routes() + + qb := qbit.New() // Register routes handlers := map[string]http.Handler{ "/": ui, - "/api/v2": qbitRoutes, "/webdav": webdavRoutes, } - srv := server.New(handlers) + if qb != nil { + handlers["/api/v2"] = qb.Routes() + } + if _usenet != nil { + sb = sabnzbd.New(_usenet) + sabRoutes := sb.Routes() + handlers["/sabnzbd"] = sabRoutes + } + srv := server.New(_usenet, handlers) done := make(chan struct{}) go func(ctx context.Context) { @@ -93,8 +105,13 @@ func Start(ctx context.Context) error { cancelSvc() // tell existing services to shut down _log.Info().Msg("Restarting Decypharr...") <-done // wait for them to finish - qb.Reset() + if qb != nil { + qb.Reset() + } store.Reset() + if _usenet != nil { + _usenet.Close() + } // rebuild svcCtx off the original parent svcCtx, cancelSvc = context.WithCancel(ctx) diff --git a/go.mod b/go.mod index a434cb2..5c9b7b3 100644 --- a/go.mod +++ b/go.mod @@ -5,23 +5,29 @@ go 1.24.0 toolchain go1.24.3 require ( + github.com/Tensai75/nzbparser v0.1.0 github.com/anacrolix/torrent v1.55.0 github.com/cavaliergopher/grab/v3 v3.0.1 + github.com/chrisfarms/yenc v0.0.0-20140520125709-00bca2f8b3cb github.com/go-chi/chi/v5 v5.1.0 github.com/go-co-op/gocron/v2 v2.16.1 github.com/google/uuid v1.6.0 github.com/gorilla/sessions v1.4.0 + github.com/nwaples/rardecode/v2 v2.0.0-beta.4 + github.com/puzpuzpuz/xsync/v4 v4.1.0 github.com/robfig/cron/v3 v3.0.1 github.com/rs/zerolog v1.33.0 + github.com/sourcegraph/conc v0.3.0 github.com/stanNthe5/stringbuf v0.0.3 go.uber.org/ratelimit v0.3.1 golang.org/x/crypto v0.33.0 golang.org/x/net v0.35.0 - golang.org/x/sync v0.12.0 + golang.org/x/sync v0.15.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 ) require ( + github.com/Tensai75/subjectparser v0.1.0 // indirect github.com/anacrolix/missinggo v1.3.0 // indirect github.com/anacrolix/missinggo/v2 v2.7.3 // indirect github.com/benbjohnson/clock v1.3.0 // indirect @@ -35,5 +41,8 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rogpeppe/go-internal v1.13.1 // indirect + go.uber.org/atomic v1.7.0 // indirect + go.uber.org/multierr v1.9.0 // indirect golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.26.0 // indirect ) diff --git a/go.sum b/go.sum index 4b3eca2..4d1f841 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,10 @@ github.com/RoaringBitmap/roaring v0.4.17/go.mod h1:D3qVegWTmfCaX4Bl5CrBE9hfrSrrX github.com/RoaringBitmap/roaring v0.4.23/go.mod h1:D0gp8kJQgE1A4LQ5wFLggQEyvDi06Mq5mKs52e1TwOo= 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/Tensai75/nzbparser v0.1.0 h1:6RppAuWFahqu/kKjWO5Br0xuEYcxGz+XBTxYc+qvPo4= +github.com/Tensai75/nzbparser v0.1.0/go.mod h1:IUIIaeGaYp2dLAAF29BWYeKTfI4COvXaeQAzQiTOfMY= +github.com/Tensai75/subjectparser v0.1.0 h1:6fEWnRov8lDHxJS2EWqY6VonwYfrIRN+k8h8H7fFwHA= +github.com/Tensai75/subjectparser v0.1.0/go.mod h1:PNBFBnkOGbVDfX+56ZmC4GKSpqoRMCF1Y44xYd7NLGI= 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= @@ -49,6 +53,8 @@ github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8/go.mod h1:spo1JLcs67 github.com/cavaliergopher/grab/v3 v3.0.1 h1:4z7TkBfmPjmLAAmkkAZNX/6QJ1nNFdv3SdIHXju0Fr4= github.com/cavaliergopher/grab/v3 v3.0.1/go.mod h1:1U/KNnD+Ft6JJiYoYBAimKH2XrYptb8Kl3DFGmsjpq4= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chrisfarms/yenc v0.0.0-20140520125709-00bca2f8b3cb h1:BK9pqCayfiXrcRypTPxDsunA6hPJtOyOTJYY2DJ429g= +github.com/chrisfarms/yenc v0.0.0-20140520125709-00bca2f8b3cb/go.mod h1:V4bkS2felTTOSIsYx9JivzrbdBOuksi02ZkzfbHUVAk= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -157,6 +163,8 @@ github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3Rllmb github.com/mschoch/smat v0.0.0-20160514031455-90eadee771ae/go.mod h1:qAyveg+e4CE+eKJXWVjKXM4ck2QobLqTDytGJbLLhJg= github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/nwaples/rardecode/v2 v2.0.0-beta.4 h1:sdiJxQdPjECn2lh9nLFFhgLCf+0ulDU5rODbtERTlUY= +github.com/nwaples/rardecode/v2 v2.0.0-beta.4/go.mod h1:yntwv/HfMc/Hbvtq9I19D1n58te3h6KsqCf3GxyfBGY= 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= @@ -185,6 +193,8 @@ github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R 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/puzpuzpuz/xsync/v4 v4.1.0 h1:x9eHRl4QhZFIPJ17yl4KKW9xLyVWbb3/Yq4SXpjF71U= +github.com/puzpuzpuz/xsync/v4 v4.1.0/go.mod h1:VJDmTCJMBt8igNxnkQd86r+8KUeN1quSfNKu5bLYFQo= github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= @@ -200,6 +210,8 @@ github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1 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/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/stanNthe5/stringbuf v0.0.3 h1:3ChRipDckEY6FykaQ1Dowy3B+ZQa72EDBCasvT5+D1w= github.com/stanNthe5/stringbuf v0.0.3/go.mod h1:hii5Vr+mucoWkNJlIYQVp8YvuPtq45fFnJEAhcPf2cQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -222,6 +234,8 @@ go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= +go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= go.uber.org/ratelimit v0.3.1 h1:K4qVE+byfv/B3tC+4nYWP7v/6SimcO7HzHekoMNBma0= go.uber.org/ratelimit v0.3.1/go.mod h1:6euWsTB6U/Nb3X++xEUXA8ciPJvr19Q/0h1+oDcJhRk= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= @@ -252,8 +266,8 @@ golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJ 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-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= -golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 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= @@ -272,6 +286,8 @@ golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 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.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= +golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= 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= diff --git a/internal/config/config.go b/internal/config/config.go index 13750a4..8b07d22 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -47,7 +47,6 @@ type Debrid struct { type QBitTorrent struct { Username string `json:"username,omitempty"` Password string `json:"password,omitempty"` - Port string `json:"port,omitempty"` // deprecated DownloadFolder string `json:"download_folder,omitempty"` Categories []string `json:"categories,omitempty"` RefreshInterval int `json:"refresh_interval,omitempty"` @@ -82,26 +81,55 @@ type Auth struct { Password string `json:"password,omitempty"` } +type SABnzbd struct { + DownloadFolder string `json:"download_folder,omitempty"` + RefreshInterval int `json:"refresh_interval,omitempty"` + Categories []string `json:"categories,omitempty"` +} + +type Usenet struct { + Providers []UsenetProvider `json:"providers,omitempty"` // List of usenet providers + MountFolder string `json:"mount_folder,omitempty"` // Folder where usenet downloads are mounted + SkipPreCache bool `json:"skip_pre_cache,omitempty"` + Chunks int `json:"chunks,omitempty"` // Number of chunks to pre-cache + RcUrl string `json:"rc_url,omitempty"` // Rclone RC URL for the webdav + RcUser string `json:"rc_user,omitempty"` // Rclone RC username + RcPass string `json:"rc_pass,omitempty"` // Rclone RC password +} + +type UsenetProvider struct { + Name string `json:"name,omitempty"` + Host string `json:"host,omitempty"` // Host of the usenet server + Port int `json:"port,omitempty"` // Port of the usenet server + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` + Connections int `json:"connections,omitempty"` // Number of connections to use + SSL bool `json:"ssl,omitempty"` // Use SSL for the connection + UseTLS bool `json:"use_tls,omitempty"` // Use TLS for the connection +} + type Config struct { // server BindAddress string `json:"bind_address,omitempty"` URLBase string `json:"url_base,omitempty"` Port string `json:"port,omitempty"` - LogLevel string `json:"log_level,omitempty"` - Debrids []Debrid `json:"debrids,omitempty"` - QBitTorrent QBitTorrent `json:"qbittorrent,omitempty"` - Arrs []Arr `json:"arrs,omitempty"` - Repair Repair `json:"repair,omitempty"` - WebDav WebDav `json:"webdav,omitempty"` - AllowedExt []string `json:"allowed_file_types,omitempty"` - MinFileSize string `json:"min_file_size,omitempty"` // Minimum file size to download, 10MB, 1GB, etc - MaxFileSize string `json:"max_file_size,omitempty"` // Maximum file size to download (0 means no limit) - Path string `json:"-"` // Path to save the config file - UseAuth bool `json:"use_auth,omitempty"` - Auth *Auth `json:"-"` - DiscordWebhook string `json:"discord_webhook_url,omitempty"` - RemoveStalledAfter string `json:"remove_stalled_after,omitzero"` + LogLevel string `json:"log_level,omitempty"` + Debrids []Debrid `json:"debrids,omitempty"` + QBitTorrent *QBitTorrent `json:"qbittorrent,omitempty"` + SABnzbd *SABnzbd `json:"sabnzbd,omitempty"` + Usenet *Usenet `json:"usenet,omitempty"` // Usenet configuration + Arrs []Arr `json:"arrs,omitempty"` + Repair Repair `json:"repair,omitempty"` + WebDav WebDav `json:"webdav,omitempty"` + AllowedExt []string `json:"allowed_file_types,omitempty"` + MinFileSize string `json:"min_file_size,omitempty"` // Minimum file size to download, 10MB, 1GB, etc + MaxFileSize string `json:"max_file_size,omitempty"` // Maximum file size to download (0 means no limit) + Path string `json:"-"` // Path to save the config file + UseAuth bool `json:"use_auth,omitempty"` + Auth *Auth `json:"-"` + DiscordWebhook string `json:"discord_webhook_url,omitempty"` + RemoveStalledAfter string `json:"remove_stalled_after,omitzero"` } func (c *Config) JsonFile() string { @@ -115,6 +143,10 @@ func (c *Config) TorrentsFile() string { return filepath.Join(c.Path, "torrents.json") } +func (c *Config) NZBsPath() string { + return filepath.Join(c.Path, "cache/nzbs") +} + func (c *Config) loadConfig() error { // Load the config file if configPath == "" { @@ -142,9 +174,6 @@ func (c *Config) loadConfig() error { } func validateDebrids(debrids []Debrid) error { - if len(debrids) == 0 { - return errors.New("no debrids configured") - } for _, debrid := range debrids { // Basic field validation @@ -159,17 +188,51 @@ func validateDebrids(debrids []Debrid) error { return nil } -func validateQbitTorrent(config *QBitTorrent) error { - if config.DownloadFolder == "" { - return errors.New("qbittorent download folder is required") +func validateUsenet(usenet *Usenet) error { + if usenet == nil { + return nil // No usenet configuration provided } - if _, err := os.Stat(config.DownloadFolder); os.IsNotExist(err) { - return fmt.Errorf("qbittorent download folder(%s) does not exist", config.DownloadFolder) + for _, usenet := range usenet.Providers { + // Basic field validation + if usenet.Host == "" { + return errors.New("usenet host is required") + } + if usenet.Username == "" { + return errors.New("usenet username is required") + } + if usenet.Password == "" { + return errors.New("usenet password is required") + } + } + + return nil +} + +func validateSabznbd(config *SABnzbd) error { + if config == nil { + return nil // No SABnzbd configuration provided + } + if config.DownloadFolder != "" { + if _, err := os.Stat(config.DownloadFolder); os.IsNotExist(err) { + return fmt.Errorf("sabnzbd download folder(%s) does not exist", config.DownloadFolder) + } } return nil } -func validateRepair(config *Repair) error { +func validateQbitTorrent(config *QBitTorrent) error { + if config == nil { + return nil // No qBittorrent configuration provided + } + if config.DownloadFolder != "" { + if _, err := os.Stat(config.DownloadFolder); os.IsNotExist(err) { + return fmt.Errorf("qbittorent download folder(%s) does not exist", config.DownloadFolder) + } + } + return nil +} + +func validateRepair(config Repair) error { if !config.Enabled { return nil } @@ -181,19 +244,34 @@ func validateRepair(config *Repair) error { func ValidateConfig(config *Config) error { // Run validations concurrently + // Check if there's at least one debrid or usenet configured + hasUsenet := false + if config.Usenet != nil && len(config.Usenet.Providers) > 0 { + hasUsenet = true + } + if len(config.Debrids) == 0 && !hasUsenet { + return errors.New("at least one debrid or usenet provider must be configured") + } if err := validateDebrids(config.Debrids); err != nil { return err } - if err := validateQbitTorrent(&config.QBitTorrent); err != nil { + if err := validateUsenet(config.Usenet); err != nil { return err } - if err := validateRepair(&config.Repair); err != nil { + if err := validateSabznbd(config.SABnzbd); err != nil { return err } + if err := validateQbitTorrent(config.QBitTorrent); err != nil { + return err + } + + if err := validateRepair(config.Repair); err != nil { + return err + } return nil } @@ -299,6 +377,10 @@ func (c *Config) updateDebrid(d Debrid) Debrid { } d.DownloadAPIKeys = downloadKeys + if d.Workers == 0 { + d.Workers = perDebrid + } + if !d.UseWebDav { return d } @@ -309,9 +391,6 @@ func (c *Config) updateDebrid(d Debrid) Debrid { if d.WebDav.DownloadLinksRefreshInterval == "" { d.DownloadLinksRefreshInterval = cmp.Or(c.WebDav.DownloadLinksRefreshInterval, "40m") // 40 minutes } - if d.Workers == 0 { - d.Workers = perDebrid - } if d.FolderNaming == "" { d.FolderNaming = cmp.Or(c.WebDav.FolderNaming, "original_no_ext") } @@ -338,17 +417,47 @@ func (c *Config) updateDebrid(d Debrid) Debrid { return d } +func (c *Config) updateUsenet(u UsenetProvider) UsenetProvider { + if u.Name == "" { + parts := strings.Split(u.Host, ".") + if len(parts) >= 2 { + u.Name = parts[len(parts)-2] // Gets "example" from "news.example.com" + } else { + u.Name = u.Host // Fallback to host if it doesn't look like a domain + } + } + if u.Port == 0 { + u.Port = 119 // Default port for usenet + } + if u.Connections == 0 { + u.Connections = 30 // Default connections + } + if u.SSL && !u.UseTLS { + u.UseTLS = true // Use TLS if SSL is enabled + } + return u +} + func (c *Config) setDefaults() { for i, debrid := range c.Debrids { c.Debrids[i] = c.updateDebrid(debrid) } + if c.SABnzbd != nil { + c.SABnzbd.RefreshInterval = cmp.Or(c.SABnzbd.RefreshInterval, 10) // Default to 10 seconds + } + + if c.Usenet != nil { + c.Usenet.Chunks = cmp.Or(c.Usenet.Chunks, 5) + for i, provider := range c.Usenet.Providers { + c.Usenet.Providers[i] = c.updateUsenet(provider) + } + } + if len(c.AllowedExt) == 0 { c.AllowedExt = getDefaultExtensions() } - c.Port = cmp.Or(c.Port, c.QBitTorrent.Port) - if c.URLBase == "" { c.URLBase = "/" } @@ -395,11 +504,6 @@ func (c *Config) createConfig(path string) error { c.Port = "8282" c.LogLevel = "info" c.UseAuth = true - c.QBitTorrent = QBitTorrent{ - DownloadFolder: filepath.Join(path, "downloads"), - Categories: []string{"sonarr", "radarr"}, - RefreshInterval: 15, - } return nil } @@ -408,7 +512,3 @@ func Reload() { instance = nil once = sync.Once{} } - -func DefaultFreeSlot() int { - return 10 -} diff --git a/internal/nntp/client.go b/internal/nntp/client.go new file mode 100644 index 0000000..f3ef03e --- /dev/null +++ b/internal/nntp/client.go @@ -0,0 +1,178 @@ +package nntp + +import ( + "bytes" + "context" + "errors" + "fmt" + "github.com/puzpuzpuz/xsync/v4" + "github.com/rs/zerolog" + "github.com/sirrobot01/decypharr/internal/config" + "github.com/sirrobot01/decypharr/internal/logger" + "sync/atomic" + "time" +) + +// Client represents a failover NNTP client that manages multiple providers +type Client struct { + providers []config.UsenetProvider + pools *xsync.Map[string, *Pool] + logger zerolog.Logger + closed atomic.Bool + minimumMaxConns int // Minimum number of max connections across all pools +} + +func NewClient(providers []config.UsenetProvider) (*Client, error) { + + client := &Client{ + providers: providers, + logger: logger.New("nntp"), + pools: xsync.NewMap[string, *Pool](), + } + if len(providers) == 0 { + return nil, fmt.Errorf("no NNTP providers configured") + } + return client, nil +} + +func (c *Client) InitPools() error { + + var initErrors []error + successfulPools := 0 + + for _, provider := range c.providers { + serverPool, err := NewPool(provider, c.logger) + if err != nil { + c.logger.Error(). + Err(err). + Str("server", provider.Host). + Int("port", provider.Port). + Msg("Failed to initialize server pool") + initErrors = append(initErrors, err) + continue + } + if c.minimumMaxConns == 0 { + // Set minimumMaxConns to the max connections of the first successful pool + c.minimumMaxConns = serverPool.ConnectionCount() + } else { + c.minimumMaxConns = min(c.minimumMaxConns, serverPool.ConnectionCount()) + } + + c.pools.Store(provider.Name, serverPool) + successfulPools++ + } + + if successfulPools == 0 { + return fmt.Errorf("failed to initialize any server pools: %v", initErrors) + } + + c.logger.Info(). + Int("providers", len(c.providers)). + Msg("NNTP client created") + + return nil +} + +func (c *Client) Close() { + if c.closed.Load() { + c.logger.Warn().Msg("NNTP client already closed") + return + } + + c.pools.Range(func(key string, value *Pool) bool { + if value != nil { + err := value.Close() + if err != nil { + return false + } + } + return true + }) + + c.closed.Store(true) + c.logger.Info().Msg("NNTP client closed") +} + +func (c *Client) GetConnection(ctx context.Context) (*Connection, func(), error) { + if c.closed.Load() { + return nil, nil, fmt.Errorf("nntp client is closed") + } + + // Prevent workers from waiting too long for connections + connCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + providerCount := len(c.providers) + + for _, provider := range c.providers { + pool, ok := c.pools.Load(provider.Name) + if !ok { + return nil, nil, fmt.Errorf("no pool found for provider %s", provider.Name) + } + + if !pool.IsFree() && providerCount > 1 { + continue + } + + conn, err := pool.Get(connCtx) // Use timeout context + if err != nil { + if errors.Is(err, ErrNoAvailableConnection) || errors.Is(err, context.DeadlineExceeded) { + continue + } + return nil, nil, fmt.Errorf("error getting connection from provider %s: %w", provider.Name, err) + } + + if conn == nil { + continue + } + + return conn, func() { pool.Put(conn) }, nil + } + + return nil, nil, ErrNoAvailableConnection +} + +func (c *Client) DownloadHeader(ctx context.Context, messageID string) (*YencMetadata, error) { + conn, cleanup, err := c.GetConnection(ctx) + if err != nil { + return nil, err + } + defer cleanup() + + data, err := conn.GetBody(messageID) + if err != nil { + return nil, err + } + + // yEnc decode + part, err := DecodeYencHeaders(bytes.NewReader(data)) + if err != nil || part == nil { + return nil, fmt.Errorf("failed to decode segment") + } + + // Return both the filename and decoded data + return part, nil +} + +func (c *Client) MinimumMaxConns() int { + return c.minimumMaxConns +} + +func (c *Client) TotalActiveConnections() int { + total := 0 + c.pools.Range(func(key string, value *Pool) bool { + if value != nil { + total += value.ActiveConnections() + } + return true + }) + return total +} + +func (c *Client) Pools() *xsync.Map[string, *Pool] { + return c.pools +} + +func (c *Client) GetProviders() []config.UsenetProvider { + return c.providers +} diff --git a/internal/nntp/conns.go b/internal/nntp/conns.go new file mode 100644 index 0000000..9b420b6 --- /dev/null +++ b/internal/nntp/conns.go @@ -0,0 +1,394 @@ +package nntp + +import ( + "bufio" + "crypto/tls" + "fmt" + "github.com/chrisfarms/yenc" + "github.com/rs/zerolog" + "io" + "net" + "net/textproto" + "strconv" + "strings" +) + +// Connection represents an NNTP connection +type Connection struct { + username, password, address string + port int + conn net.Conn + text *textproto.Conn + reader *bufio.Reader + writer *bufio.Writer + logger zerolog.Logger +} + +func (c *Connection) authenticate() error { + // Send AUTHINFO USER command + if err := c.sendCommand(fmt.Sprintf("AUTHINFO USER %s", c.username)); err != nil { + return NewConnectionError(fmt.Errorf("failed to send username: %w", err)) + } + + resp, err := c.readResponse() + if err != nil { + return NewConnectionError(fmt.Errorf("failed to read user response: %w", err)) + } + + if resp.Code != 381 { + return classifyNNTPError(resp.Code, fmt.Sprintf("unexpected response to AUTHINFO USER: %s", resp.Message)) + } + + // Send AUTHINFO PASS command + if err := c.sendCommand(fmt.Sprintf("AUTHINFO PASS %s", c.password)); err != nil { + return NewConnectionError(fmt.Errorf("failed to send password: %w", err)) + } + + resp, err = c.readResponse() + if err != nil { + return NewConnectionError(fmt.Errorf("failed to read password response: %w", err)) + } + + if resp.Code != 281 { + return classifyNNTPError(resp.Code, fmt.Sprintf("authentication failed: %s", resp.Message)) + } + return nil +} + +// startTLS initiates TLS encryption with proper error handling +func (c *Connection) startTLS() error { + if err := c.sendCommand("STARTTLS"); err != nil { + return NewConnectionError(fmt.Errorf("failed to send STARTTLS: %w", err)) + } + + resp, err := c.readResponse() + if err != nil { + return NewConnectionError(fmt.Errorf("failed to read STARTTLS response: %w", err)) + } + + if resp.Code != 382 { + return classifyNNTPError(resp.Code, fmt.Sprintf("STARTTLS not supported: %s", resp.Message)) + } + + // Upgrade connection to TLS + tlsConn := tls.Client(c.conn, &tls.Config{ + ServerName: c.address, + InsecureSkipVerify: false, + }) + + c.conn = tlsConn + c.reader = bufio.NewReader(tlsConn) + c.writer = bufio.NewWriter(tlsConn) + c.text = textproto.NewConn(tlsConn) + + c.logger.Debug().Msg("TLS encryption enabled") + return nil +} + +// ping sends a simple command to test the connection +func (c *Connection) ping() error { + if err := c.sendCommand("DATE"); err != nil { + return NewConnectionError(err) + } + _, err := c.readResponse() + if err != nil { + return NewConnectionError(err) + } + return nil +} + +// sendCommand sends a command to the NNTP server +func (c *Connection) sendCommand(command string) error { + _, err := fmt.Fprintf(c.writer, "%s\r\n", command) + if err != nil { + return err + } + return c.writer.Flush() +} + +// readResponse reads a response from the NNTP server +func (c *Connection) readResponse() (*Response, error) { + line, err := c.text.ReadLine() + if err != nil { + return nil, err + } + + parts := strings.SplitN(line, " ", 2) + code, err := strconv.Atoi(parts[0]) + if err != nil { + return nil, fmt.Errorf("invalid response code: %s", parts[0]) + } + + message := "" + if len(parts) > 1 { + message = parts[1] + } + + return &Response{ + Code: code, + Message: message, + }, nil +} + +// readMultilineResponse reads a multiline response +func (c *Connection) readMultilineResponse() (*Response, error) { + resp, err := c.readResponse() + if err != nil { + return nil, err + } + + // Check if this is a multiline response + if resp.Code < 200 || resp.Code >= 300 { + return resp, nil + } + + lines, err := c.text.ReadDotLines() + if err != nil { + return nil, err + } + + resp.Lines = lines + return resp, nil +} + +// GetArticle retrieves an article by message ID with proper error classification +func (c *Connection) GetArticle(messageID string) (*Article, error) { + messageID = FormatMessageID(messageID) + if err := c.sendCommand(fmt.Sprintf("ARTICLE %s", messageID)); err != nil { + return nil, NewConnectionError(fmt.Errorf("failed to send ARTICLE command: %w", err)) + } + + resp, err := c.readMultilineResponse() + if err != nil { + return nil, NewConnectionError(fmt.Errorf("failed to read article response: %w", err)) + } + + if resp.Code != 220 { + return nil, classifyNNTPError(resp.Code, resp.Message) + } + + return c.parseArticle(messageID, resp.Lines) +} + +// GetBody retrieves article body by message ID with proper error classification +func (c *Connection) GetBody(messageID string) ([]byte, error) { + messageID = FormatMessageID(messageID) + if err := c.sendCommand(fmt.Sprintf("BODY %s", messageID)); err != nil { + return nil, NewConnectionError(fmt.Errorf("failed to send BODY command: %w", err)) + } + + // Read the initial response + resp, err := c.readResponse() + if err != nil { + return nil, NewConnectionError(fmt.Errorf("failed to read body response: %w", err)) + } + + if resp.Code != 222 { + return nil, classifyNNTPError(resp.Code, resp.Message) + } + + // Read the raw body data directly using textproto to preserve exact formatting for yEnc + lines, err := c.text.ReadDotLines() + if err != nil { + return nil, NewConnectionError(fmt.Errorf("failed to read body data: %w", err)) + } + + // Join with \r\n to preserve original line endings and add final \r\n + body := strings.Join(lines, "\r\n") + if len(lines) > 0 { + body += "\r\n" + } + + return []byte(body), nil +} + +// GetHead retrieves article headers by message ID +func (c *Connection) GetHead(messageID string) ([]byte, error) { + messageID = FormatMessageID(messageID) + if err := c.sendCommand(fmt.Sprintf("HEAD %s", messageID)); err != nil { + return nil, NewConnectionError(fmt.Errorf("failed to send HEAD command: %w", err)) + } + + // Read the initial response + resp, err := c.readResponse() + if err != nil { + return nil, NewConnectionError(fmt.Errorf("failed to read head response: %w", err)) + } + + if resp.Code != 221 { + return nil, classifyNNTPError(resp.Code, resp.Message) + } + + // Read the header data using textproto + lines, err := c.text.ReadDotLines() + if err != nil { + return nil, NewConnectionError(fmt.Errorf("failed to read header data: %w", err)) + } + + // Join with \r\n to preserve original line endings and add final \r\n + headers := strings.Join(lines, "\r\n") + if len(lines) > 0 { + headers += "\r\n" + } + + return []byte(headers), nil +} + +// GetSegment retrieves a specific segment with proper error handling +func (c *Connection) GetSegment(messageID string, segmentNumber int) (*Segment, error) { + messageID = FormatMessageID(messageID) + body, err := c.GetBody(messageID) + if err != nil { + return nil, err // GetBody already returns classified errors + } + + return &Segment{ + MessageID: messageID, + Number: segmentNumber, + Bytes: int64(len(body)), + Data: body, + }, nil +} + +// Stat retrieves article statistics by message ID with proper error classification +func (c *Connection) Stat(messageID string) (articleNumber int, echoedID string, err error) { + messageID = FormatMessageID(messageID) + + if err = c.sendCommand(fmt.Sprintf("STAT %s", messageID)); err != nil { + return 0, "", NewConnectionError(fmt.Errorf("failed to send STAT: %w", err)) + } + + resp, err := c.readResponse() + if err != nil { + return 0, "", NewConnectionError(fmt.Errorf("failed to read STAT response: %w", err)) + } + + if resp.Code != 223 { + return 0, "", classifyNNTPError(resp.Code, resp.Message) + } + + fields := strings.Fields(resp.Message) + if len(fields) < 2 { + return 0, "", NewProtocolError(resp.Code, fmt.Sprintf("unexpected STAT response format: %q", resp.Message)) + } + + if articleNumber, err = strconv.Atoi(fields[0]); err != nil { + return 0, "", NewProtocolError(resp.Code, fmt.Sprintf("invalid article number %q: %v", fields[0], err)) + } + echoedID = fields[1] + + return articleNumber, echoedID, nil +} + +// SelectGroup selects a newsgroup and returns group information +func (c *Connection) SelectGroup(groupName string) (*GroupInfo, error) { + if err := c.sendCommand(fmt.Sprintf("GROUP %s", groupName)); err != nil { + return nil, NewConnectionError(fmt.Errorf("failed to send GROUP command: %w", err)) + } + + resp, err := c.readResponse() + if err != nil { + return nil, NewConnectionError(fmt.Errorf("failed to read GROUP response: %w", err)) + } + + if resp.Code != 211 { + return nil, classifyNNTPError(resp.Code, resp.Message) + } + + // Parse GROUP response: "211 number low high group-name" + fields := strings.Fields(resp.Message) + if len(fields) < 4 { + return nil, NewProtocolError(resp.Code, fmt.Sprintf("unexpected GROUP response format: %q", resp.Message)) + } + + groupInfo := &GroupInfo{ + Name: groupName, + } + + if count, err := strconv.Atoi(fields[0]); err == nil { + groupInfo.Count = count + } + if low, err := strconv.Atoi(fields[1]); err == nil { + groupInfo.Low = low + } + if high, err := strconv.Atoi(fields[2]); err == nil { + groupInfo.High = high + } + + return groupInfo, nil +} + +// parseArticle parses article data from response lines +func (c *Connection) parseArticle(messageID string, lines []string) (*Article, error) { + article := &Article{ + MessageID: messageID, + Groups: []string{}, + } + + headerEnd := -1 + for i, line := range lines { + if line == "" { + headerEnd = i + break + } + + // Parse headers + if strings.HasPrefix(line, "Subject: ") { + article.Subject = strings.TrimPrefix(line, "Subject: ") + } else if strings.HasPrefix(line, "From: ") { + article.From = strings.TrimPrefix(line, "From: ") + } else if strings.HasPrefix(line, "Date: ") { + article.Date = strings.TrimPrefix(line, "Date: ") + } else if strings.HasPrefix(line, "Newsgroups: ") { + groups := strings.TrimPrefix(line, "Newsgroups: ") + article.Groups = strings.Split(groups, ",") + for i := range article.Groups { + article.Groups[i] = strings.TrimSpace(article.Groups[i]) + } + } + } + + // Join body lines + if headerEnd != -1 && headerEnd+1 < len(lines) { + body := strings.Join(lines[headerEnd+1:], "\n") + article.Body = []byte(body) + article.Size = int64(len(article.Body)) + } + + return article, nil +} + +// close closes the NNTP connection +func (c *Connection) close() error { + if c.conn != nil { + return c.conn.Close() + } + return nil +} + +func DecodeYenc(reader io.Reader) (*yenc.Part, error) { + part, err := yenc.Decode(reader) + if err != nil { + return nil, NewYencDecodeError(fmt.Errorf("failed to create yenc decoder: %w", err)) + } + return part, nil +} + +func IsValidMessageID(messageID string) bool { + if len(messageID) < 3 { + return false + } + return strings.Contains(messageID, "@") +} + +// FormatMessageID ensures message ID has proper format +func FormatMessageID(messageID string) string { + messageID = strings.TrimSpace(messageID) + if !strings.HasPrefix(messageID, "<") { + messageID = "<" + messageID + } + if !strings.HasSuffix(messageID, ">") { + messageID = messageID + ">" + } + return messageID +} diff --git a/internal/nntp/decoder.go b/internal/nntp/decoder.go new file mode 100644 index 0000000..ee8158f --- /dev/null +++ b/internal/nntp/decoder.go @@ -0,0 +1,116 @@ +package nntp + +import ( + "bufio" + "fmt" + "io" + "strconv" + "strings" +) + +// YencMetadata contains just the header information +type YencMetadata struct { + Name string // filename + Size int64 // total file size + Part int // part number + Total int // total parts + Begin int64 // part start byte + End int64 // part end byte + LineSize int // line length +} + +// DecodeYencHeaders extracts only yenc header metadata without decoding body +func DecodeYencHeaders(reader io.Reader) (*YencMetadata, error) { + buf := bufio.NewReader(reader) + metadata := &YencMetadata{} + + // Find and parse =ybegin header + if err := parseYBeginHeader(buf, metadata); err != nil { + return nil, NewYencDecodeError(fmt.Errorf("failed to parse ybegin header: %w", err)) + } + + // Parse =ypart header if this is a multipart file + if metadata.Part > 0 { + if err := parseYPartHeader(buf, metadata); err != nil { + return nil, NewYencDecodeError(fmt.Errorf("failed to parse ypart header: %w", err)) + } + } + + return metadata, nil +} + +func parseYBeginHeader(buf *bufio.Reader, metadata *YencMetadata) error { + var s string + var err error + + // Find the =ybegin line + for { + s, err = buf.ReadString('\n') + if err != nil { + return err + } + if len(s) >= 7 && s[:7] == "=ybegin" { + break + } + } + + // Parse the header line + parts := strings.SplitN(s[7:], "name=", 2) + if len(parts) > 1 { + metadata.Name = strings.TrimSpace(parts[1]) + } + + // Parse other parameters + for _, header := range strings.Split(parts[0], " ") { + kv := strings.SplitN(strings.TrimSpace(header), "=", 2) + if len(kv) < 2 { + continue + } + + switch kv[0] { + case "size": + metadata.Size, _ = strconv.ParseInt(kv[1], 10, 64) + case "line": + metadata.LineSize, _ = strconv.Atoi(kv[1]) + case "part": + metadata.Part, _ = strconv.Atoi(kv[1]) + case "total": + metadata.Total, _ = strconv.Atoi(kv[1]) + } + } + + return nil +} + +func parseYPartHeader(buf *bufio.Reader, metadata *YencMetadata) error { + var s string + var err error + + // Find the =ypart line + for { + s, err = buf.ReadString('\n') + if err != nil { + return err + } + if len(s) >= 6 && s[:6] == "=ypart" { + break + } + } + + // Parse part parameters + for _, header := range strings.Split(s[6:], " ") { + kv := strings.SplitN(strings.TrimSpace(header), "=", 2) + if len(kv) < 2 { + continue + } + + switch kv[0] { + case "begin": + metadata.Begin, _ = strconv.ParseInt(kv[1], 10, 64) + case "end": + metadata.End, _ = strconv.ParseInt(kv[1], 10, 64) + } + } + + return nil +} diff --git a/internal/nntp/errors.go b/internal/nntp/errors.go new file mode 100644 index 0000000..fce23da --- /dev/null +++ b/internal/nntp/errors.go @@ -0,0 +1,195 @@ +package nntp + +import ( + "errors" + "fmt" +) + +// Error types for NNTP operations +type ErrorType int + +const ( + ErrorTypeUnknown ErrorType = iota + ErrorTypeConnection + ErrorTypeAuthentication + ErrorTypeTimeout + ErrorTypeArticleNotFound + ErrorTypeGroupNotFound + ErrorTypePermissionDenied + ErrorTypeServerBusy + ErrorTypeInvalidCommand + ErrorTypeProtocol + ErrorTypeYencDecode + ErrorTypeNoAvailableConnection +) + +// Error represents an NNTP-specific error +type Error struct { + Type ErrorType + Code int // NNTP response code + Message string // Error message + Err error // Underlying error +} + +// Predefined errors for common cases +var ( + ErrArticleNotFound = &Error{Type: ErrorTypeArticleNotFound, Code: 430, Message: "article not found"} + ErrGroupNotFound = &Error{Type: ErrorTypeGroupNotFound, Code: 411, Message: "group not found"} + ErrPermissionDenied = &Error{Type: ErrorTypePermissionDenied, Code: 502, Message: "permission denied"} + ErrAuthenticationFail = &Error{Type: ErrorTypeAuthentication, Code: 482, Message: "authentication failed"} + ErrServerBusy = &Error{Type: ErrorTypeServerBusy, Code: 400, Message: "server busy"} + ErrPoolNotFound = &Error{Type: ErrorTypeUnknown, Code: 0, Message: "NNTP pool not found", Err: nil} + ErrNoAvailableConnection = &Error{Type: ErrorTypeNoAvailableConnection, Code: 0, Message: "no available connection in pool", Err: nil} +) + +func (e *Error) Error() string { + if e.Err != nil { + return fmt.Sprintf("NNTP %s (code %d): %s - %v", e.Type.String(), e.Code, e.Message, e.Err) + } + return fmt.Sprintf("NNTP %s (code %d): %s", e.Type.String(), e.Code, e.Message) +} + +func (e *Error) Unwrap() error { + return e.Err +} + +func (e *Error) Is(target error) bool { + if t, ok := target.(*Error); ok { + return e.Type == t.Type + } + return false +} + +// IsRetryable returns true if the error might be resolved by retrying +func (e *Error) IsRetryable() bool { + switch e.Type { + case ErrorTypeConnection, ErrorTypeTimeout, ErrorTypeServerBusy: + return true + case ErrorTypeArticleNotFound, ErrorTypeGroupNotFound, ErrorTypePermissionDenied, ErrorTypeAuthentication: + return false + default: + return false + } +} + +// ShouldStopParsing returns true if this error should stop the entire parsing process +func (e *Error) ShouldStopParsing() bool { + switch e.Type { + case ErrorTypeAuthentication, ErrorTypePermissionDenied: + return true // Critical auth issues + case ErrorTypeConnection: + return false // Can continue with other connections + case ErrorTypeArticleNotFound: + return false // Can continue searching for other articles + case ErrorTypeServerBusy: + return false // Temporary issue + default: + return false + } +} + +func (et ErrorType) String() string { + switch et { + case ErrorTypeConnection: + return "CONNECTION" + case ErrorTypeAuthentication: + return "AUTHENTICATION" + case ErrorTypeTimeout: + return "TIMEOUT" + case ErrorTypeArticleNotFound: + return "ARTICLE_NOT_FOUND" + case ErrorTypeGroupNotFound: + return "GROUP_NOT_FOUND" + case ErrorTypePermissionDenied: + return "PERMISSION_DENIED" + case ErrorTypeServerBusy: + return "SERVER_BUSY" + case ErrorTypeInvalidCommand: + return "INVALID_COMMAND" + case ErrorTypeProtocol: + return "PROTOCOL" + case ErrorTypeYencDecode: + return "YENC_DECODE" + default: + return "UNKNOWN" + } +} + +// Helper functions to create specific errors +func NewConnectionError(err error) *Error { + return &Error{ + Type: ErrorTypeConnection, + Message: "connection failed", + Err: err, + } +} + +func NewTimeoutError(err error) *Error { + return &Error{ + Type: ErrorTypeTimeout, + Message: "operation timed out", + Err: err, + } +} + +func NewProtocolError(code int, message string) *Error { + return &Error{ + Type: ErrorTypeProtocol, + Code: code, + Message: message, + } +} + +func NewYencDecodeError(err error) *Error { + return &Error{ + Type: ErrorTypeYencDecode, + Message: "yEnc decode failed", + Err: err, + } +} + +// classifyNNTPError classifies an NNTP response code into an error type +func classifyNNTPError(code int, message string) *Error { + switch { + case code == 430 || code == 423: + return &Error{Type: ErrorTypeArticleNotFound, Code: code, Message: message} + case code == 411: + return &Error{Type: ErrorTypeGroupNotFound, Code: code, Message: message} + case code == 502 || code == 503: + return &Error{Type: ErrorTypePermissionDenied, Code: code, Message: message} + case code == 481 || code == 482: + return &Error{Type: ErrorTypeAuthentication, Code: code, Message: message} + case code == 400: + return &Error{Type: ErrorTypeServerBusy, Code: code, Message: message} + case code == 500 || code == 501: + return &Error{Type: ErrorTypeInvalidCommand, Code: code, Message: message} + case code >= 400: + return &Error{Type: ErrorTypeProtocol, Code: code, Message: message} + default: + return &Error{Type: ErrorTypeUnknown, Code: code, Message: message} + } +} + +func IsArticleNotFoundError(err error) bool { + var nntpErr *Error + if errors.As(err, &nntpErr) { + return nntpErr.Type == ErrorTypeArticleNotFound + } + return false +} + +func IsAuthenticationError(err error) bool { + var nntpErr *Error + if errors.As(err, &nntpErr) { + return nntpErr.Type == ErrorTypeAuthentication + } + return false +} + +func IsRetryableError(err error) bool { + var nntpErr *Error + if errors.As(err, &nntpErr) { + return nntpErr.IsRetryable() + } + return false +} diff --git a/internal/nntp/pool.go b/internal/nntp/pool.go new file mode 100644 index 0000000..858f26b --- /dev/null +++ b/internal/nntp/pool.go @@ -0,0 +1,299 @@ +package nntp + +import ( + "bufio" + "context" + "crypto/tls" + "fmt" + "github.com/rs/zerolog" + "github.com/sirrobot01/decypharr/internal/config" + "net" + "net/textproto" + "sync" + "sync/atomic" + "time" +) + +// Pool manages a pool of NNTP connections +type Pool struct { + address, username, password string + maxConns, port int + ssl bool + useTLS bool + connections chan *Connection + logger zerolog.Logger + closed atomic.Bool + totalConnections atomic.Int32 + activeConnections atomic.Int32 +} + +// Segment represents a usenet segment +type Segment struct { + MessageID string + Number int + Bytes int64 + Data []byte +} + +// Article represents a complete usenet article +type Article struct { + MessageID string + Subject string + From string + Date string + Groups []string + Body []byte + Size int64 +} + +// Response represents an NNTP server response +type Response struct { + Code int + Message string + Lines []string +} + +// GroupInfo represents information about a newsgroup +type GroupInfo struct { + Name string + Count int // Number of articles in the group + Low int // Lowest article number + High int // Highest article number +} + +// NewPool creates a new NNTP connection pool +func NewPool(provider config.UsenetProvider, logger zerolog.Logger) (*Pool, error) { + maxConns := provider.Connections + if maxConns <= 0 { + maxConns = 1 + } + + pool := &Pool{ + address: provider.Host, + username: provider.Username, + password: provider.Password, + port: provider.Port, + maxConns: maxConns, + ssl: provider.SSL, + useTLS: provider.UseTLS, + connections: make(chan *Connection, maxConns), + logger: logger, + } + + return pool.initializeConnections() +} + +func (p *Pool) initializeConnections() (*Pool, error) { + var wg sync.WaitGroup + var mu sync.Mutex + var successfulConnections []*Connection + var errs []error + + // Create connections concurrently + for i := 0; i < p.maxConns; i++ { + wg.Add(1) + go func(connIndex int) { + defer wg.Done() + + conn, err := p.createConnection() + + mu.Lock() + defer mu.Unlock() + + if err != nil { + errs = append(errs, err) + } else { + successfulConnections = append(successfulConnections, conn) + } + }(i) + } + + // Wait for all connection attempts to complete + wg.Wait() + + // Add successful connections to the pool + for _, conn := range successfulConnections { + p.connections <- conn + } + p.totalConnections.Store(int32(len(successfulConnections))) + + if len(successfulConnections) == 0 { + return nil, fmt.Errorf("failed to create any connections: %v", errs) + } + + // Log results + p.logger.Info(). + Str("server", p.address). + Int("port", p.port). + Int("requested_connections", p.maxConns). + Int("successful_connections", len(successfulConnections)). + Int("failed_connections", len(errs)). + Msg("NNTP connection pool created") + + // If some connections failed, log a warning but continue + if len(errs) > 0 { + p.logger.Warn(). + Int("failed_count", len(errs)). + Msg("Some connections failed during pool initialization") + } + + return p, nil +} + +// Get retrieves a connection from the pool +func (p *Pool) Get(ctx context.Context) (*Connection, error) { + if p.closed.Load() { + return nil, NewConnectionError(fmt.Errorf("connection pool is closed")) + } + + select { + case conn := <-p.connections: + if conn == nil { + return nil, NewConnectionError(fmt.Errorf("received nil connection from pool")) + } + p.activeConnections.Add(1) + + if err := conn.ping(); err != nil { + p.activeConnections.Add(-1) + err := conn.close() + if err != nil { + return nil, err + } + // Create a new connection + newConn, err := p.createConnection() + if err != nil { + return nil, NewConnectionError(fmt.Errorf("failed to create replacement connection: %w", err)) + } + p.activeConnections.Add(1) + return newConn, nil + } + + return conn, nil + case <-ctx.Done(): + return nil, NewTimeoutError(ctx.Err()) + } +} + +// Put returns a connection to the pool +func (p *Pool) Put(conn *Connection) { + if conn == nil { + return + } + + defer p.activeConnections.Add(-1) + + if p.closed.Load() { + conn.close() + return + } + + // Try non-blocking first + select { + case p.connections <- conn: + return + default: + } + + // If pool is full, this usually means we have too many connections + // Force return by making space (close oldest connection) + select { + case oldConn := <-p.connections: + oldConn.close() // Close the old connection + p.connections <- conn // Put the new one back + case <-time.After(1 * time.Second): + // Still can't return - close this connection + conn.close() + } +} + +// Close closes all connections in the pool +func (p *Pool) Close() error { + + if p.closed.Load() { + return nil + } + p.closed.Store(true) + + close(p.connections) + for conn := range p.connections { + err := conn.close() + if err != nil { + return err + } + } + + p.logger.Info().Msg("NNTP connection pool closed") + return nil +} + +// createConnection creates a new NNTP connection with proper error handling +func (p *Pool) createConnection() (*Connection, error) { + addr := fmt.Sprintf("%s:%d", p.address, p.port) + + var conn net.Conn + var err error + + if p.ssl { + conn, err = tls.DialWithDialer(&net.Dialer{}, "tcp", addr, &tls.Config{ + InsecureSkipVerify: false, + }) + } else { + conn, err = net.Dial("tcp", addr) + } + + if err != nil { + return nil, NewConnectionError(fmt.Errorf("failed to connect to %s: %w", addr, err)) + } + + reader := bufio.NewReaderSize(conn, 256*1024) // 256KB buffer for better performance + writer := bufio.NewWriterSize(conn, 256*1024) // 256KB buffer for better performance + text := textproto.NewConn(conn) + + nntpConn := &Connection{ + username: p.username, + password: p.password, + address: p.address, + port: p.port, + conn: conn, + text: text, + reader: reader, + writer: writer, + logger: p.logger, + } + + // Read welcome message + _, err = nntpConn.readResponse() + if err != nil { + conn.Close() + return nil, NewConnectionError(fmt.Errorf("failed to read welcome message: %w", err)) + } + + // Authenticate if credentials are provided + if p.username != "" && p.password != "" { + if err := nntpConn.authenticate(); err != nil { + conn.Close() + return nil, err // authenticate() already returns NNTPError + } + } + + // Enable TLS if requested (STARTTLS) + if p.useTLS && !p.ssl { + if err := nntpConn.startTLS(); err != nil { + conn.Close() + return nil, err // startTLS() already returns NNTPError + } + } + return nntpConn, nil +} + +func (p *Pool) ConnectionCount() int { + return int(p.totalConnections.Load()) +} + +func (p *Pool) ActiveConnections() int { + return int(p.activeConnections.Load()) +} + +func (p *Pool) IsFree() bool { + return p.ActiveConnections() < p.maxConns +} diff --git a/internal/request/request.go b/internal/request/request.go index 6fa1f43..39596cb 100644 --- a/internal/request/request.go +++ b/internal/request/request.go @@ -5,7 +5,6 @@ import ( "context" "crypto/tls" "encoding/json" - "errors" "fmt" "github.com/rs/zerolog" "github.com/sirrobot01/decypharr/internal/logger" @@ -180,8 +179,7 @@ func (c *Client) Do(req *http.Request) (*http.Response, error) { resp, err = c.doRequest(req) if err != nil { - // Check if this is a network error that might be worth retrying - if isRetryableError(err) && attempt < c.maxRetries { + if attempt < c.maxRetries { // Apply backoff with jitter jitter := time.Duration(rand.Int63n(int64(backoff / 4))) sleepTime := backoff + jitter @@ -390,30 +388,3 @@ func Default() *Client { }) return instance } - -func isRetryableError(err error) bool { - errString := err.Error() - - // Connection reset and other network errors - if strings.Contains(errString, "connection reset by peer") || - strings.Contains(errString, "read: connection reset") || - strings.Contains(errString, "connection refused") || - strings.Contains(errString, "network is unreachable") || - strings.Contains(errString, "connection timed out") || - strings.Contains(errString, "no such host") || - strings.Contains(errString, "i/o timeout") || - strings.Contains(errString, "unexpected EOF") || - strings.Contains(errString, "TLS handshake timeout") { - return true - } - - // Check for net.Error type which can provide more information - var netErr net.Error - if errors.As(err, &netErr) { - // Retry on timeout errors and temporary errors - return netErr.Timeout() - } - - // Not a retryable error - return false -} diff --git a/internal/utils/misc.go b/internal/utils/misc.go index b9e733e..4b7e131 100644 --- a/internal/utils/misc.go +++ b/internal/utils/misc.go @@ -1,5 +1,16 @@ package utils +import ( + "fmt" + "io" + "mime" + "net/http" + "net/url" + "path" + "path/filepath" + "strings" +) + func RemoveItem[S ~[]E, E comparable](s S, values ...E) S { result := make(S, 0, len(s)) outer: @@ -22,3 +33,131 @@ func Contains(slice []string, value string) bool { } return false } + +func GenerateHash(data string) string { + // Simple hash generation using a basic algorithm (for demonstration purposes) + _hash := 0 + for _, char := range data { + _hash = (_hash*31 + int(char)) % 1000003 // Simple hash function + } + return string(rune(_hash)) +} + +func DownloadFile(url string) (string, []byte, error) { + resp, err := http.Get(url) + if err != nil { + return "", nil, fmt.Errorf("failed to download file: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", nil, fmt.Errorf("failed to download file: status code %d", resp.StatusCode) + } + + filename := getFilenameFromResponse(resp, url) + + data, err := io.ReadAll(resp.Body) + if err != nil { + return "", nil, fmt.Errorf("failed to read response body: %w", err) + } + + return filename, data, nil +} + +func getFilenameFromResponse(resp *http.Response, originalURL string) string { + // 1. Try Content-Disposition header + if cd := resp.Header.Get("Content-Disposition"); cd != "" { + if _, params, err := mime.ParseMediaType(cd); err == nil { + if filename := params["filename"]; filename != "" { + return filename + } + } + } + + // 2. Try to decode URL-encoded filename from Content-Disposition + if cd := resp.Header.Get("Content-Disposition"); cd != "" { + if strings.Contains(cd, "filename*=") { + // Handle RFC 5987 encoded filenames + parts := strings.Split(cd, "filename*=") + if len(parts) > 1 { + encoded := strings.Trim(parts[1], `"`) + if strings.HasPrefix(encoded, "UTF-8''") { + if decoded, err := url.QueryUnescape(encoded[7:]); err == nil { + return decoded + } + } + } + } + } + + // 3. Fall back to URL path + if parsedURL, err := url.Parse(originalURL); err == nil { + if filename := filepath.Base(parsedURL.Path); filename != "." && filename != "/" { + // URL decode the filename + if decoded, err := url.QueryUnescape(filename); err == nil { + return decoded + } + return filename + } + } + + // 4. Default filename + return "downloaded_file" +} + +func ValidateServiceURL(urlStr string) error { + if urlStr == "" { + return fmt.Errorf("URL cannot be empty") + } + + // Try parsing as full URL first + u, err := url.Parse(urlStr) + if err == nil && u.Scheme != "" && u.Host != "" { + // It's a full URL, validate scheme + if u.Scheme != "http" && u.Scheme != "https" { + return fmt.Errorf("URL scheme must be http or https") + } + return nil + } + + // Check if it's a host:port format (no scheme) + if strings.Contains(urlStr, ":") && !strings.Contains(urlStr, "://") { + // Try parsing with http:// prefix + testURL := "http://" + urlStr + u, err := url.Parse(testURL) + if err != nil { + return fmt.Errorf("invalid host:port format: %w", err) + } + + if u.Host == "" { + return fmt.Errorf("host is required in host:port format") + } + + // Validate port number + if u.Port() == "" { + return fmt.Errorf("port is required in host:port format") + } + + return nil + } + + return fmt.Errorf("invalid URL format: %s", urlStr) +} + +func ExtractFilenameFromURL(rawURL string) string { + // Parse the URL + parsedURL, err := url.Parse(rawURL) + if err != nil { + return "" + } + + // Get the base filename from path + filename := path.Base(parsedURL.Path) + + // Handle edge cases + if filename == "/" || filename == "." || filename == "" { + return "" + } + + return filename +} diff --git a/internal/utils/regex.go b/internal/utils/regex.go index e71d15f..7e5a01f 100644 --- a/internal/utils/regex.go +++ b/internal/utils/regex.go @@ -57,3 +57,15 @@ func IsSampleFile(path string) bool { } return RegexMatch(sampleRegex, path) } + +func IsParFile(path string) bool { + ext := filepath.Ext(path) + return strings.EqualFold(ext, ".par") || strings.EqualFold(ext, ".par2") +} + +func IsRarFile(path string) bool { + ext := filepath.Ext(path) + return strings.EqualFold(ext, ".rar") || strings.EqualFold(ext, ".r00") || + strings.EqualFold(ext, ".r01") || strings.EqualFold(ext, ".r02") || + strings.EqualFold(ext, ".r03") || strings.EqualFold(ext, ".r04") +} diff --git a/pkg/qbit/context.go b/pkg/qbit/context.go index 79dbcf7..92f646c 100644 --- a/pkg/qbit/context.go +++ b/pkg/qbit/context.go @@ -3,12 +3,11 @@ package qbit import ( "context" "encoding/base64" - "fmt" "github.com/go-chi/chi/v5" + "github.com/sirrobot01/decypharr/internal/utils" "github.com/sirrobot01/decypharr/pkg/arr" "github.com/sirrobot01/decypharr/pkg/store" "net/http" - "net/url" "strings" ) @@ -20,45 +19,6 @@ const ( arrKey contextKey = "arr" ) -func validateServiceURL(urlStr string) error { - if urlStr == "" { - return fmt.Errorf("URL cannot be empty") - } - - // Try parsing as full URL first - u, err := url.Parse(urlStr) - if err == nil && u.Scheme != "" && u.Host != "" { - // It's a full URL, validate scheme - if u.Scheme != "http" && u.Scheme != "https" { - return fmt.Errorf("URL scheme must be http or https") - } - return nil - } - - // Check if it's a host:port format (no scheme) - if strings.Contains(urlStr, ":") && !strings.Contains(urlStr, "://") { - // Try parsing with http:// prefix - testURL := "http://" + urlStr - u, err := url.Parse(testURL) - if err != nil { - return fmt.Errorf("invalid host:port format: %w", err) - } - - if u.Host == "" { - return fmt.Errorf("host is required in host:port format") - } - - // Validate port number - if u.Port() == "" { - return fmt.Errorf("port is required in host:port format") - } - - return nil - } - - return fmt.Errorf("invalid URL format: %s", urlStr) -} - func getCategory(ctx context.Context) string { if category, ok := ctx.Value(categoryKey).(string); ok { return category @@ -146,7 +106,7 @@ func (q *QBit) authContext(next http.Handler) http.Handler { } } a.Source = "auto" - if err := validateServiceURL(a.Host); err != nil { + if err := utils.ValidateServiceURL(a.Host); err != nil { // Return silently, no need to raise a problem. Just do not add the Arr to the context/config.json next.ServeHTTP(w, r) return diff --git a/pkg/qbit/qbit.go b/pkg/qbit/qbit.go index 77a4334..493089e 100644 --- a/pkg/qbit/qbit.go +++ b/pkg/qbit/qbit.go @@ -18,13 +18,16 @@ type QBit struct { } func New() *QBit { - _cfg := config.Get() - cfg := _cfg.QBitTorrent + cfg := config.Get() + qbitCfg := cfg.QBitTorrent + if qbitCfg == nil { + return nil + } return &QBit{ - Username: cfg.Username, - Password: cfg.Password, - DownloadFolder: cfg.DownloadFolder, - Categories: cfg.Categories, + Username: qbitCfg.Username, + Password: qbitCfg.Password, + DownloadFolder: qbitCfg.DownloadFolder, + Categories: qbitCfg.Categories, storage: store.Get().Torrents(), logger: logger.New("qbit"), } diff --git a/pkg/rar/rarar.go b/pkg/rar/rarar.go index ae9f4b5..f4022da 100644 --- a/pkg/rar/rarar.go +++ b/pkg/rar/rarar.go @@ -684,18 +684,3 @@ func (r *Reader) ExtractFile(file *File) ([]byte, error) { return r.readBytes(file.DataOffset, int(file.CompressedSize)) } - -// Helper functions -func min(a, b int) int { - if a < b { - return a - } - return b -} - -func max(a, b int) int { - if a > b { - return a - } - return b -} diff --git a/pkg/repair/repair.go b/pkg/repair/repair.go index bbbb470..8a38b8d 100644 --- a/pkg/repair/repair.go +++ b/pkg/repair/repair.go @@ -214,7 +214,6 @@ func (r *Repair) newJob(arrsNames []string, mediaIDs []string) *Job { } } -// initRun initializes the repair run, setting up necessary configurations, checks and caches func (r *Repair) initRun(ctx context.Context) { if r.useWebdav { // Webdav use is enabled, initialize debrid torrent caches diff --git a/pkg/sabnzbd/config.go b/pkg/sabnzbd/config.go new file mode 100644 index 0000000..959fb2e --- /dev/null +++ b/pkg/sabnzbd/config.go @@ -0,0 +1,171 @@ +package sabnzbd + +// ConfigResponse represents configuration response +type ConfigResponse struct { + Config *Config `json:"config"` +} + +type ConfigNewzbin struct { + Username string `json:"username"` + BookmarkRate int `json:"bookmark_rate"` + Url string `json:"url"` + Bookmarks int `json:"bookmarks"` + Password string `json:"password"` + Unbookmark int `json:"unbookmark"` +} + +// Category represents a SABnzbd category +type Category struct { + Name string `json:"name"` + Order int `json:"order"` + Pp string `json:"pp"` + Script string `json:"script"` + Dir string `json:"dir"` + NewzBin string `json:"newzbin"` + Priority string `json:"priority"` +} + +// Server represents a usenet server +type Server struct { + Name string `json:"name"` + Host string `json:"host"` + Port int `json:"port"` + Username string `json:"username"` + Password string `json:"password"` + Connections int `json:"connections"` + Retention int `json:"retention"` + Priority int `json:"priority"` + SSL bool `json:"ssl"` + Optional bool `json:"optional"` +} + +type Config struct { + Misc MiscConfig `json:"misc"` + Categories []Category `json:"categories"` + Servers []Server `json:"servers"` +} + +type MiscConfig struct { + // Directory Configuration + CompleteDir string `json:"complete_dir"` + DownloadDir string `json:"download_dir"` + AdminDir string `json:"admin_dir"` + NzbBackupDir string `json:"nzb_backup_dir"` + ScriptDir string `json:"script_dir"` + EmailDir string `json:"email_dir"` + WebDir string `json:"web_dir"` + + // Processing Options + ParOption string `json:"par_option"` + ParOptionConvert string `json:"par_option_convert"` + ParOptionDuplicate string `json:"par_option_duplicate"` + DirectUnpack string `json:"direct_unpack"` + FlatUnpack string `json:"flat_unpack"` + EnableRecursiveUnpack string `json:"enable_recursive_unpack"` + OverwriteFiles string `json:"overwrite_files"` + IgnoreWrongUnrar string `json:"ignore_wrong_unrar"` + IgnoreUnrarDates string `json:"ignore_unrar_dates"` + PreCheck string `json:"pre_check"` + + // File Handling + Permissions string `json:"permissions"` + FolderRename string `json:"folder_rename"` + FileRename string `json:"file_rename"` + ReplaceIllegal string `json:"replace_illegal"` + ReplaceDots string `json:"replace_dots"` + ReplaceSpaces string `json:"replace_spaces"` + SanitizeSafe string `json:"sanitize_safe"` + IgnoreSamples string `json:"ignore_samples"` + UnwantedExtensions []string `json:"unwanted_extensions"` + ActionOnUnwanted string `json:"action_on_unwanted"` + ActionOnDuplicate string `json:"action_on_duplicate"` + BackupForDuplicates string `json:"backup_for_duplicates"` + CleanupList []string `json:"cleanup_list"` + DeobfuscateFinalFilenames string `json:"deobfuscate_final_filenames"` + + // Scripts and Processing + PreScript string `json:"pre_script"` + PostScript string `json:"post_script"` + EmptyPostproc string `json:"empty_postproc"` + PauseOnPostProcessing string `json:"pause_on_post_processing"` + + // System Resources + Nice string `json:"nice"` + NiceUnpack string `json:"nice_unpack"` + Ionice string `json:"ionice"` + Fsync string `json:"fsync"` + + // Bandwidth and Performance + BandwidthMax string `json:"bandwidth_max"` + BandwidthPerc string `json:"bandwidth_perc"` + RefreshRate string `json:"refresh_rate"` + DirscanSpeed string `json:"dirscan_speed"` + FolderMaxLength string `json:"folder_max_length"` + PropagationDelay string `json:"propagation_delay"` + + // Storage Management + DownloadFree string `json:"download_free"` + CompleteFree string `json:"complete_free"` + + // Queue Management + QueueComplete string `json:"queue_complete"` + QueueCompletePers string `json:"queue_complete_pers"` + AutoSort string `json:"auto_sort"` + NewNzbOnFailure string `json:"new_nzb_on_failure"` + PauseOnPwrar string `json:"pause_on_pwrar"` + WarnedOldQueue string `json:"warned_old_queue"` + + // Web Interface + WebHost string `json:"web_host"` + WebPort string `json:"web_port"` + WebUsername string `json:"web_username"` + WebPassword string `json:"web_password"` + WebColor string `json:"web_color"` + WebColor2 string `json:"web_color2"` + AutoBrowser string `json:"auto_browser"` + Autobrowser string `json:"autobrowser"` // Duplicate field - may need to resolve + + // HTTPS Configuration + EnableHTTPS string `json:"enable_https"` + EnableHTTPSVerification string `json:"enable_https_verification"` + HTTPSPort string `json:"https_port"` + HTTPSCert string `json:"https_cert"` + HTTPSKey string `json:"https_key"` + HTTPSChain string `json:"https_chain"` + + // Security and API + APIKey string `json:"api_key"` + NzbKey string `json:"nzb_key"` + HostWhitelist string `json:"host_whitelist"` + LocalRanges []string `json:"local_ranges"` + InetExposure string `json:"inet_exposure"` + APILogging string `json:"api_logging"` + APIWarnings string `json:"api_warnings"` + + // Logging + LogLevel string `json:"log_level"` + LogSize string `json:"log_size"` + MaxLogSize string `json:"max_log_size"` + LogBackups string `json:"log_backups"` + LogNew string `json:"log_new"` + + // Notifications + MatrixUsername string `json:"matrix_username"` + MatrixPassword string `json:"matrix_password"` + MatrixServer string `json:"matrix_server"` + MatrixRoom string `json:"matrix_room"` + + // Miscellaneous + ConfigLock string `json:"config_lock"` + Language string `json:"language"` + CheckNewRel string `json:"check_new_rel"` + RSSFilenames string `json:"rss_filenames"` + IPv6Hosting string `json:"ipv6_hosting"` + EnableBonjour string `json:"enable_bonjour"` + Cherryhost string `json:"cherryhost"` + WinMenu string `json:"win_menu"` + AMPM string `json:"ampm"` + NotifiedNewSkin string `json:"notified_new_skin"` + HelpURI string `json:"helpuri"` + SSDURI string `json:"ssduri"` +} diff --git a/pkg/sabnzbd/context.go b/pkg/sabnzbd/context.go new file mode 100644 index 0000000..726f730 --- /dev/null +++ b/pkg/sabnzbd/context.go @@ -0,0 +1,121 @@ +package sabnzbd + +import ( + "context" + "github.com/sirrobot01/decypharr/internal/utils" + "github.com/sirrobot01/decypharr/pkg/store" + "net/http" + "strings" + + "github.com/sirrobot01/decypharr/pkg/arr" +) + +type contextKey string + +const ( + apiKeyKey contextKey = "apikey" + modeKey contextKey = "mode" + arrKey contextKey = "arr" + categoryKey contextKey = "category" +) + +func getMode(ctx context.Context) string { + if mode, ok := ctx.Value(modeKey).(string); ok { + return mode + } + return "" +} + +func (s *SABnzbd) categoryContext(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + category := r.URL.Query().Get("category") + if category == "" { + // Check form data + _ = r.ParseForm() + category = r.Form.Get("category") + } + if category == "" { + category = r.FormValue("category") + } + + ctx := context.WithValue(r.Context(), categoryKey, strings.TrimSpace(category)) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func getArrFromContext(ctx context.Context) *arr.Arr { + if a, ok := ctx.Value(arrKey).(*arr.Arr); ok { + return a + } + return nil +} + +func getCategory(ctx context.Context) string { + if category, ok := ctx.Value(categoryKey).(string); ok { + return category + } + return "" +} + +// modeContext extracts the mode parameter from the request +func (s *SABnzbd) modeContext(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mode := r.URL.Query().Get("mode") + if mode == "" { + // Check form data + _ = r.ParseForm() + mode = r.Form.Get("mode") + } + + // Extract category for Arr integration + category := r.URL.Query().Get("cat") + if category == "" { + category = r.Form.Get("cat") + } + + // Create a default Arr instance for the category + downloadUncached := false + a := arr.New(category, "", "", false, false, &downloadUncached, "", "auto") + + ctx := context.WithValue(r.Context(), modeKey, strings.TrimSpace(mode)) + ctx = context.WithValue(ctx, arrKey, a) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// authContext creates a middleware that extracts the Arr host and token from the Authorization header +// and adds it to the request context. +// This is used to identify the Arr instance for the request. +// Only a valid host and token will be added to the context/config. The rest are manual +func (s *SABnzbd) authContext(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + host := r.FormValue("ma_username") + token := r.FormValue("ma_password") + category := getCategory(r.Context()) + arrs := store.Get().Arr() + // Check if arr exists + a := arrs.Get(category) + if a == nil { + // Arr is not configured, create a new one + downloadUncached := false + a = arr.New(category, "", "", false, false, &downloadUncached, "", "auto") + } + host = strings.TrimSpace(host) + if host != "" { + a.Host = host + } + token = strings.TrimSpace(token) + if token != "" { + a.Token = token + } + a.Source = "auto" + if err := utils.ValidateServiceURL(a.Host); err != nil { + // Return silently, no need to raise a problem. Just do not add the Arr to the context/config.json + next.ServeHTTP(w, r) + return + } + arrs.AddOrUpdate(a) + ctx := context.WithValue(r.Context(), arrKey, a) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} diff --git a/pkg/sabnzbd/handlers.go b/pkg/sabnzbd/handlers.go new file mode 100644 index 0000000..2334a38 --- /dev/null +++ b/pkg/sabnzbd/handlers.go @@ -0,0 +1,476 @@ +package sabnzbd + +import ( + "context" + "fmt" + "github.com/sirrobot01/decypharr/internal/request" + "github.com/sirrobot01/decypharr/internal/utils" + "github.com/sirrobot01/decypharr/pkg/arr" + "github.com/sirrobot01/decypharr/pkg/usenet" + "io" + "net/http" + "strconv" + "strings" + "time" +) + +// handleAPI is the main handler for all SABnzbd API requests +func (s *SABnzbd) handleAPI(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + mode := getMode(ctx) + + switch mode { + case ModeQueue: + s.handleQueue(w, r) + case ModeHistory: + s.handleHistory(w, r) + case ModeConfig: + s.handleConfig(w, r) + case ModeStatus, ModeFullStatus: + s.handleStatus(w, r) + case ModeGetConfig: + s.handleConfig(w, r) + case ModeAddURL: + s.handleAddURL(w, r) + case ModeAddFile: + s.handleAddFile(w, r) + case ModeVersion: + s.handleVersion(w, r) + case ModeGetCats: + s.handleGetCategories(w, r) + case ModeGetScripts: + s.handleGetScripts(w, r) + case ModeGetFiles: + s.handleGetFiles(w, r) + default: + // Default to queue if no mode specified + s.logger.Warn().Str("mode", mode).Msg("Unknown API mode, returning 404") + http.Error(w, "Not Found", http.StatusNotFound) + + } +} + +func (s *SABnzbd) handleQueue(w http.ResponseWriter, r *http.Request) { + name := r.FormValue("name") + if name == "" { + s.handleListQueue(w, r) + return + } + name = strings.ToLower(strings.TrimSpace(name)) + switch name { + case "delete": + s.handleQueueDelete(w, r) + case "pause": + s.handleQueuePause(w, r) + case "resume": + s.handleQueueResume(w, r) + } +} + +// handleResume handles resume operations +func (s *SABnzbd) handleQueueResume(w http.ResponseWriter, r *http.Request) { + response := StatusResponse{Status: true} + request.JSONResponse(w, response, http.StatusOK) +} + +// handleDelete handles delete operations +func (s *SABnzbd) handleQueueDelete(w http.ResponseWriter, r *http.Request) { + nzoIDs := r.FormValue("value") + if nzoIDs == "" { + s.writeError(w, "No NZB IDs provided", http.StatusBadRequest) + return + } + + var successCount int + var errors []string + + for _, nzoID := range strings.Split(nzoIDs, ",") { + nzoID = strings.TrimSpace(nzoID) + if nzoID == "" { + continue // Skip empty IDs + } + + s.logger.Info().Str("nzo_id", nzoID).Msg("Deleting NZB") + + // Use atomic delete operation + if err := s.usenet.Store().AtomicDelete(nzoID); err != nil { + s.logger.Error(). + Err(err). + Str("nzo_id", nzoID). + Msg("Failed to delete NZB") + errors = append(errors, fmt.Sprintf("Failed to delete %s: %v", nzoID, err)) + } else { + successCount++ + } + } + + // Return response with success/error information + if len(errors) > 0 { + if successCount == 0 { + // All deletions failed + s.writeError(w, fmt.Sprintf("All deletions failed: %s", strings.Join(errors, "; ")), http.StatusInternalServerError) + return + } else { + // Partial success + s.logger.Warn(). + Int("success_count", successCount). + Int("error_count", len(errors)). + Strs("errors", errors). + Msg("Partial success in queue deletion") + } + } + + response := StatusResponse{ + Status: true, + Error: "", // Could add error details here if needed + } + request.JSONResponse(w, response, http.StatusOK) +} + +// handlePause handles pause operations +func (s *SABnzbd) handleQueuePause(w http.ResponseWriter, r *http.Request) { + response := StatusResponse{Status: true} + request.JSONResponse(w, response, http.StatusOK) +} + +// handleQueue returns the current download queue +func (s *SABnzbd) handleListQueue(w http.ResponseWriter, r *http.Request) { + nzbs := s.usenet.Store().GetQueue() + + queue := Queue{ + Version: Version, + Slots: []QueueSlot{}, + } + + // Convert NZBs to queue slots + for _, nzb := range nzbs { + if nzb.ETA <= 0 { + nzb.ETA = 0 // Ensure ETA is non-negative + } + var timeLeft string + if nzb.ETA == 0 { + timeLeft = "00:00:00" // If ETA is 0, set TimeLeft to "00:00:00" + } else { + // Convert ETA from seconds to "HH:MM:SS" format + duration := time.Duration(nzb.ETA) * time.Second + timeLeft = duration.String() + } + slot := QueueSlot{ + Status: s.mapNZBStatus(nzb.Status), + Mb: nzb.TotalSize, + Filename: nzb.Name, + Cat: nzb.Category, + MBLeft: 0, + Percentage: nzb.Percentage, + NzoId: nzb.ID, + Size: nzb.TotalSize, + TimeLeft: timeLeft, // This is in "00:00:00" format + } + queue.Slots = append(queue.Slots, slot) + } + + response := QueueResponse{ + Queue: queue, + Status: true, + Version: Version, + } + + request.JSONResponse(w, response, http.StatusOK) +} + +// handleHistory returns the download history +func (s *SABnzbd) handleHistory(w http.ResponseWriter, r *http.Request) { + limitStr := r.FormValue("limit") + if limitStr == "" { + limitStr = "0" + } + limit, err := strconv.Atoi(limitStr) + if err != nil { + s.logger.Error().Err(err).Msg("Invalid limit parameter for history") + s.writeError(w, "Invalid limit parameter", http.StatusBadRequest) + return + } + if limit < 0 { + limit = 0 + } + history := s.getHistory(r.Context(), limit) + + response := HistoryResponse{ + History: history, + } + + request.JSONResponse(w, response, http.StatusOK) +} + +// handleConfig returns the configuration +func (s *SABnzbd) handleConfig(w http.ResponseWriter, r *http.Request) { + + response := ConfigResponse{ + Config: s.config, + } + + request.JSONResponse(w, response, http.StatusOK) +} + +// handleAddURL handles adding NZB by URL +func (s *SABnzbd) handleAddURL(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + _arr := getArrFromContext(ctx) + cat := getCategory(ctx) + + if _arr == nil { + // If Arr is not in context, create a new one with default values + _arr = arr.New(cat, "", "", false, false, nil, "", "") + } + + if r.Method != http.MethodPost { + s.logger.Warn().Str("method", r.Method).Msg("Invalid method") + s.writeError(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + url := r.FormValue("name") + action := r.FormValue("action") + downloadDir := r.FormValue("download_dir") + if action == "" { + action = "symlink" + } + if downloadDir == "" { + downloadDir = s.config.Misc.DownloadDir + } + + if url == "" { + s.writeError(w, "URL is required", http.StatusBadRequest) + return + } + + nzoID, err := s.addNZBURL(ctx, url, _arr, action, downloadDir) + if err != nil { + s.writeError(w, err.Error(), http.StatusInternalServerError) + return + } + if nzoID == "" { + s.writeError(w, "Failed to add NZB", http.StatusInternalServerError) + return + } + + response := AddNZBResponse{ + Status: true, + NzoIds: []string{nzoID}, + } + + request.JSONResponse(w, response, http.StatusOK) +} + +// handleAddFile handles NZB file uploads +func (s *SABnzbd) handleAddFile(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + _arr := getArrFromContext(ctx) + cat := getCategory(ctx) + + if _arr == nil { + // If Arr is not in context, create a new one with default values + _arr = arr.New(cat, "", "", false, false, nil, "", "") + } + + if r.Method != http.MethodPost { + s.writeError(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Parse multipart form + err := r.ParseMultipartForm(32 << 20) // 32 MB limit + if err != nil { + s.writeError(w, "Failed to parse multipart form", http.StatusBadRequest) + return + } + + file, header, err := r.FormFile("name") + if err != nil { + s.writeError(w, "No file uploaded", http.StatusBadRequest) + return + } + defer file.Close() + + // Read file content + content, err := io.ReadAll(file) + if err != nil { + s.writeError(w, "Failed to read file", http.StatusInternalServerError) + return + } + action := r.FormValue("action") + downloadDir := r.FormValue("download_dir") + if action == "" { + action = "symlink" + } + if downloadDir == "" { + downloadDir = s.config.Misc.DownloadDir + } + + // Process NZB file + nzbID, err := s.addNZBFile(ctx, content, header.Filename, _arr, action, downloadDir) + if err != nil { + s.writeError(w, fmt.Sprintf("Failed to add NZB file: %s", err.Error()), http.StatusInternalServerError) + return + } + if nzbID == "" { + s.writeError(w, "Failed to add NZB file", http.StatusInternalServerError) + return + } + + response := AddNZBResponse{ + Status: true, + NzoIds: []string{nzbID}, + } + + request.JSONResponse(w, response, http.StatusOK) +} + +// handleVersion returns version information +func (s *SABnzbd) handleVersion(w http.ResponseWriter, r *http.Request) { + response := VersionResponse{ + Version: Version, + } + request.JSONResponse(w, response, http.StatusOK) +} + +// handleGetCategories returns available categories +func (s *SABnzbd) handleGetCategories(w http.ResponseWriter, r *http.Request) { + categories := s.getCategories() + request.JSONResponse(w, categories, http.StatusOK) +} + +// handleGetScripts returns available scripts +func (s *SABnzbd) handleGetScripts(w http.ResponseWriter, r *http.Request) { + scripts := []string{"None"} + request.JSONResponse(w, scripts, http.StatusOK) +} + +// handleGetFiles returns files for a specific NZB +func (s *SABnzbd) handleGetFiles(w http.ResponseWriter, r *http.Request) { + nzoID := r.FormValue("value") + var files []string + + if nzoID != "" { + nzb := s.usenet.Store().Get(nzoID) + if nzb != nil { + for _, file := range nzb.Files { + files = append(files, file.Name) + } + } + } + + request.JSONResponse(w, files, http.StatusOK) +} + +func (s *SABnzbd) handleStatus(w http.ResponseWriter, r *http.Request) { + type status struct { + CompletedDir string `json:"completed_dir"` + } + response := struct { + Status status `json:"status"` + }{ + Status: status{ + CompletedDir: s.config.Misc.DownloadDir, + }, + } + request.JSONResponse(w, response, http.StatusOK) +} + +// Helper methods + +func (s *SABnzbd) getHistory(ctx context.Context, limit int) History { + cat := getCategory(ctx) + items := s.usenet.Store().GetHistory(cat, limit) + slots := make([]HistorySlot, 0, len(items)) + history := History{ + Version: Version, + Paused: false, + } + for _, item := range items { + slot := HistorySlot{ + Status: s.mapNZBStatus(item.Status), + Name: item.Name, + NZBName: item.Name, + NzoId: item.ID, + Category: item.Category, + FailMessage: item.FailMessage, + Bytes: item.TotalSize, + Storage: item.Storage, + } + slots = append(slots, slot) + } + history.Slots = slots + return history +} + +func (s *SABnzbd) writeError(w http.ResponseWriter, message string, status int) { + response := StatusResponse{ + Status: false, + Error: message, + } + request.JSONResponse(w, response, status) +} + +func (s *SABnzbd) mapNZBStatus(status string) string { + switch status { + case "downloading": + return StatusDownloading + case "completed": + return StatusCompleted + case "paused": + return StatusPaused + case "error", "failed": + return StatusFailed + case "processing": + return StatusProcessing + case "verifying": + return StatusVerifying + case "repairing": + return StatusRepairing + case "extracting": + return StatusExtracting + case "moving": + return StatusMoving + case "running": + return StatusRunning + default: + return StatusQueued + } +} + +func (s *SABnzbd) addNZBURL(ctx context.Context, url string, arr *arr.Arr, action, downloadDir string) (string, error) { + if url == "" { + return "", fmt.Errorf("URL is required") + } + // Download NZB content + filename, content, err := utils.DownloadFile(url) + if err != nil { + s.logger.Error().Err(err).Str("url", url).Msg("Failed to download NZB from URL") + return "", fmt.Errorf("failed to download NZB from URL: %w", err) + } + + if len(content) == 0 { + s.logger.Warn().Str("url", url).Msg("Downloaded content is empty") + return "", fmt.Errorf("downloaded content is empty") + } + return s.addNZBFile(ctx, content, filename, arr, action, downloadDir) +} + +func (s *SABnzbd) addNZBFile(ctx context.Context, content []byte, filename string, arr *arr.Arr, action, downloadDir string) (string, error) { + if s.usenet == nil { + return "", fmt.Errorf("store not initialized") + } + req := &usenet.ProcessRequest{ + NZBContent: content, + Name: filename, + Arr: arr, + Action: action, + DownloadDir: downloadDir, + } + nzb, err := s.usenet.ProcessNZB(ctx, req) + if err != nil { + return "", fmt.Errorf("failed to process NZB: %w", err) + } + return nzb.ID, nil +} diff --git a/pkg/sabnzbd/routes.go b/pkg/sabnzbd/routes.go new file mode 100644 index 0000000..344c7be --- /dev/null +++ b/pkg/sabnzbd/routes.go @@ -0,0 +1,24 @@ +package sabnzbd + +import ( + "net/http" + + "github.com/go-chi/chi/v5" +) + +func (s *SABnzbd) Routes() http.Handler { + r := chi.NewRouter() + r.Use(s.categoryContext) + r.Use(s.authContext) + + // SABnzbd API endpoints - all under /api with mode parameter + r.Route("/api", func(r chi.Router) { + r.Use(s.modeContext) + + // Queue operations + r.Get("/", s.handleAPI) + r.Post("/", s.handleAPI) + }) + + return r +} diff --git a/pkg/sabnzbd/sabnzbd.go b/pkg/sabnzbd/sabnzbd.go new file mode 100644 index 0000000..4ce697c --- /dev/null +++ b/pkg/sabnzbd/sabnzbd.go @@ -0,0 +1,116 @@ +package sabnzbd + +import ( + "github.com/rs/zerolog" + "github.com/sirrobot01/decypharr/internal/config" + "github.com/sirrobot01/decypharr/internal/logger" + "github.com/sirrobot01/decypharr/pkg/store" + "github.com/sirrobot01/decypharr/pkg/usenet" + "path/filepath" +) + +type SABnzbd struct { + downloadFolder string + config *Config + refreshInterval int + logger zerolog.Logger + usenet usenet.Usenet + defaultCategories []string +} + +func New(usenetClient usenet.Usenet) *SABnzbd { + _cfg := config.Get() + cfg := _cfg.SABnzbd + var defaultCategories []string + for _, cat := range _cfg.SABnzbd.Categories { + if cat != "" { + defaultCategories = append(defaultCategories, cat) + } + } + sb := &SABnzbd{ + downloadFolder: cfg.DownloadFolder, + refreshInterval: cfg.RefreshInterval, + logger: logger.New("sabnzbd"), + usenet: usenetClient, + defaultCategories: defaultCategories, + } + sb.SetConfig(_cfg) + return sb +} + +func (s *SABnzbd) SetConfig(cfg *config.Config) { + sabnzbdConfig := &Config{ + Misc: MiscConfig{ + CompleteDir: s.downloadFolder, + DownloadDir: s.downloadFolder, + AdminDir: s.downloadFolder, + WebPort: cfg.Port, + Language: "en", + RefreshRate: "1", + QueueComplete: "0", + ConfigLock: "0", + Autobrowser: "1", + CheckNewRel: "1", + }, + Categories: s.getCategories(), + } + if cfg.Usenet != nil || len(cfg.Usenet.Providers) == 0 { + for _, provider := range cfg.Usenet.Providers { + if provider.Host == "" || provider.Port == 0 { + continue + } + sabnzbdConfig.Servers = append(sabnzbdConfig.Servers, Server{ + Name: provider.Name, + Host: provider.Host, + Port: provider.Port, + Username: provider.Username, + Password: provider.Password, + Connections: provider.Connections, + SSL: provider.SSL, + }) + } + } + s.config = sabnzbdConfig +} + +func (s *SABnzbd) getCategories() []Category { + _store := store.Get() + arrs := _store.Arr().GetAll() + categories := make([]Category, 0, len(arrs)) + added := map[string]struct{}{} + + for i, a := range arrs { + if _, ok := added[a.Name]; ok { + continue // Skip if category already added + } + categories = append(categories, Category{ + Name: a.Name, + Order: i + 1, + Pp: "3", + Script: "None", + Dir: filepath.Join(s.downloadFolder, a.Name), + Priority: PriorityNormal, + }) + } + + // Add default categories if not already present + for _, defaultCat := range s.defaultCategories { + if _, ok := added[defaultCat]; ok { + continue // Skip if default category already added + } + categories = append(categories, Category{ + Name: defaultCat, + Order: len(categories) + 1, + Pp: "3", + Script: "None", + Dir: filepath.Join(s.downloadFolder, defaultCat), + Priority: PriorityNormal, + }) + added[defaultCat] = struct{}{} + } + + return categories +} + +func (s *SABnzbd) Reset() { +} diff --git a/pkg/sabnzbd/types.go b/pkg/sabnzbd/types.go new file mode 100644 index 0000000..67e0913 --- /dev/null +++ b/pkg/sabnzbd/types.go @@ -0,0 +1,150 @@ +package sabnzbd + +// SABnzbd API response types based on official documentation + +var ( + Version = "4.5.0" +) + +// QueueResponse represents the queue status response +type QueueResponse struct { + Queue Queue `json:"queue"` + Status bool `json:"status"` + Version string `json:"version"` +} + +// Queue represents the download queue +type Queue struct { + Version string `json:"version"` + Slots []QueueSlot `json:"slots"` +} + +// QueueSlot represents a download in the queue +type QueueSlot struct { + Status string `json:"status"` + TimeLeft string `json:"timeleft"` + Mb int64 `json:"mb"` + Filename string `json:"filename"` + Priority string `json:"priority"` + Cat string `json:"cat"` + MBLeft int64 `json:"mbleft"` + Percentage float64 `json:"percentage"` + NzoId string `json:"nzo_id"` + Size int64 `json:"size"` +} + +// HistoryResponse represents the history response +type HistoryResponse struct { + History History `json:"history"` +} + +// History represents the download history +type History struct { + Version string `json:"version"` + Paused bool `json:"paused"` + Slots []HistorySlot `json:"slots"` +} + +// HistorySlot represents a completed download +type HistorySlot struct { + Status string `json:"status"` + Name string `json:"name"` + NZBName string `json:"nzb_name"` + NzoId string `json:"nzo_id"` + Category string `json:"category"` + FailMessage string `json:"fail_message"` + Bytes int64 `json:"bytes"` + Storage string `json:"storage"` +} + +// StageLog represents processing stages +type StageLog struct { + Name string `json:"name"` + Actions []string `json:"actions"` +} + +// VersionResponse represents version information +type VersionResponse struct { + Version string `json:"version"` +} + +// StatusResponse represents general status +type StatusResponse struct { + Status bool `json:"status"` + Error string `json:"error,omitempty"` +} + +// FullStatusResponse represents the full status response with queue and history +type FullStatusResponse struct { + Queue Queue `json:"queue"` + History History `json:"history"` + Status bool `json:"status"` + Version string `json:"version"` +} + +// AddNZBRequest represents the request to add an NZB +type AddNZBRequest struct { + Name string `json:"name"` + Cat string `json:"cat"` + Script string `json:"script"` + Priority string `json:"priority"` + PP string `json:"pp"` + Password string `json:"password"` + NZBData []byte `json:"nzb_data"` + URL string `json:"url"` +} + +// AddNZBResponse represents the response when adding an NZB +type AddNZBResponse struct { + Status bool `json:"status"` + NzoIds []string `json:"nzo_ids"` + Error string `json:"error,omitempty"` +} + +// API Mode constants +const ( + ModeQueue = "queue" + ModeHistory = "history" + ModeConfig = "config" + ModeGetConfig = "get_config" + ModeAddURL = "addurl" + ModeAddFile = "addfile" + ModeVersion = "version" + ModePause = "pause" + ModeResume = "resume" + ModeDelete = "delete" + ModeShutdown = "shutdown" + ModeRestart = "restart" + ModeGetCats = "get_cats" + ModeGetScripts = "get_scripts" + ModeGetFiles = "get_files" + ModeRetry = "retry" + ModeStatus = "status" + ModeFullStatus = "fullstatus" +) + +// Status constants +const ( + StatusQueued = "Queued" + StatusPaused = "Paused" + StatusDownloading = "downloading" + StatusProcessing = "Processing" + StatusCompleted = "Completed" + StatusFailed = "Failed" + StatusGrabbing = "Grabbing" + StatusPropagating = "Propagating" + StatusVerifying = "Verifying" + StatusRepairing = "Repairing" + StatusExtracting = "Extracting" + StatusMoving = "Moving" + StatusRunning = "Running" +) + +// Priority constants +const ( + PriorityForced = "2" + PriorityHigh = "1" + PriorityNormal = "0" + PriorityLow = "-1" + PriorityStop = "-2" +) diff --git a/pkg/server/debug.go b/pkg/server/debug.go index cbfd705..77635b9 100644 --- a/pkg/server/debug.go +++ b/pkg/server/debug.go @@ -3,6 +3,7 @@ package server import ( "fmt" "github.com/go-chi/chi/v5" + "github.com/sirrobot01/decypharr/internal/nntp" "github.com/sirrobot01/decypharr/internal/request" debridTypes "github.com/sirrobot01/decypharr/pkg/debrid/types" "github.com/sirrobot01/decypharr/pkg/store" @@ -118,5 +119,23 @@ func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) { profiles = append(profiles, profile) } stats["debrids"] = profiles + + if s.usenet != nil { + if client := s.usenet.Client(); client != nil { + usenetsData := make([]map[string]interface{}, 0) + client.Pools().Range(func(key string, value *nntp.Pool) bool { + if value != nil { + providerData := make(map[string]interface{}) + providerData["name"] = key + providerData["active_connections"] = value.ActiveConnections() + providerData["total_connections"] = value.ConnectionCount() + usenetsData = append(usenetsData, providerData) + } + return true + }) + stats["usenet"] = usenetsData + } + } + request.JSONResponse(w, stats, http.StatusOK) } diff --git a/pkg/server/server.go b/pkg/server/server.go index 9d22e6b..b44cd9b 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -9,6 +9,7 @@ import ( "github.com/rs/zerolog" "github.com/sirrobot01/decypharr/internal/config" "github.com/sirrobot01/decypharr/internal/logger" + "github.com/sirrobot01/decypharr/pkg/usenet" "io" "net/http" "os" @@ -17,9 +18,10 @@ import ( type Server struct { router *chi.Mux logger zerolog.Logger + usenet usenet.Usenet } -func New(handlers map[string]http.Handler) *Server { +func New(usenet usenet.Usenet, handlers map[string]http.Handler) *Server { l := logger.New("http") r := chi.NewRouter() r.Use(middleware.Recoverer) @@ -28,6 +30,7 @@ func New(handlers map[string]http.Handler) *Server { s := &Server{ logger: l, + usenet: usenet, } r.Route(cfg.URLBase, func(r chi.Router) { diff --git a/pkg/store/store.go b/pkg/store/store.go index 28dd752..da401bb 100644 --- a/pkg/store/store.go +++ b/pkg/store/store.go @@ -1,7 +1,6 @@ package store import ( - "cmp" "context" "github.com/rs/zerolog" "github.com/sirrobot01/decypharr/internal/config" @@ -37,18 +36,21 @@ func Get() *Store { arrs := arr.NewStorage() deb := debrid.NewStorage() cfg := config.Get() - qbitCfg := cfg.QBitTorrent - instance = &Store{ repair: repair.New(arrs, deb), arr: arrs, debrid: deb, torrents: newTorrentStorage(cfg.TorrentsFile()), logger: logger.Default(), // Use default logger [decypharr] - refreshInterval: time.Duration(cmp.Or(qbitCfg.RefreshInterval, 10)) * time.Minute, - skipPreCache: qbitCfg.SkipPreCache, - downloadSemaphore: make(chan struct{}, cmp.Or(qbitCfg.MaxDownloads, 5)), importsQueue: NewImportQueue(context.Background(), 1000), + refreshInterval: 10 * time.Minute, // Default refresh interval + skipPreCache: false, // Default skip pre-cache + downloadSemaphore: make(chan struct{}, 5), // Default max concurrent downloads + } + if cfg.QBitTorrent != nil { + instance.refreshInterval = time.Duration(cfg.QBitTorrent.RefreshInterval) * time.Minute + instance.skipPreCache = cfg.QBitTorrent.SkipPreCache + instance.downloadSemaphore = make(chan struct{}, cfg.QBitTorrent.MaxDownloads) } if cfg.RemoveStalledAfter != "" { removeStalledAfter, err := time.ParseDuration(cfg.RemoveStalledAfter) diff --git a/pkg/usenet/cache.go b/pkg/usenet/cache.go new file mode 100644 index 0000000..14c2a12 --- /dev/null +++ b/pkg/usenet/cache.go @@ -0,0 +1,141 @@ +package usenet + +import ( + "github.com/chrisfarms/yenc" + "github.com/puzpuzpuz/xsync/v4" + "github.com/rs/zerolog" + "sync/atomic" + "time" +) + +// SegmentCache provides intelligent caching for NNTP segments +type SegmentCache struct { + cache *xsync.Map[string, *CachedSegment] + logger zerolog.Logger + maxSize int64 + currentSize atomic.Int64 +} + +// CachedSegment represents a cached segment with metadata +type CachedSegment struct { + MessageID string `json:"message_id"` + Data []byte `json:"data"` + DecodedSize int64 `json:"decoded_size"` // Actual size after yEnc decoding + DeclaredSize int64 `json:"declared_size"` // Size declared in NZB + CachedAt time.Time `json:"cached_at"` + AccessCount int64 `json:"access_count"` + LastAccess time.Time `json:"last_access"` + FileBegin int64 `json:"file_begin"` // Start byte offset in the file + FileEnd int64 `json:"file_end"` // End byte offset in the file +} + +// NewSegmentCache creates a new segment cache +func NewSegmentCache(logger zerolog.Logger) *SegmentCache { + sc := &SegmentCache{ + cache: xsync.NewMap[string, *CachedSegment](), + logger: logger.With().Str("component", "segment_cache").Logger(), + maxSize: 50 * 1024 * 1024, // Default max size 100MB + } + + return sc +} + +// Get retrieves a segment from cache +func (sc *SegmentCache) Get(messageID string) (*CachedSegment, bool) { + segment, found := sc.cache.Load(messageID) + if !found { + return nil, false + } + + segment.AccessCount++ + segment.LastAccess = time.Now() + + return segment, true +} + +// Put stores a segment in cache with intelligent size management +func (sc *SegmentCache) Put(messageID string, data *yenc.Part, declaredSize int64) { + dataSize := data.Size + + currentSize := sc.currentSize.Load() + // Check if we need to make room + wouldExceed := (currentSize + dataSize) > sc.maxSize + + if wouldExceed { + sc.evictLRU(dataSize) + } + + segment := &CachedSegment{ + MessageID: messageID, + Data: make([]byte, data.Size), + DecodedSize: dataSize, + DeclaredSize: declaredSize, + CachedAt: time.Now(), + AccessCount: 1, + LastAccess: time.Now(), + } + + copy(segment.Data, data.Body) + + sc.cache.Store(messageID, segment) + + sc.currentSize.Add(dataSize) +} + +// evictLRU evicts least recently used segments to make room +func (sc *SegmentCache) evictLRU(neededSpace int64) { + if neededSpace <= 0 { + return // No need to evict if no space is needed + } + if sc.cache.Size() == 0 { + return // Nothing to evict + } + + // Create a sorted list of segments by last access time + type segmentInfo struct { + key string + segment *CachedSegment + lastAccess time.Time + } + + segments := make([]segmentInfo, 0, sc.cache.Size()) + sc.cache.Range(func(key string, value *CachedSegment) bool { + segments = append(segments, segmentInfo{ + key: key, + segment: value, + lastAccess: value.LastAccess, + }) + return true // continue iteration + }) + + // Sort by last access time (oldest first) + for i := 0; i < len(segments)-1; i++ { + for j := i + 1; j < len(segments); j++ { + if segments[i].lastAccess.After(segments[j].lastAccess) { + segments[i], segments[j] = segments[j], segments[i] + } + } + } + + // Evict segments until we have enough space + freedSpace := int64(0) + for _, seg := range segments { + if freedSpace >= neededSpace { + break + } + + sc.cache.Delete(seg.key) + freedSpace += int64(len(seg.segment.Data)) + } +} + +// Clear removes all cached segments +func (sc *SegmentCache) Clear() { + sc.cache.Clear() + sc.currentSize.Store(0) +} + +// Delete removes a specific segment from cache +func (sc *SegmentCache) Delete(messageID string) { + sc.cache.Delete(messageID) +} diff --git a/pkg/usenet/downloader.go b/pkg/usenet/downloader.go new file mode 100644 index 0000000..3a95f97 --- /dev/null +++ b/pkg/usenet/downloader.go @@ -0,0 +1,281 @@ +package usenet + +import ( + "context" + "errors" + "fmt" + "github.com/rs/zerolog" + "github.com/sirrobot01/decypharr/internal/config" + "github.com/sirrobot01/decypharr/internal/logger" + "github.com/sirrobot01/decypharr/internal/nntp" + "github.com/sirrobot01/decypharr/internal/utils" + "golang.org/x/sync/errgroup" + "os" + "path/filepath" + "time" +) + +// DownloadWorker manages concurrent NZB downloads +type DownloadWorker struct { + client *nntp.Client + processor *Processor + logger zerolog.Logger + skipPreCache bool // Skip pre-caching for faster processing + mountFolder string // Folder where downloads are mounted +} + +// DownloadJob represents a download job for an NZB +type DownloadJob struct { + NZB *NZB + Action string + Priority int + Callback func(*NZB, error) + DownloadDir string +} + +// NewDownloadWorker creates a new download worker +func NewDownloadWorker(config *config.Usenet, client *nntp.Client, processor *Processor) *DownloadWorker { + + dw := &DownloadWorker{ + processor: processor, + client: client, + logger: logger.New("usenet-download-worker"), + skipPreCache: config.SkipPreCache, + mountFolder: config.MountFolder, + } + return dw +} + +func (dw *DownloadWorker) CheckAvailability(ctx context.Context, job *DownloadJob) error { + dw.logger.Debug(). + Str("nzb_id", job.NZB.ID). + Msg("Checking NZB availability") + + // Grab first file to extract message IDs + firstFile := job.NZB.Files[0] + if len(firstFile.Segments) == 0 { + return fmt.Errorf("no segments found in first file of NZB") + } + + segments := firstFile.Segments + + // Smart sampling: check first, last, and some middle segments + samplesToCheck := dw.getSampleSegments(segments) + + // Create error group for concurrent checking + g, gCtx := errgroup.WithContext(ctx) + + // Limit concurrent goroutines to prevent overwhelming the NNTP server + maxConcurrency := len(samplesToCheck) + if maxConns := dw.client.MinimumMaxConns(); maxConns < maxConcurrency { + maxConcurrency = maxConns + } + g.SetLimit(maxConcurrency) + + // Check each segment concurrently + for i, segment := range samplesToCheck { + segment := segment // capture loop variable + segmentNum := i + 1 + + g.Go(func() error { + select { + case <-gCtx.Done(): + return gCtx.Err() // Return if context is canceled + default: + } + conn, cleanup, err := dw.client.GetConnection(gCtx) + if err != nil { + return fmt.Errorf("failed to get NNTP connection: %w", err) + } + defer cleanup() // Ensure connection is returned to the pool + // Check segment availability + seg, err := conn.GetSegment(segment.MessageID, segmentNum) + + if err != nil { + return fmt.Errorf("failed to check segment %d availability: %w", segmentNum, err) + } + if seg == nil { + return fmt.Errorf("segment %d not found", segmentNum) + } + + return nil + }) + } + + // Wait for all checks to complete + if err := g.Wait(); err != nil { + return fmt.Errorf("availability check failed: %w", err) + } + + // Update storage with availability info + if err := dw.processor.store.Update(job.NZB); err != nil { + dw.logger.Warn().Err(err).Msg("Failed to update NZB with availability info") + } + + return nil +} + +func (dw *DownloadWorker) Process(ctx context.Context, job *DownloadJob) error { + var ( + finalPath string + err error + ) + + defer func(err error) { + if job.Callback != nil { + job.Callback(job.NZB, err) + } + }(err) + + switch job.Action { + case "download": + finalPath, err = dw.downloadNZB(ctx, job) + case "symlink": + finalPath, err = dw.symlinkNZB(ctx, job) + case "none": + return nil + default: + // Use symlink as default action + finalPath, err = dw.symlinkNZB(ctx, job) + } + if err != nil { + return err + } + + if finalPath == "" { + err = fmt.Errorf("final path is empty after processing job: %s", job.Action) + return err + } + + // Use atomic transition to completed state + return dw.processor.store.MarkAsCompleted(job.NZB.ID, finalPath) +} + +// downloadNZB downloads an NZB to the specified directory +func (dw *DownloadWorker) downloadNZB(ctx context.Context, job *DownloadJob) (string, error) { + dw.logger.Info(). + Str("nzb_id", job.NZB.ID). + Str("download_dir", job.DownloadDir). + Msg("Starting NZB download") + + // TODO: implement download logic + + return job.DownloadDir, nil +} + +// getSampleMessageIDs returns a smart sample of message IDs to check +func (dw *DownloadWorker) getSampleSegments(segments []NZBSegment) []NZBSegment { + totalSegments := len(segments) + + // For small NZBs, check all segments + if totalSegments <= 2 { + return segments + } + + var samplesToCheck []NZBSegment + // Always check the first and last segments + samplesToCheck = append(samplesToCheck, segments[0]) // First segment + samplesToCheck = append(samplesToCheck, segments[totalSegments-1]) // Last segment + return samplesToCheck +} + +func (dw *DownloadWorker) symlinkNZB(ctx context.Context, job *DownloadJob) (string, error) { + dw.logger.Info(). + Str("nzb_id", job.NZB.ID). + Str("symlink_dir", job.DownloadDir). + Msg("Creating symlinks for NZB") + if job.NZB == nil { + return "", fmt.Errorf("NZB is nil") + } + + mountFolder := filepath.Join(dw.mountFolder, job.NZB.Name) // e.g. /mnt/rclone/usenet/__all__/TV_SHOW + if mountFolder == "" { + return "", fmt.Errorf("mount folder is empty") + } + symlinkPath := filepath.Join(job.DownloadDir, job.NZB.Name) // e.g. /mnt/symlinks/usenet/sonarr/TV_SHOW + if err := os.MkdirAll(symlinkPath, 0755); err != nil { + return "", fmt.Errorf("failed to create symlink directory: %w", err) + } + + return dw.createSymlinksWebdav(job.NZB, mountFolder, symlinkPath) +} + +func (dw *DownloadWorker) createSymlinksWebdav(nzb *NZB, mountPath, symlinkPath string) (string, error) { + files := nzb.GetFiles() + remainingFiles := make(map[string]NZBFile) + for _, file := range files { + remainingFiles[file.Name] = file + } + + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + timeout := time.After(30 * time.Minute) + filePaths := make([]string, 0, len(files)) + maxLogCount := 10 // Limit the number of log messages to avoid flooding + + for len(remainingFiles) > 0 { + select { + case <-ticker.C: + entries, err := os.ReadDir(mountPath) + if err != nil { + if maxLogCount > 0 && !errors.Is(err, os.ErrNotExist) { + // Only log if it's not a "not found" error + // This is due to the fact the mount path may not exist YET + dw.logger.Warn(). + Err(err). + Str("mount_path", mountPath). + Msg("Failed to read directory, retrying") + maxLogCount-- + } + continue + } + + // Check which files exist in this batch + for _, entry := range entries { + filename := entry.Name() + dw.logger.Info(). + Str("filename", filename). + Msg("Checking file existence in mount path") + + if file, exists := remainingFiles[filename]; exists { + fullFilePath := filepath.Join(mountPath, filename) + fileSymlinkPath := filepath.Join(symlinkPath, file.Name) + + if err := os.Symlink(fullFilePath, fileSymlinkPath); err != nil && !os.IsExist(err) { + dw.logger.Debug().Msgf("Failed to create symlink: %s: %v", fileSymlinkPath, err) + } else { + filePaths = append(filePaths, fileSymlinkPath) + delete(remainingFiles, filename) + dw.logger.Info().Msgf("File is ready: %s", file.Name) + } + } + } + + case <-timeout: + dw.logger.Warn().Msgf("Timeout waiting for files, %d files still pending", len(remainingFiles)) + return symlinkPath, fmt.Errorf("timeout waiting for files") + } + } + + if dw.skipPreCache { + return symlinkPath, nil + } + + go func() { + defer func() { + if r := recover(); r != nil { + dw.logger.Error(). + Interface("panic", r). + Str("nzbName", nzb.Name). + Msg("Recovered from panic in pre-cache goroutine") + } + }() + if err := utils.PreCacheFile(filePaths); err != nil { + dw.logger.Error().Msgf("Failed to pre-cache file: %s", err) + } else { + dw.logger.Debug().Msgf("Pre-cached %d files", len(filePaths)) + } + }() // Pre-cache the files in the background + // Pre-cache the first 256KB and 1MB of the file + return symlinkPath, nil +} diff --git a/pkg/usenet/errors.go b/pkg/usenet/errors.go new file mode 100644 index 0000000..2f5ad94 --- /dev/null +++ b/pkg/usenet/errors.go @@ -0,0 +1,353 @@ +package usenet + +import ( + "errors" + "fmt" + "net" + "strings" + "sync" + "time" +) + +var ( + ErrConnectionFailed = errors.New("failed to connect to NNTP server") + ErrServerUnavailable = errors.New("NNTP server unavailable") + ErrRateLimitExceeded = errors.New("rate limit exceeded") + ErrDownloadTimeout = errors.New("download timeout") +) + +// ErrInvalidNZBf creates a formatted error for NZB validation failures +func ErrInvalidNZBf(format string, args ...interface{}) error { + return fmt.Errorf("invalid NZB: "+format, args...) +} + +// Error represents a structured usenet error +type Error struct { + Code string + Message string + Err error + ServerAddr string + Timestamp time.Time + Retryable bool +} + +func (e *Error) Error() string { + if e.ServerAddr != "" { + return fmt.Sprintf("usenet error [%s] on %s: %s", e.Code, e.ServerAddr, e.Message) + } + return fmt.Sprintf("usenet error [%s]: %s", e.Code, e.Message) +} + +func (e *Error) Unwrap() error { + return e.Err +} + +func (e *Error) Is(target error) bool { + if target == nil { + return false + } + return e.Err != nil && errors.Is(e.Err, target) +} + +// NewUsenetError creates a new UsenetError +func NewUsenetError(code, message string, err error) *Error { + return &Error{ + Code: code, + Message: message, + Err: err, + Timestamp: time.Now(), + Retryable: isRetryableError(err), + } +} + +// NewServerError creates a new UsenetError with server address +func NewServerError(code, message, serverAddr string, err error) *Error { + return &Error{ + Code: code, + Message: message, + Err: err, + ServerAddr: serverAddr, + Timestamp: time.Now(), + Retryable: isRetryableError(err), + } +} + +// isRetryableError determines if an error is retryable +func isRetryableError(err error) bool { + if err == nil { + return false + } + + // Network errors are generally retryable + var netErr net.Error + if errors.As(err, &netErr) { + return netErr.Timeout() + } + + // DNS errors are retryable + var dnsErr *net.DNSError + if errors.As(err, &dnsErr) { + return dnsErr.Temporary() + } + + // Connection refused is retryable + if errors.Is(err, net.ErrClosed) { + return true + } + + // Check error message for retryable conditions + errMsg := strings.ToLower(err.Error()) + retryableMessages := []string{ + "connection refused", + "connection reset", + "connection timed out", + "network is unreachable", + "host is unreachable", + "temporary failure", + "service unavailable", + "server overloaded", + "rate limit", + "too many connections", + } + + for _, msg := range retryableMessages { + if strings.Contains(errMsg, msg) { + return true + } + } + + return false +} + +// RetryConfig defines retry behavior +type RetryConfig struct { + MaxRetries int + InitialDelay time.Duration + MaxDelay time.Duration + BackoffFactor float64 + RetryableErrors []error +} + +// DefaultRetryConfig returns a default retry configuration +func DefaultRetryConfig() *RetryConfig { + return &RetryConfig{ + MaxRetries: 3, + InitialDelay: 1 * time.Second, + MaxDelay: 30 * time.Second, + BackoffFactor: 2.0, + RetryableErrors: []error{ + ErrConnectionFailed, + ErrServerUnavailable, + ErrRateLimitExceeded, + ErrDownloadTimeout, + }, + } +} + +// ShouldRetry determines if an error should be retried +func (rc *RetryConfig) ShouldRetry(err error, attempt int) bool { + if attempt >= rc.MaxRetries { + return false + } + + // Check if it's a retryable UsenetError + var usenetErr *Error + if errors.As(err, &usenetErr) { + return usenetErr.Retryable + } + + // Check if it's in the list of retryable errors + for _, retryableErr := range rc.RetryableErrors { + if errors.Is(err, retryableErr) { + return true + } + } + + return isRetryableError(err) +} + +// GetDelay calculates the delay for the next retry +func (rc *RetryConfig) GetDelay(attempt int) time.Duration { + if attempt <= 0 { + return rc.InitialDelay + } + + delay := time.Duration(float64(rc.InitialDelay) * float64(attempt) * rc.BackoffFactor) + if delay > rc.MaxDelay { + delay = rc.MaxDelay + } + + return delay +} + +// RetryWithBackoff retries a function with exponential backoff +func RetryWithBackoff(config *RetryConfig, operation func() error) error { + var lastErr error + + for attempt := 0; attempt <= config.MaxRetries; attempt++ { + if attempt > 0 { + delay := config.GetDelay(attempt) + time.Sleep(delay) + } + + err := operation() + if err == nil { + return nil + } + + lastErr = err + + if !config.ShouldRetry(err, attempt) { + break + } + } + + return lastErr +} + +// CircuitBreakerConfig defines circuit breaker behavior +type CircuitBreakerConfig struct { + MaxFailures int + ResetTimeout time.Duration + CheckInterval time.Duration + FailureCallback func(error) +} + +// CircuitBreaker implements a circuit breaker pattern for NNTP connections +type CircuitBreaker struct { + config *CircuitBreakerConfig + failures int + lastFailure time.Time + state string // "closed", "open", "half-open" + mu *sync.RWMutex +} + +// NewCircuitBreaker creates a new circuit breaker +func NewCircuitBreaker(config *CircuitBreakerConfig) *CircuitBreaker { + if config == nil { + config = &CircuitBreakerConfig{ + MaxFailures: 5, + ResetTimeout: 60 * time.Second, + CheckInterval: 10 * time.Second, + } + } + + return &CircuitBreaker{ + config: config, + state: "closed", + mu: &sync.RWMutex{}, + } +} + +// Execute executes an operation through the circuit breaker +func (cb *CircuitBreaker) Execute(operation func() error) error { + cb.mu.RLock() + state := cb.state + failures := cb.failures + lastFailure := cb.lastFailure + cb.mu.RUnlock() + + // Check if we should attempt reset + if state == "open" && time.Since(lastFailure) > cb.config.ResetTimeout { + cb.mu.Lock() + cb.state = "half-open" + cb.mu.Unlock() + state = "half-open" + } + + if state == "open" { + return NewUsenetError("circuit_breaker_open", + fmt.Sprintf("circuit breaker is open (failures: %d)", failures), + ErrServerUnavailable) + } + + err := operation() + + cb.mu.Lock() + defer cb.mu.Unlock() + + if err != nil { + cb.failures++ + cb.lastFailure = time.Now() + + if cb.failures >= cb.config.MaxFailures { + cb.state = "open" + } + + if cb.config.FailureCallback != nil { + go func() { + cb.config.FailureCallback(err) + }() + } + + return err + } + + // Success - reset if we were in half-open state + if cb.state == "half-open" { + cb.state = "closed" + cb.failures = 0 + } + + return nil +} + +// GetState returns the current circuit breaker state +func (cb *CircuitBreaker) GetState() string { + cb.mu.RLock() + defer cb.mu.RUnlock() + return cb.state +} + +// Reset manually resets the circuit breaker +func (cb *CircuitBreaker) Reset() { + cb.mu.Lock() + defer cb.mu.Unlock() + cb.state = "closed" + cb.failures = 0 +} + +// ValidationError represents validation errors +type ValidationError struct { + Field string + Value interface{} + Message string +} + +func (e *ValidationError) Error() string { + return fmt.Sprintf("validation error for field '%s': %s", e.Field, e.Message) +} + +// ValidateNZBContent validates NZB content +func ValidateNZBContent(content []byte) error { + if len(content) == 0 { + return &ValidationError{ + Field: "content", + Value: len(content), + Message: "NZB content cannot be empty", + } + } + + if len(content) > 100*1024*1024 { // 100MB limit + return &ValidationError{ + Field: "content", + Value: len(content), + Message: "NZB content exceeds maximum size limit (100MB)", + } + } + + contentStr := string(content) + if !strings.Contains(contentStr, "= end+1 { + break + } + } + + return segmentRanges +} + +func (nf *NZBFile) ConvertToSegmentRanges(segments []NZBSegment) []SegmentRange { + var segmentRanges []SegmentRange + var cumulativeSize int64 + + for i, segment := range segments { + // Use the file's segment size (uniform) + segmentSize := nf.SegmentSize + + // Handle last segment which might be smaller + if i == len(segments)-1 { + segmentSize = segment.Bytes // Last segment uses actual size + } + + cumulativeSize += segmentSize + + segmentRange := SegmentRange{ + Segment: segment, + ByteStart: 0, // Always starts at 0 within the segment + ByteEnd: segmentSize - 1, // Ends at segment size - 1 + TotalStart: cumulativeSize - segmentSize, // Absolute start position + TotalEnd: cumulativeSize - 1, // Absolute end position + } + + segmentRanges = append(segmentRanges, segmentRange) + } + + return segmentRanges +} + +func (nf *NZBFile) GetCacheKey() string { + return fmt.Sprintf("rar_%s_%d", nf.Name, nf.Size) +} + +func (nzb *NZB) GetFiles() []NZBFile { + files := make([]NZBFile, 0, len(nzb.Files)) + for _, file := range nzb.Files { + if !file.IsDeleted { + files = append(files, file) + } + } + return files[:len(files):len(files)] // Return a slice to avoid aliasing +} + +// ValidateNZB performs basic validation on NZB content +func ValidateNZB(content []byte) error { + if len(content) == 0 { + return fmt.Errorf("empty NZB content") + } + + // Check for basic XML structure + if !strings.Contains(string(content), " tag") + } + + if !strings.Contains(string(content), " 0.95 { + fileDataOffset := tracker.Position() + + p.logger.Info(). + Str("file", header.Name). + Int64("accurate_offset", fileDataOffset). + Float64("compression_ratio", compressionRatio). + Msg("Found accurate store RAR offset using position tracking") + + return &ExtractedFileInfo{ + FileName: header.Name, + FileSize: header.UnPackedSize, + SegmentSize: fileInfo.ChunkSize, + EstimatedStartOffset: fileDataOffset, + } + } + break + } + + // Skip file content - this advances the tracker position + io.Copy(io.Discard, rarReader) + } + + return nil +} + +func (p *NZBParser) determineExtension(group *FileGroup) string { + // Try to determine extension from filenames + for _, file := range group.Files { + ext := filepath.Ext(file.Filename) + if ext != "" { + return ext + } + } + return ".mkv" // Default +} + +func (p *NZBParser) getGroupsList(groups map[string]struct{}) []string { + result := make([]string, 0, len(groups)) + for g := range groups { + result = append(result, g) + } + return result +} + +// Download RAR headers from segments +func (p *NZBParser) downloadRarHeaders(ctx context.Context, segments []nzbparser.NzbSegment) ([]byte, error) { + var headerBuffer bytes.Buffer + + for _, segment := range segments { + conn, cleanup, err := p.client.GetConnection(ctx) + if err != nil { + continue + } + + data, err := conn.GetBody(segment.Id) + cleanup() + + if err != nil { + if !nntp.IsRetryableError(err) { + return nil, err + } + continue + } + + if len(data) == 0 { + continue + } + + // yEnc decode + part, err := nntp.DecodeYenc(bytes.NewReader(data)) + if err != nil || part == nil || len(part.Body) == 0 { + p.logger.Warn().Err(err).Str("segment_id", segment.Id).Msg("Failed to decode RAR header segment") + continue + } + + headerBuffer.Write(part.Body) + + // Stop if we have enough data (typically first segment is enough for headers) + if headerBuffer.Len() > 32768 { // 32KB should be plenty for RAR headers + break + } + } + + if headerBuffer.Len() == 0 { + return nil, fmt.Errorf("no valid header data downloaded") + } + + return headerBuffer.Bytes(), nil +} + +func (p *NZBParser) detectFileTypeByContent(ctx context.Context, file nzbparser.NzbFile) (FileType, string) { + if len(file.Segments) == 0 { + return FileTypeUnknown, "" + } + + // Download first segment to check file signature + firstSegment := file.Segments[0] + data, err := p.downloadFirstSegment(ctx, firstSegment) + if err != nil { + p.logger.Warn().Err(err).Msg("Failed to download first segment for content detection") + return FileTypeUnknown, "" + } + + if data.Name != "" { + fileType := p.detectFileType(data.Name) + if fileType != FileTypeUnknown { + return fileType, data.Name + } + } + + return p.detectFileTypeFromContent(data.Body), data.Name +} + +func (p *NZBParser) detectFileTypeFromContent(data []byte) FileType { + if len(data) == 0 { + return FileTypeUnknown + } + + // Check for RAR signatures (both RAR 4.x and 5.x) + if len(data) >= 7 { + // RAR 4.x signature + if bytes.Equal(data[:7], []byte("Rar!\x1A\x07\x00")) { + return FileTypeRar + } + } + if len(data) >= 8 { + // RAR 5.x signature + if bytes.Equal(data[:8], []byte("Rar!\x1A\x07\x01\x00")) { + return FileTypeRar + } + } + + // Check for ZIP signature + if len(data) >= 4 && bytes.Equal(data[:4], []byte{0x50, 0x4B, 0x03, 0x04}) { + return FileTypeArchive + } + + // Check for 7z signature + if len(data) >= 6 && bytes.Equal(data[:6], []byte{0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C}) { + return FileTypeArchive + } + + // Check for common media file signatures + if len(data) >= 4 { + // Matroska (MKV/WebM) + if bytes.Equal(data[:4], []byte{0x1A, 0x45, 0xDF, 0xA3}) { + return FileTypeMedia + } + + // MP4/MOV (check for 'ftyp' at offset 4) + if len(data) >= 8 && bytes.Equal(data[4:8], []byte("ftyp")) { + return FileTypeMedia + } + + // AVI + if len(data) >= 12 && bytes.Equal(data[:4], []byte("RIFF")) && + bytes.Equal(data[8:12], []byte("AVI ")) { + return FileTypeMedia + } + } + + // MPEG checks need more specific patterns + if len(data) >= 4 { + // MPEG-1/2 Program Stream + if bytes.Equal(data[:4], []byte{0x00, 0x00, 0x01, 0xBA}) { + return FileTypeMedia + } + + // MPEG-1/2 Video Stream + if bytes.Equal(data[:4], []byte{0x00, 0x00, 0x01, 0xB3}) { + return FileTypeMedia + } + } + + // Check for Transport Stream (TS files) + if len(data) >= 1 && data[0] == 0x47 { + // Additional validation for TS files + if len(data) >= 188 && data[188] == 0x47 { + return FileTypeMedia + } + } + + return FileTypeUnknown +} + +func (p *NZBParser) downloadFirstSegment(ctx context.Context, segment nzbparser.NzbSegment) (*yenc.Part, error) { + conn, cleanup, err := p.client.GetConnection(ctx) + if err != nil { + return nil, err + } + defer cleanup() + + data, err := conn.GetBody(segment.Id) + if err != nil { + return nil, err + } + + // yEnc decode + part, err := nntp.DecodeYenc(bytes.NewReader(data)) + if err != nil || part == nil { + return nil, fmt.Errorf("failed to decode segment") + } + + // Return both the filename and decoded data + return part, nil +} + +// Calculate total archive size from all RAR parts in the group +func (p *NZBParser) calculateTotalArchiveSize(group *FileGroup) int64 { + var total int64 + for _, file := range group.Files { + for _, segment := range file.Segments { + total += int64(segment.Bytes) + } + } + return total +} + +func determineNZBName(filename string, meta map[string]string) string { + // Prefer filename if it exists + if filename != "" { + filename = strings.Replace(filename, filepath.Ext(filename), "", 1) + } else { + if name := meta["name"]; name != "" { + filename = name + } else if title := meta["title"]; title != "" { + filename = title + } + } + return utils.RemoveInvalidChars(filename) +} + +func generateID(nzb *NZB) string { + h := sha256.New() + h.Write([]byte(nzb.Name)) + h.Write([]byte(fmt.Sprintf("%d", nzb.TotalSize))) + h.Write([]byte(nzb.Category)) + h.Write([]byte(nzb.Password)) + return hex.EncodeToString(h.Sum(nil))[:16] +} diff --git a/pkg/usenet/processor.go b/pkg/usenet/processor.go new file mode 100644 index 0000000..2c76573 --- /dev/null +++ b/pkg/usenet/processor.go @@ -0,0 +1,145 @@ +package usenet + +import ( + "context" + "fmt" + "github.com/rs/zerolog" + "github.com/sirrobot01/decypharr/internal/config" + "github.com/sirrobot01/decypharr/internal/nntp" + "github.com/sirrobot01/decypharr/pkg/arr" + "path/filepath" + "time" +) + +// Processor handles NZB processing and download orchestration +type Processor struct { + store Store + parser *NZBParser + downloadWorker *DownloadWorker + logger zerolog.Logger + client *nntp.Client +} + +// ProcessRequest represents a request to process an NZB +type ProcessRequest struct { + NZBContent []byte + Name string + Arr *arr.Arr + Action string // "download", "symlink", "none" + DownloadDir string +} + +// NewProcessor creates a new usenet processor +func NewProcessor(config *config.Usenet, logger zerolog.Logger, store Store, client *nntp.Client) (*Processor, error) { + processor := &Processor{ + store: store, + logger: logger.With().Str("component", "usenet-processor").Logger(), + client: client, + } + + // Initialize download worker + processor.downloadWorker = NewDownloadWorker(config, client, processor) + processor.parser = NewNZBParser(client, nil, processor.logger) + + return processor, nil +} + +// Process processes an NZB for download/streaming +func (p *Processor) Process(ctx context.Context, req *ProcessRequest) (*NZB, error) { + if len(req.NZBContent) == 0 { + return nil, fmt.Errorf("NZB content is empty") + } + + // Validate NZB content + if err := ValidateNZB(req.NZBContent); err != nil { + return nil, fmt.Errorf("invalid NZB content: %w", err) + } + nzb, err := p.process(ctx, req) + if err != nil { + p.logger.Error(). + Err(err). + Msg("Failed to process NZB content") + return nil, fmt.Errorf("failed to process NZB content: %w", err) + } + return nzb, nil +} + +func (p *Processor) process(ctx context.Context, req *ProcessRequest) (*NZB, error) { + nzb, err := p.parser.Parse(ctx, req.Name, req.Arr.Name, req.NZBContent) + if err != nil { + p.logger.Error(). + Err(err). + Msg("Failed to parse NZB content") + return nil, fmt.Errorf("failed to parse NZB content: %w", err) + } + if nzb == nil { + p.logger.Error(). + Msg("Parsed NZB is nil") + return nil, fmt.Errorf("parsed NZB is nil") + } + p.logger.Info(). + Str("nzb_id", nzb.ID). + Msg("Successfully parsed NZB content") + + if existing := p.store.Get(nzb.ID); existing != nil { + p.logger.Info().Str("nzb_id", nzb.ID).Msg("NZB already exists") + return existing, nil + } + + p.logger.Info(). + Str("nzb_id", nzb.ID). + Msg("Creating new NZB download job") + + downloadDir := req.DownloadDir + if req.Arr != nil { + downloadDir = filepath.Join(downloadDir, req.Arr.Name) + } + + job := &DownloadJob{ + NZB: nzb, + Action: req.Action, + DownloadDir: downloadDir, + Callback: func(completedNZB *NZB, err error) { + if err != nil { + p.logger.Error(). + Err(err). + Str("nzb_id", completedNZB.ID). + Msg("Download job failed") + return + } + p.logger.Info(). + Str("nzb_id", completedNZB.ID). + Msg("Download job completed successfully") + }, + } + // Check availability before submitting the job + //if err := p.downloadWorker.CheckAvailability(ctx, job); err != nil { + // p.logger.Error(). + // Err(err). + // Str("nzb_id", nzb.ID). + // Msg("NZB availability check failed") + // return nil, fmt.Errorf("availability check failed for NZB %s: %w", nzb.ID, err) + //} + // Mark NZB as downloaded but not completed + nzb.Downloaded = true + nzb.AddedOn = time.Now() + p.store.AddToQueue(nzb) + + if err := p.store.Add(nzb); err != nil { + return nil, err + } // Add the downloaded NZB to the store asynchronously + p.logger.Info(). + Str("nzb_id", nzb.ID). + Msg("NZB added to queue") + + go func() { + if err := p.downloadWorker.Process(ctx, job); err != nil { + p.logger.Error(). + Err(err). + Str("nzb_id", nzb.ID). + Msg("Failed to submit download job") + } + }() + + return nzb, nil +} diff --git a/pkg/usenet/rar.go b/pkg/usenet/rar.go new file mode 100644 index 0000000..06fdbde --- /dev/null +++ b/pkg/usenet/rar.go @@ -0,0 +1,336 @@ +package usenet + +import ( + "bytes" + "context" + "fmt" + "github.com/nwaples/rardecode/v2" + "github.com/sirrobot01/decypharr/internal/utils" + "io" + "strings" + "time" +) + +type RarParser struct { + streamer *Streamer +} + +func NewRarParser(s *Streamer) *RarParser { + return &RarParser{streamer: s} +} + +func (p *RarParser) ExtractFileRange(ctx context.Context, file *NZBFile, password string, start, end int64, writer io.Writer) error { + info, err := p.getFileInfo(ctx, file, password) + if err != nil { + return fmt.Errorf("failed to get file info: %w", err) + } + + requiredSegments := p.calculateSmartSegmentRanges(file, info, start, end) + return p.extract(ctx, requiredSegments, password, info.FileName, start, end, writer) +} + +func (p *RarParser) calculateSmartSegmentRanges(file *NZBFile, fileInfo *ExtractedFileInfo, start, end int64) []SegmentRange { + totalSegments := len(file.Segments) + + // For store compression, be more conservative with seeking + compressionOverhead := 1.1 // Increase to 10% overhead + + estimatedArchiveStart := int64(float64(start) * compressionOverhead) + estimatedArchiveEnd := int64(float64(end) * compressionOverhead) + + startSegmentIndex := int(float64(estimatedArchiveStart) / float64(fileInfo.ArchiveSize) * float64(totalSegments)) + endSegmentIndex := int(float64(estimatedArchiveEnd) / float64(fileInfo.ArchiveSize) * float64(totalSegments)) + + // More conservative buffers for seeking + if start > 0 { + // For seeking, always include more context + headerBuffer := min(10, startSegmentIndex) // Up to 10 segments back + startSegmentIndex = max(0, startSegmentIndex-headerBuffer) + } else { + startSegmentIndex = 0 + } + + // Larger end buffer for segment boundaries and RAR footer + endBuffer := 10 + int(float64(totalSegments)*0.02) // 2% of total segments as buffer + endSegmentIndex = min(totalSegments-1, endSegmentIndex+endBuffer) + + // Ensure minimum segment count for seeking + minSegmentsForSeek := 20 + if endSegmentIndex-startSegmentIndex < minSegmentsForSeek { + endSegmentIndex = min(totalSegments-1, startSegmentIndex+minSegmentsForSeek) + } + + return convertSegmentIndicesToRanges(file, startSegmentIndex, endSegmentIndex) +} + +func (p *RarParser) extract(ctx context.Context, segmentRanges []SegmentRange, password, targetFileName string, start, end int64, writer io.Writer) error { + pipeReader, pipeWriter := io.Pipe() + + extractionErr := make(chan error, 1) + streamingErr := make(chan error, 1) + + // RAR extraction goroutine + go func() { + defer func() { + pipeReader.Close() + if r := recover(); r != nil { + extractionErr <- fmt.Errorf("extraction panic: %v", r) + } + }() + + rarReader, err := rardecode.NewReader(pipeReader, rardecode.Password(password)) + if err != nil { + extractionErr <- fmt.Errorf("failed to create RAR reader: %w", err) + return + } + + found := false + for { + select { + case <-ctx.Done(): + extractionErr <- ctx.Err() + return + default: + } + + header, err := rarReader.Next() + if err == io.EOF { + if !found { + extractionErr <- fmt.Errorf("target file %s not found in downloaded segments", targetFileName) + } else { + extractionErr <- fmt.Errorf("reached EOF before completing range extraction") + } + return + } + if err != nil { + extractionErr <- fmt.Errorf("failed to read RAR header: %w", err) + return + } + + if header.Name == targetFileName || utils.IsMediaFile(header.Name) { + found = true + err = p.extractRangeFromReader(ctx, rarReader, start, end, writer) + extractionErr <- err + return + } else if !header.IsDir { + err = p.skipFileEfficiently(ctx, rarReader) + if err != nil && ctx.Err() == nil { + extractionErr <- fmt.Errorf("failed to skip file %s: %w", header.Name, err) + return + } + } + } + }() + + // Streaming goroutine + go func() { + defer pipeWriter.Close() + err := p.streamer.stream(ctx, segmentRanges, pipeWriter) + streamingErr <- err + }() + + // Wait with longer timeout for seeking operations + select { + case err := <-extractionErr: + return err + case err := <-streamingErr: + if err != nil && !p.isSkippableError(err) { + return fmt.Errorf("segment streaming failed: %w", err) + } + // Longer timeout for seeking operations + select { + case err := <-extractionErr: + return err + case <-time.After(30 * time.Second): // Increased from 5 seconds + return fmt.Errorf("extraction timeout after 30 seconds") + } + case <-ctx.Done(): + return ctx.Err() + } +} + +func (p *RarParser) extractRangeFromReader(ctx context.Context, reader io.Reader, start, end int64, writer io.Writer) error { + // Skip to start position efficiently + if start > 0 { + skipped, err := p.smartSkip(ctx, reader, start) + if err != nil { + return fmt.Errorf("failed to skip to position %d (skipped %d): %w", start, skipped, err) + } + } + + // Copy requested range + bytesToCopy := end - start + 1 + copied, err := p.smartCopy(ctx, writer, reader, bytesToCopy) + if err != nil && err != io.EOF { + return fmt.Errorf("failed to copy range (copied %d/%d): %w", copied, bytesToCopy, err) + } + + return nil +} + +func (p *RarParser) smartSkip(ctx context.Context, reader io.Reader, bytesToSkip int64) (int64, error) { + const skipBufferSize = 64 * 1024 // Larger buffer for skipping + + buffer := make([]byte, skipBufferSize) + var totalSkipped int64 + + for totalSkipped < bytesToSkip { + select { + case <-ctx.Done(): + return totalSkipped, ctx.Err() + default: + } + + toRead := skipBufferSize + if remaining := bytesToSkip - totalSkipped; remaining < int64(toRead) { + toRead = int(remaining) + } + + n, err := reader.Read(buffer[:toRead]) + if n > 0 { + totalSkipped += int64(n) + } + if err != nil { + if err == io.EOF { + break + } + return totalSkipped, err + } + } + + return totalSkipped, nil +} + +func (p *RarParser) smartCopy(ctx context.Context, dst io.Writer, src io.Reader, bytesToCopy int64) (int64, error) { + const copyBufferSize = 32 * 1024 + + buffer := make([]byte, copyBufferSize) + var totalCopied int64 + + for totalCopied < bytesToCopy { + select { + case <-ctx.Done(): + return totalCopied, ctx.Err() + default: + } + + toRead := copyBufferSize + if remaining := bytesToCopy - totalCopied; remaining < int64(toRead) { + toRead = int(remaining) + } + + n, err := src.Read(buffer[:toRead]) + if n > 0 { + written, writeErr := dst.Write(buffer[:n]) + if writeErr != nil { + return totalCopied, writeErr + } + totalCopied += int64(written) + } + + if err != nil { + if err == io.EOF { + break + } + return totalCopied, err + } + } + + return totalCopied, nil +} + +func (p *RarParser) skipFileEfficiently(ctx context.Context, reader io.Reader) error { + _, err := p.smartSkip(ctx, reader, 1<<62) // Very large number + if err == io.EOF { + return nil // EOF is expected when skipping + } + return err +} + +func (p *RarParser) getFileInfo(ctx context.Context, file *NZBFile, password string) (*ExtractedFileInfo, error) { + headerSegments := p.getMinimalHeaders(file) + + var headerBuffer bytes.Buffer + err := p.streamer.stream(ctx, headerSegments, &headerBuffer) + if err != nil { + return nil, fmt.Errorf("failed to download headers: %w", err) + } + + reader := bytes.NewReader(headerBuffer.Bytes()) + rarReader, err := rardecode.NewReader(reader, rardecode.Password(password)) + if err != nil { + return nil, fmt.Errorf("failed to create RAR reader (check password): %w", err) + } + + totalArchiveSize := p.calculateTotalSize(file.SegmentSize, file.Segments) + + for { + header, err := rarReader.Next() + if err == io.EOF { + break + } + if err != nil { + continue + } + + if !header.IsDir && utils.IsMediaFile(header.Name) { + return &ExtractedFileInfo{ + FileName: header.Name, + FileSize: header.UnPackedSize, + ArchiveSize: totalArchiveSize, + }, nil + } + } + + return nil, fmt.Errorf("no media file found in RAR archive") +} + +func (p *RarParser) getMinimalHeaders(file *NZBFile) []SegmentRange { + headerCount := min(len(file.Segments), 4) // Minimal for password+headers + return file.ConvertToSegmentRanges(file.Segments[:headerCount]) +} + +func (p *RarParser) calculateTotalSize(segmentSize int64, segments []NZBSegment) int64 { + total := int64(0) + for i, seg := range segments { + if segmentSize <= 0 { + segmentSize = seg.Bytes // Fallback to actual segment size if not set + } + if i == len(segments)-1 { + segmentSize = seg.Bytes // Last segment uses actual size + } + total += segmentSize + } + return total +} + +func (p *RarParser) isSkippableError(err error) bool { + if err == nil { + return true + } + errStr := err.Error() + return strings.Contains(errStr, "client disconnected") || + strings.Contains(errStr, "broken pipe") || + strings.Contains(errStr, "connection reset") +} + +func convertSegmentIndicesToRanges(file *NZBFile, startIndex, endIndex int) []SegmentRange { + var segmentRanges []SegmentRange + + for i := startIndex; i <= endIndex && i < len(file.Segments); i++ { + segment := file.Segments[i] + + // For RAR files, we want the entire segment (no partial byte ranges) + segmentRange := SegmentRange{ + Segment: segment, + ByteStart: 0, // Always start at beginning of segment + ByteEnd: segment.Bytes - 1, // Always go to end of segment + TotalStart: 0, // Not used for this approach + TotalEnd: segment.Bytes - 1, // Not used for this approach + } + + segmentRanges = append(segmentRanges, segmentRange) + } + + return segmentRanges +} diff --git a/pkg/usenet/store.go b/pkg/usenet/store.go new file mode 100644 index 0000000..dcb7021 --- /dev/null +++ b/pkg/usenet/store.go @@ -0,0 +1,619 @@ +package usenet + +import ( + "context" + "encoding/json" + "fmt" + "github.com/puzpuzpuz/xsync/v4" + "github.com/rs/zerolog" + "github.com/sirrobot01/decypharr/internal/config" + "github.com/sourcegraph/conc/pool" + "io" + "io/fs" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "time" +) + +type fileInfo struct { + id string + name string + size int64 + mode os.FileMode + modTime time.Time + isDir bool +} + +func (fi *fileInfo) Name() string { return fi.name } +func (fi *fileInfo) Size() int64 { return fi.size } +func (fi *fileInfo) Mode() os.FileMode { return fi.mode } +func (fi *fileInfo) ModTime() time.Time { return fi.modTime } +func (fi *fileInfo) IsDir() bool { return fi.isDir } +func (fi *fileInfo) ID() string { return fi.id } +func (fi *fileInfo) Sys() interface{} { return nil } + +type Store interface { + Add(nzb *NZB) error + Get(nzoID string) *NZB + GetByName(name string) *NZB + Update(nzb *NZB) error + UpdateFile(nzoID string, file *NZBFile) error + Delete(nzoID string) error + Count() int + Filter(category string, limit int, status ...string) []*NZB + GetHistory(category string, limit int) []*NZB + UpdateStatus(nzoID string, status string) error + Close() error + GetListing(folder string) []os.FileInfo + Load() error + + // GetQueueItem Queue management + + GetQueueItem(nzoID string) *NZB + AddToQueue(nzb *NZB) + RemoveFromQueue(nzoID string) + GetQueue() []*NZB + AtomicDelete(nzoID string) error + + RemoveFile(nzoID string, filename string) error + + MarkAsCompleted(nzoID string, storage string) error +} + +type store struct { + storePath string + listing atomic.Value + badListing atomic.Value + queue *xsync.Map[string, *NZB] + titles *xsync.Map[string, string] // title -> nzoID + config *config.Usenet + logger zerolog.Logger +} + +func NewStore(config *config.Config, logger zerolog.Logger) Store { + err := os.MkdirAll(config.NZBsPath(), 0755) + if err != nil { + return nil + } + + s := &store{ + storePath: config.NZBsPath(), + queue: xsync.NewMap[string, *NZB](), + titles: xsync.NewMap[string, string](), + config: config.Usenet, + logger: logger, + } + return s +} + +func (ns *store) Load() error { + ids, err := ns.getAllIDs() + if err != nil { + return err + } + + listing := make([]os.FileInfo, 0) + badListing := make([]os.FileInfo, 0) + + for _, id := range ids { + nzb, err := ns.loadFromFile(id) + if err != nil { + continue // Skip if file cannot be loaded + } + + ns.titles.Store(nzb.Name, nzb.ID) + + fileInfo := &fileInfo{ + id: nzb.ID, + name: nzb.Name, + size: nzb.TotalSize, + mode: 0644, + modTime: nzb.AddedOn, + isDir: true, + } + + listing = append(listing, fileInfo) + if nzb.IsBad { + badListing = append(badListing, fileInfo) + } + } + + ns.listing.Store(listing) + ns.badListing.Store(badListing) + + return nil +} + +// getFilePath returns the file path for an NZB +func (ns *store) getFilePath(nzoID string) string { + return filepath.Join(ns.storePath, nzoID+".json") +} + +func (ns *store) loadFromFile(nzoID string) (*NZB, error) { + filePath := ns.getFilePath(nzoID) + + data, err := os.ReadFile(filePath) + if err != nil { + return nil, err + } + + var compact CompactNZB + if err := json.Unmarshal(data, &compact); err != nil { + return nil, err + } + + return compact.toNZB(), nil +} + +// saveToFile saves an NZB to file +func (ns *store) saveToFile(nzb *NZB) error { + filePath := ns.getFilePath(nzb.ID) + + // Ensure directory exists + dir := filepath.Dir(filePath) + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + + compact := nzb.toCompact() + data, err := json.Marshal(compact) // Use compact JSON + if err != nil { + return err + } + + return os.WriteFile(filePath, data, 0644) +} + +func (ns *store) refreshListing() error { + ids, err := ns.getAllIDs() + if err != nil { + return err + } + + listing := make([]os.FileInfo, 0, len(ids)) + badListing := make([]os.FileInfo, 0, len(ids)) + + for _, id := range ids { + nzb, err := ns.loadFromFile(id) + if err != nil { + continue // Skip if file cannot be loaded + } + fileInfo := &fileInfo{ + id: nzb.ID, + name: nzb.Name, + size: nzb.TotalSize, + mode: 0644, + modTime: nzb.AddedOn, + isDir: true, + } + listing = append(listing, fileInfo) + ns.titles.Store(nzb.Name, nzb.ID) + if nzb.IsBad { + badListing = append(badListing, fileInfo) + } + } + + // Update all structures atomically + ns.listing.Store(listing) + ns.badListing.Store(badListing) + + // Refresh rclone if configured + go func() { + if err := ns.refreshRclone(); err != nil { + ns.logger.Error().Err(err).Msg("Failed to refresh rclone") + } + }() + + return nil +} + +func (ns *store) Add(nzb *NZB) error { + if nzb == nil { + return fmt.Errorf("nzb cannot be nil") + } + if err := ns.saveToFile(nzb); err != nil { + return err + } + + ns.titles.Store(nzb.Name, nzb.ID) + + go func() { + _ = ns.refreshListing() + }() + return nil +} + +func (ns *store) GetByName(name string) *NZB { + + if nzoID, exists := ns.titles.Load(name); exists { + return ns.Get(nzoID) + } + return nil +} + +func (ns *store) GetQueueItem(nzoID string) *NZB { + if item, exists := ns.queue.Load(nzoID); exists { + return item + } + return nil +} + +func (ns *store) AddToQueue(nzb *NZB) { + if nzb == nil { + return + } + ns.queue.Store(nzb.ID, nzb) +} + +func (ns *store) RemoveFromQueue(nzoID string) { + if nzoID == "" { + return + } + ns.queue.Delete(nzoID) +} + +func (ns *store) GetQueue() []*NZB { + var queueItems []*NZB + ns.queue.Range(func(_ string, value *NZB) bool { + queueItems = append(queueItems, value) + return true // continue iteration + }) + return queueItems +} + +func (ns *store) Get(nzoID string) *NZB { + nzb, err := ns.loadFromFile(nzoID) + if err != nil { + return nil + } + return nzb +} +func (ns *store) Update(nzb *NZB) error { + if err := ns.saveToFile(nzb); err != nil { + return err + } + return nil +} + +func (ns *store) Delete(nzoID string) error { + return ns.AtomicDelete(nzoID) +} + +// AtomicDelete performs an atomic delete operation across all data structures +func (ns *store) AtomicDelete(nzoID string) error { + if nzoID == "" { + return fmt.Errorf("nzoID cannot be empty") + } + + filePath := ns.getFilePath(nzoID) + + // Get NZB info before deletion for cleanup + nzb := ns.Get(nzoID) + if nzb == nil { + // Check if file exists on disk even if not in cache + if _, err := os.Stat(filePath); os.IsNotExist(err) { + return nil // Already deleted + } + } + ns.queue.Delete(nzoID) + + if nzb != nil { + ns.titles.Delete(nzb.Name) + } + + if currentListing := ns.listing.Load(); currentListing != nil { + oldListing := currentListing.([]os.FileInfo) + newListing := make([]os.FileInfo, 0, len(oldListing)) + for _, fi := range oldListing { + if fileInfo, ok := fi.(*fileInfo); ok && fileInfo.id != nzoID { + newListing = append(newListing, fi) + } + } + ns.listing.Store(newListing) + } + + if currentListing := ns.badListing.Load(); currentListing != nil { + oldListing := currentListing.([]os.FileInfo) + newListing := make([]os.FileInfo, 0, len(oldListing)) + for _, fi := range oldListing { + if fileInfo, ok := fi.(*fileInfo); ok && fileInfo.id != nzoID { + newListing = append(newListing, fi) + } + } + ns.badListing.Store(newListing) + } + + // Remove file from disk + return os.Remove(filePath) +} + +func (ns *store) RemoveFile(nzoID string, filename string) error { + if nzoID == "" || filename == "" { + return fmt.Errorf("nzoID and filename cannot be empty") + } + + nzb := ns.Get(nzoID) + if nzb == nil { + return fmt.Errorf("nzb with nzoID %s not found", nzoID) + } + err := nzb.MarkFileAsRemoved(filename) + if err != nil { + return err + } + if err := ns.Update(nzb); err != nil { + return fmt.Errorf("failed to update nzb after removing file %s: %w", filename, err) + } + // Refresh listing after file removal + _ = ns.refreshListing() + // Remove file from rclone cache if configured + return nil +} + +func (ns *store) getAllIDs() ([]string, error) { + var ids []string + + err := filepath.WalkDir(ns.storePath, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + if !d.IsDir() && strings.HasSuffix(d.Name(), ".json") { + id := strings.TrimSuffix(d.Name(), ".json") + ids = append(ids, id) + } + return nil + }) + + return ids, err +} + +func (ns *store) Filter(category string, limit int, status ...string) []*NZB { + ids, err := ns.getAllIDs() + if err != nil { + return nil + } + + statusSet := make(map[string]struct{}) + for _, s := range status { + statusSet[s] = struct{}{} + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + p := pool.New().WithContext(ctx).WithMaxGoroutines(10) + + var results []*NZB + var mu sync.Mutex + var found atomic.Int32 + + for _, id := range ids { + id := id + p.Go(func(ctx context.Context) error { + // Early exit if limit reached + if limit > 0 && found.Load() >= int32(limit) { + return nil + } + + select { + case <-ctx.Done(): + return ctx.Err() + default: + nzb := ns.Get(id) + if nzb == nil { + return nil + } + + // Apply filters + if category != "" && nzb.Category != category { + return nil + } + + if len(statusSet) > 0 { + if _, exists := statusSet[nzb.Status]; !exists { + return nil + } + } + + // Add to results with limit check + mu.Lock() + if limit == 0 || len(results) < limit { + results = append(results, nzb) + found.Add(1) + + // Cancel if we hit the limit + if limit > 0 && len(results) >= limit { + cancel() + } + } + mu.Unlock() + + return nil + } + }) + } + + if err := p.Wait(); err != nil { + return nil + } + return results +} + +func (ns *store) Count() int { + ids, err := ns.getAllIDs() + if err != nil { + return 0 + } + return len(ids) +} + +func (ns *store) GetHistory(category string, limit int) []*NZB { + return ns.Filter(category, limit, "completed", "failed", "error") +} + +func (ns *store) UpdateStatus(nzoID string, status string) error { + nzb := ns.Get(nzoID) + if nzb == nil { + return fmt.Errorf("nzb with nzoID %s not found", nzoID) + } + + nzb.Status = status + nzb.LastActivity = time.Now() + + if status == "completed" { + nzb.CompletedOn = time.Now() + nzb.Progress = 100 + nzb.Percentage = 100 + } + if status == "failed" { + // Remove from cache if failed + err := ns.Delete(nzb.ID) + if err != nil { + return err + } + } + + return ns.Update(nzb) +} + +func (ns *store) Close() error { + // Clear cache + ns.queue = xsync.NewMap[string, *NZB]() + // Clear listings + ns.listing = atomic.Value{} + ns.badListing = atomic.Value{} + // Clear titles + ns.titles = xsync.NewMap[string, string]() + return nil +} + +func (ns *store) UpdateFile(nzoID string, file *NZBFile) error { + if nzoID == "" || file == nil { + return fmt.Errorf("nzoID and file cannot be empty") + } + + nzb := ns.Get(nzoID) + if nzb == nil { + return fmt.Errorf("nzb with nzoID %s not found", nzoID) + } + + // Update file in NZB + for i, f := range nzb.Files { + if f.Name == file.Name { + nzb.Files[i] = *file + break + } + } + + if err := ns.Update(nzb); err != nil { + return fmt.Errorf("failed to update nzb after updating file %s: %w", file.Name, err) + } + + // Refresh listing after file update + return ns.refreshListing() +} + +func (ns *store) GetListing(folder string) []os.FileInfo { + switch folder { + case "__bad__": + if badListing, ok := ns.badListing.Load().([]os.FileInfo); ok { + return badListing + } + return []os.FileInfo{} + default: + if listing, ok := ns.listing.Load().([]os.FileInfo); ok { + return listing + } + return []os.FileInfo{} + } +} + +func (ns *store) MarkAsCompleted(nzoID string, storage string) error { + if nzoID == "" { + return fmt.Errorf("nzoID cannot be empty") + } + + // Get NZB from queue + queueNZB := ns.GetQueueItem(nzoID) + if queueNZB == nil { + return fmt.Errorf("NZB %s not found in queue", nzoID) + } + + // Update NZB status + queueNZB.Status = "completed" + queueNZB.Storage = storage + queueNZB.CompletedOn = time.Now() + queueNZB.LastActivity = time.Now() + queueNZB.Progress = 100 + queueNZB.Percentage = 100 + + // Atomically: remove from queue and add to storage + ns.queue.Delete(nzoID) + if err := ns.Add(queueNZB); err != nil { + // Rollback: add back to queue if storage fails + ns.queue.Store(nzoID, queueNZB) + return fmt.Errorf("failed to store completed NZB: %w", err) + } + + return nil +} + +func (ns *store) refreshRclone() error { + + if ns.config.RcUrl == "" { + return nil + } + + client := http.DefaultClient + // Create form data + data := ns.buildRcloneRequestData() + + if err := ns.sendRcloneRequest(client, "vfs/forget", data); err != nil { + ns.logger.Error().Err(err).Msg("Failed to send rclone vfs/forget request") + } + + if err := ns.sendRcloneRequest(client, "vfs/refresh", data); err != nil { + ns.logger.Error().Err(err).Msg("Failed to send rclone vfs/refresh request") + } + + return nil +} + +func (ns *store) buildRcloneRequestData() string { + return "dir=__all__" +} + +func (ns *store) sendRcloneRequest(client *http.Client, endpoint, data string) error { + req, err := http.NewRequest("POST", fmt.Sprintf("%s/%s", ns.config.RcUrl, endpoint), strings.NewReader(data)) + if err != nil { + return err + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + if ns.config.RcUser != "" && ns.config.RcPass != "" { + req.SetBasicAuth(ns.config.RcUser, ns.config.RcPass) + } + resp, err := client.Do(req) + if err != nil { + return err + } + defer func(Body io.ReadCloser) { + err := Body.Close() + if err != nil { + ns.logger.Error().Err(err).Msg("Failed to close response body") + } + }(resp.Body) + + if resp.StatusCode != 200 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return fmt.Errorf("failed to perform %s: %s - %s", endpoint, resp.Status, string(body)) + } + + _, _ = io.Copy(io.Discard, resp.Body) + return nil +} diff --git a/pkg/usenet/stream.go b/pkg/usenet/stream.go new file mode 100644 index 0000000..a2387ba --- /dev/null +++ b/pkg/usenet/stream.go @@ -0,0 +1,383 @@ +package usenet + +import ( + "bytes" + "context" + "errors" + "fmt" + "github.com/chrisfarms/yenc" + "github.com/rs/zerolog" + "github.com/sirrobot01/decypharr/internal/nntp" + "io" + "net/http" + "sync" + "time" +) + +var groupCache = sync.Map{} + +type Streamer struct { + logger zerolog.Logger + client *nntp.Client + store Store + cache *SegmentCache + chunkSize int + maxRetries int + retryDelayMs int +} + +type segmentResult struct { + index int + data []byte + err error +} + +type FlushingWriter struct { + writer io.Writer +} + +func (fw *FlushingWriter) Write(data []byte) (int, error) { + if len(data) == 0 { + return 0, nil + } + + written, err := fw.writer.Write(data) + if err != nil { + return written, err + } + + if written != len(data) { + return written, io.ErrShortWrite + } + + // Auto-flush if possible + if flusher, ok := fw.writer.(http.Flusher); ok { + flusher.Flush() + } + + return written, nil +} + +func (fw *FlushingWriter) WriteAndFlush(data []byte) (int64, error) { + if len(data) == 0 { + return 0, nil + } + + written, err := fw.Write(data) + return int64(written), err +} + +func (fw *FlushingWriter) WriteString(s string) (int, error) { + return fw.Write([]byte(s)) +} + +func (fw *FlushingWriter) WriteBytes(data []byte) (int, error) { + return fw.Write(data) +} + +func NewStreamer(client *nntp.Client, cache *SegmentCache, store Store, chunkSize int, logger zerolog.Logger) *Streamer { + return &Streamer{ + logger: logger.With().Str("component", "streamer").Logger(), + cache: cache, + store: store, + client: client, + chunkSize: chunkSize, + maxRetries: 3, + retryDelayMs: 2000, + } +} + +func (s *Streamer) Stream(ctx context.Context, file *NZBFile, start, end int64, writer io.Writer) error { + if file == nil { + return fmt.Errorf("file cannot be nil") + } + + if start < 0 { + start = 0 + } + + if err := s.getSegmentSize(ctx, file); err != nil { + return fmt.Errorf("failed to get segment size: %w", err) + } + + if file.IsRarArchive { + return s.streamRarExtracted(ctx, file, start, end, writer) + } + if end >= file.Size { + end = file.Size - 1 + } + if start > end { + return fmt.Errorf("invalid range: start=%d > end=%d", start, end) + } + + ranges := file.GetSegmentsInRange(file.SegmentSize, start, end) + if len(ranges) == 0 { + return fmt.Errorf("no segments found for range [%d, %d]", start, end) + } + + writer = &FlushingWriter{writer: writer} + return s.stream(ctx, ranges, writer) +} + +func (s *Streamer) streamRarExtracted(ctx context.Context, file *NZBFile, start, end int64, writer io.Writer) error { + parser := NewRarParser(s) + return parser.ExtractFileRange(ctx, file, file.Password, start, end, writer) +} + +func (s *Streamer) stream(ctx context.Context, ranges []SegmentRange, writer io.Writer) error { + chunkSize := s.chunkSize + + for i := 0; i < len(ranges); i += chunkSize { + end := min(i+chunkSize, len(ranges)) + chunk := ranges[i:end] + + // Download chunk concurrently + results := make([]segmentResult, len(chunk)) + var wg sync.WaitGroup + + for j, segRange := range chunk { + wg.Add(1) + go func(idx int, sr SegmentRange) { + defer wg.Done() + data, err := s.processSegment(ctx, sr) + results[idx] = segmentResult{index: idx, data: data, err: err} + }(j, segRange) + } + + wg.Wait() + + // Write chunk sequentially + for j, result := range results { + if result.err != nil { + return fmt.Errorf("segment %d failed: %w", i+j, result.err) + } + + if len(result.data) > 0 { + _, err := writer.Write(result.data) + if err != nil { + return err + } + } + } + } + + return nil +} + +func (s *Streamer) processSegment(ctx context.Context, segRange SegmentRange) ([]byte, error) { + segment := segRange.Segment + // Try cache first + if s.cache != nil { + if cached, found := s.cache.Get(segment.MessageID); found { + return s.extractRangeFromSegment(cached.Data, segRange) + } + } + + // Download with retries + decodedData, err := s.downloadSegmentWithRetry(ctx, segment) + if err != nil { + return nil, fmt.Errorf("download failed: %w", err) + } + + // Cache full segment for future seeks + if s.cache != nil { + s.cache.Put(segment.MessageID, decodedData, segment.Bytes) + } + + // Extract the specific range from this segment + return s.extractRangeFromSegment(decodedData.Body, segRange) +} + +func (s *Streamer) extractRangeFromSegment(data []byte, segRange SegmentRange) ([]byte, error) { + // Use the segment range's pre-calculated offsets + startOffset := segRange.ByteStart + endOffset := segRange.ByteEnd + 1 // ByteEnd is inclusive, we need exclusive for slicing + + // Bounds check + if startOffset < 0 || startOffset >= int64(len(data)) { + return []byte{}, nil + } + + if endOffset > int64(len(data)) { + endOffset = int64(len(data)) + } + + if startOffset >= endOffset { + return []byte{}, nil + } + + // Extract the range + result := make([]byte, endOffset-startOffset) + copy(result, data[startOffset:endOffset]) + + return result, nil +} + +func (s *Streamer) downloadSegmentWithRetry(ctx context.Context, segment NZBSegment) (*yenc.Part, error) { + var lastErr error + + for attempt := 0; attempt < s.maxRetries; attempt++ { + // Check cancellation before each retry + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + + if attempt > 0 { + delay := time.Duration(s.retryDelayMs*(1<<(attempt-1))) * time.Millisecond + if delay > 5*time.Second { + delay = 5 * time.Second + } + + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(delay): + } + } + + data, err := s.downloadSegment(ctx, segment) + if err == nil { + return data, nil + } + + lastErr = err + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return nil, err + } + } + + return nil, fmt.Errorf("segment download failed after %d attempts: %w", s.maxRetries, lastErr) +} + +// Updated to work with NZBSegment from SegmentRange +func (s *Streamer) downloadSegment(ctx context.Context, segment NZBSegment) (*yenc.Part, error) { + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + + downloadCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + conn, cleanup, err := s.client.GetConnection(downloadCtx) + if err != nil { + return nil, err + } + defer cleanup() + + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + + if segment.Group != "" { + if _, exists := groupCache.Load(segment.Group); !exists { + if _, err := conn.SelectGroup(segment.Group); err != nil { + return nil, fmt.Errorf("failed to select group %s: %w", segment.Group, err) + } + groupCache.Store(segment.Group, true) + } + } + + body, err := conn.GetBody(segment.MessageID) + if err != nil { + return nil, fmt.Errorf("failed to get body for message %s: %w", segment.MessageID, err) + } + + if body == nil || len(body) == 0 { + return nil, fmt.Errorf("no body found for message %s", segment.MessageID) + } + + data, err := nntp.DecodeYenc(bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("failed to decode yEnc: %w", err) + } + + // Adjust begin offset + data.Begin -= 1 + + return data, nil +} + +func (s *Streamer) copySegmentData(writer io.Writer, data []byte) (int64, error) { + if len(data) == 0 { + return 0, nil + } + + reader := bytes.NewReader(data) + written, err := io.CopyN(writer, reader, int64(len(data))) + if err != nil { + return 0, fmt.Errorf("copyN failed %w", err) + } + + if written != int64(len(data)) { + return 0, fmt.Errorf("expected to copy %d bytes, only copied %d", len(data), written) + } + + if fl, ok := writer.(http.Flusher); ok { + fl.Flush() + } + + return written, nil +} + +func (s *Streamer) extractRangeWithGapHandling(data []byte, segStart, segEnd int64, globalStart, globalEnd int64) ([]byte, error) { + // Calculate intersection using actual bounds + intersectionStart := max(segStart, globalStart) + intersectionEnd := min(segEnd, globalEnd+1) // +1 because globalEnd is inclusive + + // No overlap + if intersectionStart >= intersectionEnd { + return []byte{}, nil + } + + // Calculate offsets within the actual data + offsetInData := intersectionStart - segStart + dataLength := intersectionEnd - intersectionStart + // Bounds check + if offsetInData < 0 || offsetInData >= int64(len(data)) { + return []byte{}, nil + } + + endOffset := offsetInData + dataLength + if endOffset > int64(len(data)) { + endOffset = int64(len(data)) + dataLength = endOffset - offsetInData + } + + if dataLength <= 0 { + return []byte{}, nil + } + + // Extract the range + result := make([]byte, dataLength) + copy(result, data[offsetInData:endOffset]) + + return result, nil +} + +func (s *Streamer) getSegmentSize(ctx context.Context, file *NZBFile) error { + if file.SegmentSize > 0 { + return nil + } + if len(file.Segments) == 0 { + return fmt.Errorf("no segments available for file %s", file.Name) + } + // Fetch the segment size and then store it in the file + firstSegment := file.Segments[0] + firstInfo, err := s.client.DownloadHeader(ctx, firstSegment.MessageID) + if err != nil { + return err + } + + chunkSize := firstInfo.End - (firstInfo.Begin - 1) + if chunkSize <= 0 { + return fmt.Errorf("invalid segment size for file %s: %d", file.Name, chunkSize) + } + file.SegmentSize = chunkSize + return s.store.UpdateFile(file.NzbID, file) +} diff --git a/pkg/usenet/types.go b/pkg/usenet/types.go new file mode 100644 index 0000000..a2a2785 --- /dev/null +++ b/pkg/usenet/types.go @@ -0,0 +1,239 @@ +package usenet + +import "time" + +// NZB represents a torrent-like structure for NZB files +type NZB struct { + ID string `json:"id"` + Name string `json:"name"` + Title string `json:"title,omitempty"` + TotalSize int64 `json:"total_size"` + DatePosted time.Time `json:"date_posted"` + Category string `json:"category"` + Groups []string `json:"groups"` + Files []NZBFile `json:"files"` + Downloaded bool `json:"downloaded"` // Whether the NZB has been downloaded + StreamingInfo StreamingInfo `json:"streaming_info"` + AddedOn time.Time `json:"added_on"` // When the NZB was added to the system + LastActivity time.Time `json:"last_activity"` // Last activity timestamp + Status string `json:"status"` // "queued", "downloading", "completed", "failed" + Progress float64 `json:"progress"` // Percentage of download completion + Percentage float64 `json:"percentage"` // Percentage of download completion + SizeDownloaded int64 `json:"size_downloaded"` // Total size downloaded so far + ETA int64 `json:"eta"` // Estimated time of arrival in seconds + Speed int64 `json:"speed"` // Download speed in bytes per second + CompletedOn time.Time `json:"completed_on"` // When the NZB was completed + IsBad bool `json:"is_bad"` + Storage string `json:"storage"` + FailMessage string `json:"fail_message,omitempty"` // Error message if the download failed + Password string `json:"-,omitempty"` // Password for encrypted RAR files +} + +// StreamingInfo contains metadata for streaming capabilities +type StreamingInfo struct { + IsStreamable bool `json:"is_streamable"` + MainFileIndex int `json:"main_file_index"` // Index of the main media file + HasParFiles bool `json:"has_par_files"` + HasRarFiles bool `json:"has_rar_files"` + TotalSegments int `json:"total_segments"` + EstimatedTime int64 `json:"estimated_time"` // Estimated download time in seconds +} + +type SegmentValidationInfo struct { + ExpectedSize int64 + ActualSize int64 + Validated bool +} + +// NZBFile represents a grouped file with its segments +type NZBFile struct { + NzbID string `json:"nzo_id"` + Name string `json:"name"` + Size int64 `json:"size"` + StartOffset int64 `json:"start_offset"` // This is useful for removing rar headers + Segments []NZBSegment `json:"segments"` + Groups []string `json:"groups"` + SegmentValidation map[string]*SegmentValidationInfo `json:"-"` + IsRarArchive bool `json:"is_rar_archive"` // Whether this file is a RAR archive that needs extraction + Password string `json:"password,omitempty"` // Password for encrypted RAR files + IsDeleted bool `json:"is_deleted"` + SegmentSize int64 `json:"segment_size,omitempty"` // Size of each segment in bytes, if applicable +} + +// NZBSegment represents a segment with all necessary download info +type NZBSegment struct { + Number int `json:"number"` + MessageID string `json:"message_id"` + Bytes int64 `json:"bytes"` + StartOffset int64 `json:"start_offset"` // Byte offset within the file + EndOffset int64 `json:"end_offset"` // End byte offset within the file + Group string `json:"group"` +} + +// CompactNZB is a space-optimized version of NZB for storage +type CompactNZB struct { + ID string `json:"i"` + Name string `json:"n"` + Status string `json:"s"` + Category string `json:"c"` + Size int64 `json:"sz"` + Progress float64 `json:"p"` + Speed int64 `json:"sp,omitempty"` + ETA int64 `json:"e,omitempty"` + Added int64 `json:"a"` // Unix timestamp + Modified int64 `json:"m"` // Unix timestamp + Complete int64 `json:"co,omitempty"` // Unix timestamp + Groups []string `json:"g,omitempty"` + Files []CompactFile `json:"f,omitempty"` + Storage string `json:"st,omitempty"` // Storage path + FailMessage string `json:"fm,omitempty"` // Error message if the download failed + Downloaded bool `json:"d,omitempty"` +} + +// CompactFile represents a file in compact format +type CompactFile struct { + Name string `json:"n"` + Size int64 `json:"s"` + Type string `json:"t"` + Main bool `json:"m,omitempty"` + Offset int64 `json:"o"` + Segments []CompactSegment `json:"seg,omitempty"` + IsRar bool `json:"r,omitempty"` + Password string `json:"p,omitempty"` + IsDeleted bool `json:"del,omitempty"` // Whether the file is marked as deleted + ExtractedFileInfo *ExtractedFileInfo `json:"efi,omitempty"` // Pre-extracted RAR file info + SegmentSize int64 `json:"ss,omitempty"` // Size of each segment in bytes, if applicable +} + +// CompactSegment represents a segment in compact format +type CompactSegment struct { + Number int `json:"n"` // Segment number + MessageID string `json:"mid"` // Message-ID of the segment + Bytes int64 `json:"b"` // Size in bytes + StartOffset int64 `json:"so"` // Start byte offset within the file + EndOffset int64 `json:"eo"` // End byte offset within the file + Group string `json:"g,omitempty"` // Group associated with this segment +} + +type ExtractedFileInfo struct { + FileName string `json:"fn,omitempty"` + FileSize int64 `json:"fs,omitempty"` + ArchiveSize int64 `json:"as,omitempty"` // Total size of the RAR archive + EstimatedStartOffset int64 `json:"eso,omitempty"` // Estimated start offset in the archive + SegmentSize int64 `json:"ss,omitempty"` // Size of each segment in the archive +} + +// toCompact converts NZB to compact format +func (nzb *NZB) toCompact() *CompactNZB { + compact := &CompactNZB{ + ID: nzb.ID, + Name: nzb.Name, + Status: nzb.Status, + Category: nzb.Category, + Size: nzb.TotalSize, + Progress: nzb.Progress, + Speed: nzb.Speed, + ETA: nzb.ETA, + Added: nzb.AddedOn.Unix(), + Modified: nzb.LastActivity.Unix(), + Storage: nzb.Storage, + Downloaded: nzb.Downloaded, + FailMessage: nzb.FailMessage, + } + + if !nzb.CompletedOn.IsZero() { + compact.Complete = nzb.CompletedOn.Unix() + } + + // Only store essential groups (first 3) + if len(nzb.Groups) > 0 { + maxGroups := 3 + if len(nzb.Groups) < maxGroups { + maxGroups = len(nzb.Groups) + } + compact.Groups = nzb.Groups[:maxGroups] + } + + // Store only essential file info + if len(nzb.Files) > 0 { + compact.Files = make([]CompactFile, len(nzb.Files)) + for i, file := range nzb.Files { + compact.Files[i] = file.toCompact() + } + } + + return compact +} + +// fromCompact converts compact format back to NZB +func (compact *CompactNZB) toNZB() *NZB { + nzb := &NZB{ + ID: compact.ID, + Name: compact.Name, + Status: compact.Status, + Category: compact.Category, + TotalSize: compact.Size, + Progress: compact.Progress, + Percentage: compact.Progress, + Speed: compact.Speed, + ETA: compact.ETA, + Groups: compact.Groups, + AddedOn: time.Unix(compact.Added, 0), + LastActivity: time.Unix(compact.Modified, 0), + Storage: compact.Storage, + Downloaded: compact.Downloaded, + FailMessage: compact.FailMessage, + StreamingInfo: StreamingInfo{ + MainFileIndex: -1, + }, + } + + if compact.Complete > 0 { + nzb.CompletedOn = time.Unix(compact.Complete, 0) + } + + // Reconstruct files + if len(compact.Files) > 0 { + nzb.Files = make([]NZBFile, len(compact.Files)) + for i, file := range compact.Files { + nzb.Files[i] = file.toNZB() + } + + // Set streaming info + nzb.StreamingInfo.TotalSegments = len(compact.Files) + nzb.StreamingInfo.IsStreamable = nzb.StreamingInfo.MainFileIndex >= 0 + } + + return nzb +} + +func (nf *NZBFile) toCompact() CompactFile { + compact := CompactFile{ + Name: nf.Name, + Size: nf.Size, + Offset: nf.StartOffset, + IsRar: nf.IsRarArchive, + IsDeleted: nf.IsDeleted, + Password: nf.Password, + SegmentSize: nf.SegmentSize, + } + for _, seg := range nf.Segments { + compact.Segments = append(compact.Segments, CompactSegment(seg)) + } + return compact +} +func (compact *CompactFile) toNZB() NZBFile { + f := NZBFile{ + Name: compact.Name, + Size: compact.Size, + StartOffset: compact.Offset, + IsRarArchive: compact.IsRar, + Password: compact.Password, + IsDeleted: compact.IsDeleted, + SegmentSize: compact.SegmentSize, + } + for _, seg := range compact.Segments { + f.Segments = append(f.Segments, NZBSegment(seg)) + } + return f +} diff --git a/pkg/usenet/usenet.go b/pkg/usenet/usenet.go new file mode 100644 index 0000000..9d8d310 --- /dev/null +++ b/pkg/usenet/usenet.go @@ -0,0 +1,180 @@ +package usenet + +import ( + "context" + "fmt" + "github.com/rs/zerolog" + "github.com/sirrobot01/decypharr/internal/config" + "github.com/sirrobot01/decypharr/internal/logger" + "github.com/sirrobot01/decypharr/internal/nntp" + "io" + "os" +) + +// Usenet interface for usenet operations +type Usenet interface { + Start(ctx context.Context) error + IsReady() chan struct{} + ProcessNZB(ctx context.Context, req *ProcessRequest) (*NZB, error) + GetDownloadByteRange(nzoID string, filename string) (int64, int64, error) + Close() + Logger() zerolog.Logger + Stream(ctx context.Context, nzbID string, filename string, start, end int64, writer io.Writer) error + + Store() Store + Client() *nntp.Client +} + +// Client implements UsenetClient +type usenet struct { + client *nntp.Client + store Store + processor *Processor + parser *NZBParser + streamer *Streamer + cache *SegmentCache + logger zerolog.Logger + ready chan struct{} +} + +// New creates a new usenet client +func New() Usenet { + cfg := config.Get() + usenetConfig := cfg.Usenet + if usenetConfig == nil || len(usenetConfig.Providers) == 0 { + // No usenet providers configured, return nil + return nil + } + _logger := logger.New("usenet") + client, err := nntp.NewClient(usenetConfig.Providers) + if err != nil { + _logger.Error().Err(err).Msg("Failed to create usenet client") + return nil + } + store := NewStore(cfg, _logger) + processor, err := NewProcessor(usenetConfig, _logger, store, client) + if err != nil { + _logger.Error().Err(err).Msg("Failed to create usenet processor") + return nil + } + + // Create cache and components + cache := NewSegmentCache(_logger) + parser := NewNZBParser(client, cache, _logger) + streamer := NewStreamer(client, cache, store, usenetConfig.Chunks, _logger) + + return &usenet{ + store: store, + client: client, + processor: processor, + parser: parser, + streamer: streamer, + cache: cache, + logger: _logger, + ready: make(chan struct{}), + } +} + +func (c *usenet) Start(ctx context.Context) error { + // Init the client + if err := c.client.InitPools(); err != nil { + c.logger.Error().Err(err).Msg("Failed to initialize usenet client pools") + return fmt.Errorf("failed to initialize usenet client pools: %w", err) + } + // Initialize the store + if err := c.store.Load(); err != nil { + c.logger.Error().Err(err).Msg("Failed to initialize usenet store") + return fmt.Errorf("failed to initialize usenet store: %w", err) + } + close(c.ready) + c.logger.Info().Msg("Usenet client initialized") + return nil +} + +func (c *usenet) IsReady() chan struct{} { + return c.ready +} + +func (c *usenet) Store() Store { + return c.store +} + +func (c *usenet) Client() *nntp.Client { + return c.client +} + +func (c *usenet) Logger() zerolog.Logger { + return c.logger +} + +func (c *usenet) ProcessNZB(ctx context.Context, req *ProcessRequest) (*NZB, error) { + return c.processor.Process(ctx, req) +} + +// GetNZB retrieves an NZB by ID +func (c *usenet) GetNZB(nzoID string) *NZB { + return c.store.Get(nzoID) +} + +// DeleteNZB deletes an NZB +func (c *usenet) DeleteNZB(nzoID string) error { + return c.store.Delete(nzoID) +} + +// PauseNZB pauses an NZB download +func (c *usenet) PauseNZB(nzoID string) error { + return c.store.UpdateStatus(nzoID, "paused") +} + +// ResumeNZB resumes an NZB download +func (c *usenet) ResumeNZB(nzoID string) error { + return c.store.UpdateStatus(nzoID, "downloading") +} + +func (c *usenet) Close() { + if c.store != nil { + if err := c.store.Close(); err != nil { + c.logger.Error().Err(err).Msg("Failed to close store") + } + } + + c.logger.Info().Msg("Usenet client closed") +} + +// GetListing returns the file listing of the NZB directory +func (c *usenet) GetListing(folder string) []os.FileInfo { + return c.store.GetListing(folder) +} + +func (c *usenet) GetDownloadByteRange(nzoID string, filename string) (int64, int64, error) { + return int64(0), int64(0), nil +} + +func (c *usenet) RemoveNZB(nzoID string) error { + if err := c.store.Delete(nzoID); err != nil { + return fmt.Errorf("failed to delete NZB %s: %w", nzoID, err) + } + c.logger.Info().Msgf("NZB %s deleted successfully", nzoID) + return nil +} + +// Stream streams a file using the new simplified streaming system +func (c *usenet) Stream(ctx context.Context, nzbID string, filename string, start, end int64, writer io.Writer) error { + // Get NZB from store + nzb := c.GetNZB(nzbID) + if nzb == nil { + return fmt.Errorf("NZB %s not found", nzbID) + } + + // Get file + file := nzb.GetFileByName(filename) + if file == nil { + return fmt.Errorf("file %s not found in NZB %s", filename, nzbID) + } + if file.NzbID == "" { + file.NzbID = nzbID // Ensure NZB ID is set for the file + } + + // Stream using the new streamer + return c.streamer.Stream(ctx, file, start, end, writer) +} diff --git a/pkg/web/assets/build/css/styles.css b/pkg/web/assets/build/css/styles.css index 4834112..98e068e 100644 --- a/pkg/web/assets/build/css/styles.css +++ b/pkg/web/assets/build/css/styles.css @@ -4,4 +4,4 @@ * Licensed under MIT (https://github.com/twbs/icons/blob/main/LICENSE) */@font-face{font-display:block;font-family:bootstrap-icons;src:url(../fonts/bootstrap-icons.woff2?7141511ac37f13e1a387fb9fc6646256) format("woff2"),url(../fonts/bootstrap-icons.woff?7141511ac37f13e1a387fb9fc6646256) format("woff")}.bi:before,[class*=" bi-"]:before,[class^=bi-]:before{display:inline-block;font-family:bootstrap-icons!important;font-style:normal;font-weight:400!important;font-variant:normal;text-transform:none;line-height:1;vertical-align:-.125em;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.bi-123:before{content:"\f67f"}.bi-alarm-fill:before{content:"\f101"}.bi-alarm:before{content:"\f102"}.bi-align-bottom:before{content:"\f103"}.bi-align-center:before{content:"\f104"}.bi-align-end:before{content:"\f105"}.bi-align-middle:before{content:"\f106"}.bi-align-start:before{content:"\f107"}.bi-align-top:before{content:"\f108"}.bi-alt:before{content:"\f109"}.bi-app-indicator:before{content:"\f10a"}.bi-app:before{content:"\f10b"}.bi-archive-fill:before{content:"\f10c"}.bi-archive:before{content:"\f10d"}.bi-arrow-90deg-down:before{content:"\f10e"}.bi-arrow-90deg-left:before{content:"\f10f"}.bi-arrow-90deg-right:before{content:"\f110"}.bi-arrow-90deg-up:before{content:"\f111"}.bi-arrow-bar-down:before{content:"\f112"}.bi-arrow-bar-left:before{content:"\f113"}.bi-arrow-bar-right:before{content:"\f114"}.bi-arrow-bar-up:before{content:"\f115"}.bi-arrow-clockwise:before{content:"\f116"}.bi-arrow-counterclockwise:before{content:"\f117"}.bi-arrow-down-circle-fill:before{content:"\f118"}.bi-arrow-down-circle:before{content:"\f119"}.bi-arrow-down-left-circle-fill:before{content:"\f11a"}.bi-arrow-down-left-circle:before{content:"\f11b"}.bi-arrow-down-left-square-fill:before{content:"\f11c"}.bi-arrow-down-left-square:before{content:"\f11d"}.bi-arrow-down-left:before{content:"\f11e"}.bi-arrow-down-right-circle-fill:before{content:"\f11f"}.bi-arrow-down-right-circle:before{content:"\f120"}.bi-arrow-down-right-square-fill:before{content:"\f121"}.bi-arrow-down-right-square:before{content:"\f122"}.bi-arrow-down-right:before{content:"\f123"}.bi-arrow-down-short:before{content:"\f124"}.bi-arrow-down-square-fill:before{content:"\f125"}.bi-arrow-down-square:before{content:"\f126"}.bi-arrow-down-up:before{content:"\f127"}.bi-arrow-down:before{content:"\f128"}.bi-arrow-left-circle-fill:before{content:"\f129"}.bi-arrow-left-circle:before{content:"\f12a"}.bi-arrow-left-right:before{content:"\f12b"}.bi-arrow-left-short:before{content:"\f12c"}.bi-arrow-left-square-fill:before{content:"\f12d"}.bi-arrow-left-square:before{content:"\f12e"}.bi-arrow-left:before{content:"\f12f"}.bi-arrow-repeat:before{content:"\f130"}.bi-arrow-return-left:before{content:"\f131"}.bi-arrow-return-right:before{content:"\f132"}.bi-arrow-right-circle-fill:before{content:"\f133"}.bi-arrow-right-circle:before{content:"\f134"}.bi-arrow-right-short:before{content:"\f135"}.bi-arrow-right-square-fill:before{content:"\f136"}.bi-arrow-right-square:before{content:"\f137"}.bi-arrow-right:before{content:"\f138"}.bi-arrow-up-circle-fill:before{content:"\f139"}.bi-arrow-up-circle:before{content:"\f13a"}.bi-arrow-up-left-circle-fill:before{content:"\f13b"}.bi-arrow-up-left-circle:before{content:"\f13c"}.bi-arrow-up-left-square-fill:before{content:"\f13d"}.bi-arrow-up-left-square:before{content:"\f13e"}.bi-arrow-up-left:before{content:"\f13f"}.bi-arrow-up-right-circle-fill:before{content:"\f140"}.bi-arrow-up-right-circle:before{content:"\f141"}.bi-arrow-up-right-square-fill:before{content:"\f142"}.bi-arrow-up-right-square:before{content:"\f143"}.bi-arrow-up-right:before{content:"\f144"}.bi-arrow-up-short:before{content:"\f145"}.bi-arrow-up-square-fill:before{content:"\f146"}.bi-arrow-up-square:before{content:"\f147"}.bi-arrow-up:before{content:"\f148"}.bi-arrows-angle-contract:before{content:"\f149"}.bi-arrows-angle-expand:before{content:"\f14a"}.bi-arrows-collapse:before{content:"\f14b"}.bi-arrows-expand:before{content:"\f14c"}.bi-arrows-fullscreen:before{content:"\f14d"}.bi-arrows-move:before{content:"\f14e"}.bi-aspect-ratio-fill:before{content:"\f14f"}.bi-aspect-ratio:before{content:"\f150"}.bi-asterisk:before{content:"\f151"}.bi-at:before{content:"\f152"}.bi-award-fill:before{content:"\f153"}.bi-award:before{content:"\f154"}.bi-back:before{content:"\f155"}.bi-backspace-fill:before{content:"\f156"}.bi-backspace-reverse-fill:before{content:"\f157"}.bi-backspace-reverse:before{content:"\f158"}.bi-backspace:before{content:"\f159"}.bi-badge-3d-fill:before{content:"\f15a"}.bi-badge-3d:before{content:"\f15b"}.bi-badge-4k-fill:before{content:"\f15c"}.bi-badge-4k:before{content:"\f15d"}.bi-badge-8k-fill:before{content:"\f15e"}.bi-badge-8k:before{content:"\f15f"}.bi-badge-ad-fill:before{content:"\f160"}.bi-badge-ad:before{content:"\f161"}.bi-badge-ar-fill:before{content:"\f162"}.bi-badge-ar:before{content:"\f163"}.bi-badge-cc-fill:before{content:"\f164"}.bi-badge-cc:before{content:"\f165"}.bi-badge-hd-fill:before{content:"\f166"}.bi-badge-hd:before{content:"\f167"}.bi-badge-tm-fill:before{content:"\f168"}.bi-badge-tm:before{content:"\f169"}.bi-badge-vo-fill:before{content:"\f16a"}.bi-badge-vo:before{content:"\f16b"}.bi-badge-vr-fill:before{content:"\f16c"}.bi-badge-vr:before{content:"\f16d"}.bi-badge-wc-fill:before{content:"\f16e"}.bi-badge-wc:before{content:"\f16f"}.bi-bag-check-fill:before{content:"\f170"}.bi-bag-check:before{content:"\f171"}.bi-bag-dash-fill:before{content:"\f172"}.bi-bag-dash:before{content:"\f173"}.bi-bag-fill:before{content:"\f174"}.bi-bag-plus-fill:before{content:"\f175"}.bi-bag-plus:before{content:"\f176"}.bi-bag-x-fill:before{content:"\f177"}.bi-bag-x:before{content:"\f178"}.bi-bag:before{content:"\f179"}.bi-bar-chart-fill:before{content:"\f17a"}.bi-bar-chart-line-fill:before{content:"\f17b"}.bi-bar-chart-line:before{content:"\f17c"}.bi-bar-chart-steps:before{content:"\f17d"}.bi-bar-chart:before{content:"\f17e"}.bi-basket-fill:before{content:"\f17f"}.bi-basket:before{content:"\f180"}.bi-basket2-fill:before{content:"\f181"}.bi-basket2:before{content:"\f182"}.bi-basket3-fill:before{content:"\f183"}.bi-basket3:before{content:"\f184"}.bi-battery-charging:before{content:"\f185"}.bi-battery-full:before{content:"\f186"}.bi-battery-half:before{content:"\f187"}.bi-battery:before{content:"\f188"}.bi-bell-fill:before{content:"\f189"}.bi-bell:before{content:"\f18a"}.bi-bezier:before{content:"\f18b"}.bi-bezier2:before{content:"\f18c"}.bi-bicycle:before{content:"\f18d"}.bi-binoculars-fill:before{content:"\f18e"}.bi-binoculars:before{content:"\f18f"}.bi-blockquote-left:before{content:"\f190"}.bi-blockquote-right:before{content:"\f191"}.bi-book-fill:before{content:"\f192"}.bi-book-half:before{content:"\f193"}.bi-book:before{content:"\f194"}.bi-bookmark-check-fill:before{content:"\f195"}.bi-bookmark-check:before{content:"\f196"}.bi-bookmark-dash-fill:before{content:"\f197"}.bi-bookmark-dash:before{content:"\f198"}.bi-bookmark-fill:before{content:"\f199"}.bi-bookmark-heart-fill:before{content:"\f19a"}.bi-bookmark-heart:before{content:"\f19b"}.bi-bookmark-plus-fill:before{content:"\f19c"}.bi-bookmark-plus:before{content:"\f19d"}.bi-bookmark-star-fill:before{content:"\f19e"}.bi-bookmark-star:before{content:"\f19f"}.bi-bookmark-x-fill:before{content:"\f1a0"}.bi-bookmark-x:before{content:"\f1a1"}.bi-bookmark:before{content:"\f1a2"}.bi-bookmarks-fill:before{content:"\f1a3"}.bi-bookmarks:before{content:"\f1a4"}.bi-bookshelf:before{content:"\f1a5"}.bi-bootstrap-fill:before{content:"\f1a6"}.bi-bootstrap-reboot:before{content:"\f1a7"}.bi-bootstrap:before{content:"\f1a8"}.bi-border-all:before{content:"\f1a9"}.bi-border-bottom:before{content:"\f1aa"}.bi-border-center:before{content:"\f1ab"}.bi-border-inner:before{content:"\f1ac"}.bi-border-left:before{content:"\f1ad"}.bi-border-middle:before{content:"\f1ae"}.bi-border-outer:before{content:"\f1af"}.bi-border-right:before{content:"\f1b0"}.bi-border-style:before{content:"\f1b1"}.bi-border-top:before{content:"\f1b2"}.bi-border-width:before{content:"\f1b3"}.bi-border:before{content:"\f1b4"}.bi-bounding-box-circles:before{content:"\f1b5"}.bi-bounding-box:before{content:"\f1b6"}.bi-box-arrow-down-left:before{content:"\f1b7"}.bi-box-arrow-down-right:before{content:"\f1b8"}.bi-box-arrow-down:before{content:"\f1b9"}.bi-box-arrow-in-down-left:before{content:"\f1ba"}.bi-box-arrow-in-down-right:before{content:"\f1bb"}.bi-box-arrow-in-down:before{content:"\f1bc"}.bi-box-arrow-in-left:before{content:"\f1bd"}.bi-box-arrow-in-right:before{content:"\f1be"}.bi-box-arrow-in-up-left:before{content:"\f1bf"}.bi-box-arrow-in-up-right:before{content:"\f1c0"}.bi-box-arrow-in-up:before{content:"\f1c1"}.bi-box-arrow-left:before{content:"\f1c2"}.bi-box-arrow-right:before{content:"\f1c3"}.bi-box-arrow-up-left:before{content:"\f1c4"}.bi-box-arrow-up-right:before{content:"\f1c5"}.bi-box-arrow-up:before{content:"\f1c6"}.bi-box-seam:before{content:"\f1c7"}.bi-box:before{content:"\f1c8"}.bi-braces:before{content:"\f1c9"}.bi-bricks:before{content:"\f1ca"}.bi-briefcase-fill:before{content:"\f1cb"}.bi-briefcase:before{content:"\f1cc"}.bi-brightness-alt-high-fill:before{content:"\f1cd"}.bi-brightness-alt-high:before{content:"\f1ce"}.bi-brightness-alt-low-fill:before{content:"\f1cf"}.bi-brightness-alt-low:before{content:"\f1d0"}.bi-brightness-high-fill:before{content:"\f1d1"}.bi-brightness-high:before{content:"\f1d2"}.bi-brightness-low-fill:before{content:"\f1d3"}.bi-brightness-low:before{content:"\f1d4"}.bi-broadcast-pin:before{content:"\f1d5"}.bi-broadcast:before{content:"\f1d6"}.bi-brush-fill:before{content:"\f1d7"}.bi-brush:before{content:"\f1d8"}.bi-bucket-fill:before{content:"\f1d9"}.bi-bucket:before{content:"\f1da"}.bi-bug-fill:before{content:"\f1db"}.bi-bug:before{content:"\f1dc"}.bi-building:before{content:"\f1dd"}.bi-bullseye:before{content:"\f1de"}.bi-calculator-fill:before{content:"\f1df"}.bi-calculator:before{content:"\f1e0"}.bi-calendar-check-fill:before{content:"\f1e1"}.bi-calendar-check:before{content:"\f1e2"}.bi-calendar-date-fill:before{content:"\f1e3"}.bi-calendar-date:before{content:"\f1e4"}.bi-calendar-day-fill:before{content:"\f1e5"}.bi-calendar-day:before{content:"\f1e6"}.bi-calendar-event-fill:before{content:"\f1e7"}.bi-calendar-event:before{content:"\f1e8"}.bi-calendar-fill:before{content:"\f1e9"}.bi-calendar-minus-fill:before{content:"\f1ea"}.bi-calendar-minus:before{content:"\f1eb"}.bi-calendar-month-fill:before{content:"\f1ec"}.bi-calendar-month:before{content:"\f1ed"}.bi-calendar-plus-fill:before{content:"\f1ee"}.bi-calendar-plus:before{content:"\f1ef"}.bi-calendar-range-fill:before{content:"\f1f0"}.bi-calendar-range:before{content:"\f1f1"}.bi-calendar-week-fill:before{content:"\f1f2"}.bi-calendar-week:before{content:"\f1f3"}.bi-calendar-x-fill:before{content:"\f1f4"}.bi-calendar-x:before{content:"\f1f5"}.bi-calendar:before{content:"\f1f6"}.bi-calendar2-check-fill:before{content:"\f1f7"}.bi-calendar2-check:before{content:"\f1f8"}.bi-calendar2-date-fill:before{content:"\f1f9"}.bi-calendar2-date:before{content:"\f1fa"}.bi-calendar2-day-fill:before{content:"\f1fb"}.bi-calendar2-day:before{content:"\f1fc"}.bi-calendar2-event-fill:before{content:"\f1fd"}.bi-calendar2-event:before{content:"\f1fe"}.bi-calendar2-fill:before{content:"\f1ff"}.bi-calendar2-minus-fill:before{content:"\f200"}.bi-calendar2-minus:before{content:"\f201"}.bi-calendar2-month-fill:before{content:"\f202"}.bi-calendar2-month:before{content:"\f203"}.bi-calendar2-plus-fill:before{content:"\f204"}.bi-calendar2-plus:before{content:"\f205"}.bi-calendar2-range-fill:before{content:"\f206"}.bi-calendar2-range:before{content:"\f207"}.bi-calendar2-week-fill:before{content:"\f208"}.bi-calendar2-week:before{content:"\f209"}.bi-calendar2-x-fill:before{content:"\f20a"}.bi-calendar2-x:before{content:"\f20b"}.bi-calendar2:before{content:"\f20c"}.bi-calendar3-event-fill:before{content:"\f20d"}.bi-calendar3-event:before{content:"\f20e"}.bi-calendar3-fill:before{content:"\f20f"}.bi-calendar3-range-fill:before{content:"\f210"}.bi-calendar3-range:before{content:"\f211"}.bi-calendar3-week-fill:before{content:"\f212"}.bi-calendar3-week:before{content:"\f213"}.bi-calendar3:before{content:"\f214"}.bi-calendar4-event:before{content:"\f215"}.bi-calendar4-range:before{content:"\f216"}.bi-calendar4-week:before{content:"\f217"}.bi-calendar4:before{content:"\f218"}.bi-camera-fill:before{content:"\f219"}.bi-camera-reels-fill:before{content:"\f21a"}.bi-camera-reels:before{content:"\f21b"}.bi-camera-video-fill:before{content:"\f21c"}.bi-camera-video-off-fill:before{content:"\f21d"}.bi-camera-video-off:before{content:"\f21e"}.bi-camera-video:before{content:"\f21f"}.bi-camera:before{content:"\f220"}.bi-camera2:before{content:"\f221"}.bi-capslock-fill:before{content:"\f222"}.bi-capslock:before{content:"\f223"}.bi-card-checklist:before{content:"\f224"}.bi-card-heading:before{content:"\f225"}.bi-card-image:before{content:"\f226"}.bi-card-list:before{content:"\f227"}.bi-card-text:before{content:"\f228"}.bi-caret-down-fill:before{content:"\f229"}.bi-caret-down-square-fill:before{content:"\f22a"}.bi-caret-down-square:before{content:"\f22b"}.bi-caret-down:before{content:"\f22c"}.bi-caret-left-fill:before{content:"\f22d"}.bi-caret-left-square-fill:before{content:"\f22e"}.bi-caret-left-square:before{content:"\f22f"}.bi-caret-left:before{content:"\f230"}.bi-caret-right-fill:before{content:"\f231"}.bi-caret-right-square-fill:before{content:"\f232"}.bi-caret-right-square:before{content:"\f233"}.bi-caret-right:before{content:"\f234"}.bi-caret-up-fill:before{content:"\f235"}.bi-caret-up-square-fill:before{content:"\f236"}.bi-caret-up-square:before{content:"\f237"}.bi-caret-up:before{content:"\f238"}.bi-cart-check-fill:before{content:"\f239"}.bi-cart-check:before{content:"\f23a"}.bi-cart-dash-fill:before{content:"\f23b"}.bi-cart-dash:before{content:"\f23c"}.bi-cart-fill:before{content:"\f23d"}.bi-cart-plus-fill:before{content:"\f23e"}.bi-cart-plus:before{content:"\f23f"}.bi-cart-x-fill:before{content:"\f240"}.bi-cart-x:before{content:"\f241"}.bi-cart:before{content:"\f242"}.bi-cart2:before{content:"\f243"}.bi-cart3:before{content:"\f244"}.bi-cart4:before{content:"\f245"}.bi-cash-stack:before{content:"\f246"}.bi-cash:before{content:"\f247"}.bi-cast:before{content:"\f248"}.bi-chat-dots-fill:before{content:"\f249"}.bi-chat-dots:before{content:"\f24a"}.bi-chat-fill:before{content:"\f24b"}.bi-chat-left-dots-fill:before{content:"\f24c"}.bi-chat-left-dots:before{content:"\f24d"}.bi-chat-left-fill:before{content:"\f24e"}.bi-chat-left-quote-fill:before{content:"\f24f"}.bi-chat-left-quote:before{content:"\f250"}.bi-chat-left-text-fill:before{content:"\f251"}.bi-chat-left-text:before{content:"\f252"}.bi-chat-left:before{content:"\f253"}.bi-chat-quote-fill:before{content:"\f254"}.bi-chat-quote:before{content:"\f255"}.bi-chat-right-dots-fill:before{content:"\f256"}.bi-chat-right-dots:before{content:"\f257"}.bi-chat-right-fill:before{content:"\f258"}.bi-chat-right-quote-fill:before{content:"\f259"}.bi-chat-right-quote:before{content:"\f25a"}.bi-chat-right-text-fill:before{content:"\f25b"}.bi-chat-right-text:before{content:"\f25c"}.bi-chat-right:before{content:"\f25d"}.bi-chat-square-dots-fill:before{content:"\f25e"}.bi-chat-square-dots:before{content:"\f25f"}.bi-chat-square-fill:before{content:"\f260"}.bi-chat-square-quote-fill:before{content:"\f261"}.bi-chat-square-quote:before{content:"\f262"}.bi-chat-square-text-fill:before{content:"\f263"}.bi-chat-square-text:before{content:"\f264"}.bi-chat-square:before{content:"\f265"}.bi-chat-text-fill:before{content:"\f266"}.bi-chat-text:before{content:"\f267"}.bi-chat:before{content:"\f268"}.bi-check-all:before{content:"\f269"}.bi-check-circle-fill:before{content:"\f26a"}.bi-check-circle:before{content:"\f26b"}.bi-check-square-fill:before{content:"\f26c"}.bi-check-square:before{content:"\f26d"}.bi-check:before{content:"\f26e"}.bi-check2-all:before{content:"\f26f"}.bi-check2-circle:before{content:"\f270"}.bi-check2-square:before{content:"\f271"}.bi-check2:before{content:"\f272"}.bi-chevron-bar-contract:before{content:"\f273"}.bi-chevron-bar-down:before{content:"\f274"}.bi-chevron-bar-expand:before{content:"\f275"}.bi-chevron-bar-left:before{content:"\f276"}.bi-chevron-bar-right:before{content:"\f277"}.bi-chevron-bar-up:before{content:"\f278"}.bi-chevron-compact-down:before{content:"\f279"}.bi-chevron-compact-left:before{content:"\f27a"}.bi-chevron-compact-right:before{content:"\f27b"}.bi-chevron-compact-up:before{content:"\f27c"}.bi-chevron-contract:before{content:"\f27d"}.bi-chevron-double-down:before{content:"\f27e"}.bi-chevron-double-left:before{content:"\f27f"}.bi-chevron-double-right:before{content:"\f280"}.bi-chevron-double-up:before{content:"\f281"}.bi-chevron-down:before{content:"\f282"}.bi-chevron-expand:before{content:"\f283"}.bi-chevron-left:before{content:"\f284"}.bi-chevron-right:before{content:"\f285"}.bi-chevron-up:before{content:"\f286"}.bi-circle-fill:before{content:"\f287"}.bi-circle-half:before{content:"\f288"}.bi-circle-square:before{content:"\f289"}.bi-circle:before{content:"\f28a"}.bi-clipboard-check:before{content:"\f28b"}.bi-clipboard-data:before{content:"\f28c"}.bi-clipboard-minus:before{content:"\f28d"}.bi-clipboard-plus:before{content:"\f28e"}.bi-clipboard-x:before{content:"\f28f"}.bi-clipboard:before{content:"\f290"}.bi-clock-fill:before{content:"\f291"}.bi-clock-history:before{content:"\f292"}.bi-clock:before{content:"\f293"}.bi-cloud-arrow-down-fill:before{content:"\f294"}.bi-cloud-arrow-down:before{content:"\f295"}.bi-cloud-arrow-up-fill:before{content:"\f296"}.bi-cloud-arrow-up:before{content:"\f297"}.bi-cloud-check-fill:before{content:"\f298"}.bi-cloud-check:before{content:"\f299"}.bi-cloud-download-fill:before{content:"\f29a"}.bi-cloud-download:before{content:"\f29b"}.bi-cloud-drizzle-fill:before{content:"\f29c"}.bi-cloud-drizzle:before{content:"\f29d"}.bi-cloud-fill:before{content:"\f29e"}.bi-cloud-fog-fill:before{content:"\f29f"}.bi-cloud-fog:before{content:"\f2a0"}.bi-cloud-fog2-fill:before{content:"\f2a1"}.bi-cloud-fog2:before{content:"\f2a2"}.bi-cloud-hail-fill:before{content:"\f2a3"}.bi-cloud-hail:before{content:"\f2a4"}.bi-cloud-haze-fill:before{content:"\f2a6"}.bi-cloud-haze:before{content:"\f2a7"}.bi-cloud-haze2-fill:before{content:"\f2a8"}.bi-cloud-lightning-fill:before{content:"\f2a9"}.bi-cloud-lightning-rain-fill:before{content:"\f2aa"}.bi-cloud-lightning-rain:before{content:"\f2ab"}.bi-cloud-lightning:before{content:"\f2ac"}.bi-cloud-minus-fill:before{content:"\f2ad"}.bi-cloud-minus:before{content:"\f2ae"}.bi-cloud-moon-fill:before{content:"\f2af"}.bi-cloud-moon:before{content:"\f2b0"}.bi-cloud-plus-fill:before{content:"\f2b1"}.bi-cloud-plus:before{content:"\f2b2"}.bi-cloud-rain-fill:before{content:"\f2b3"}.bi-cloud-rain-heavy-fill:before{content:"\f2b4"}.bi-cloud-rain-heavy:before{content:"\f2b5"}.bi-cloud-rain:before{content:"\f2b6"}.bi-cloud-slash-fill:before{content:"\f2b7"}.bi-cloud-slash:before{content:"\f2b8"}.bi-cloud-sleet-fill:before{content:"\f2b9"}.bi-cloud-sleet:before{content:"\f2ba"}.bi-cloud-snow-fill:before{content:"\f2bb"}.bi-cloud-snow:before{content:"\f2bc"}.bi-cloud-sun-fill:before{content:"\f2bd"}.bi-cloud-sun:before{content:"\f2be"}.bi-cloud-upload-fill:before{content:"\f2bf"}.bi-cloud-upload:before{content:"\f2c0"}.bi-cloud:before{content:"\f2c1"}.bi-clouds-fill:before{content:"\f2c2"}.bi-clouds:before{content:"\f2c3"}.bi-cloudy-fill:before{content:"\f2c4"}.bi-cloudy:before{content:"\f2c5"}.bi-code-slash:before{content:"\f2c6"}.bi-code-square:before{content:"\f2c7"}.bi-code:before{content:"\f2c8"}.bi-collection-fill:before{content:"\f2c9"}.bi-collection-play-fill:before{content:"\f2ca"}.bi-collection-play:before{content:"\f2cb"}.bi-collection:before{content:"\f2cc"}.bi-columns-gap:before{content:"\f2cd"}.bi-columns:before{content:"\f2ce"}.bi-command:before{content:"\f2cf"}.bi-compass-fill:before{content:"\f2d0"}.bi-compass:before{content:"\f2d1"}.bi-cone-striped:before{content:"\f2d2"}.bi-cone:before{content:"\f2d3"}.bi-controller:before{content:"\f2d4"}.bi-cpu-fill:before{content:"\f2d5"}.bi-cpu:before{content:"\f2d6"}.bi-credit-card-2-back-fill:before{content:"\f2d7"}.bi-credit-card-2-back:before{content:"\f2d8"}.bi-credit-card-2-front-fill:before{content:"\f2d9"}.bi-credit-card-2-front:before{content:"\f2da"}.bi-credit-card-fill:before{content:"\f2db"}.bi-credit-card:before{content:"\f2dc"}.bi-crop:before{content:"\f2dd"}.bi-cup-fill:before{content:"\f2de"}.bi-cup-straw:before{content:"\f2df"}.bi-cup:before{content:"\f2e0"}.bi-cursor-fill:before{content:"\f2e1"}.bi-cursor-text:before{content:"\f2e2"}.bi-cursor:before{content:"\f2e3"}.bi-dash-circle-dotted:before{content:"\f2e4"}.bi-dash-circle-fill:before{content:"\f2e5"}.bi-dash-circle:before{content:"\f2e6"}.bi-dash-square-dotted:before{content:"\f2e7"}.bi-dash-square-fill:before{content:"\f2e8"}.bi-dash-square:before{content:"\f2e9"}.bi-dash:before{content:"\f2ea"}.bi-diagram-2-fill:before{content:"\f2eb"}.bi-diagram-2:before{content:"\f2ec"}.bi-diagram-3-fill:before{content:"\f2ed"}.bi-diagram-3:before{content:"\f2ee"}.bi-diamond-fill:before{content:"\f2ef"}.bi-diamond-half:before{content:"\f2f0"}.bi-diamond:before{content:"\f2f1"}.bi-dice-1-fill:before{content:"\f2f2"}.bi-dice-1:before{content:"\f2f3"}.bi-dice-2-fill:before{content:"\f2f4"}.bi-dice-2:before{content:"\f2f5"}.bi-dice-3-fill:before{content:"\f2f6"}.bi-dice-3:before{content:"\f2f7"}.bi-dice-4-fill:before{content:"\f2f8"}.bi-dice-4:before{content:"\f2f9"}.bi-dice-5-fill:before{content:"\f2fa"}.bi-dice-5:before{content:"\f2fb"}.bi-dice-6-fill:before{content:"\f2fc"}.bi-dice-6:before{content:"\f2fd"}.bi-disc-fill:before{content:"\f2fe"}.bi-disc:before{content:"\f2ff"}.bi-discord:before{content:"\f300"}.bi-display-fill:before{content:"\f301"}.bi-display:before{content:"\f302"}.bi-distribute-horizontal:before{content:"\f303"}.bi-distribute-vertical:before{content:"\f304"}.bi-door-closed-fill:before{content:"\f305"}.bi-door-closed:before{content:"\f306"}.bi-door-open-fill:before{content:"\f307"}.bi-door-open:before{content:"\f308"}.bi-dot:before{content:"\f309"}.bi-download:before{content:"\f30a"}.bi-droplet-fill:before{content:"\f30b"}.bi-droplet-half:before{content:"\f30c"}.bi-droplet:before{content:"\f30d"}.bi-earbuds:before{content:"\f30e"}.bi-easel-fill:before{content:"\f30f"}.bi-easel:before{content:"\f310"}.bi-egg-fill:before{content:"\f311"}.bi-egg-fried:before{content:"\f312"}.bi-egg:before{content:"\f313"}.bi-eject-fill:before{content:"\f314"}.bi-eject:before{content:"\f315"}.bi-emoji-angry-fill:before{content:"\f316"}.bi-emoji-angry:before{content:"\f317"}.bi-emoji-dizzy-fill:before{content:"\f318"}.bi-emoji-dizzy:before{content:"\f319"}.bi-emoji-expressionless-fill:before{content:"\f31a"}.bi-emoji-expressionless:before{content:"\f31b"}.bi-emoji-frown-fill:before{content:"\f31c"}.bi-emoji-frown:before{content:"\f31d"}.bi-emoji-heart-eyes-fill:before{content:"\f31e"}.bi-emoji-heart-eyes:before{content:"\f31f"}.bi-emoji-laughing-fill:before{content:"\f320"}.bi-emoji-laughing:before{content:"\f321"}.bi-emoji-neutral-fill:before{content:"\f322"}.bi-emoji-neutral:before{content:"\f323"}.bi-emoji-smile-fill:before{content:"\f324"}.bi-emoji-smile-upside-down-fill:before{content:"\f325"}.bi-emoji-smile-upside-down:before{content:"\f326"}.bi-emoji-smile:before{content:"\f327"}.bi-emoji-sunglasses-fill:before{content:"\f328"}.bi-emoji-sunglasses:before{content:"\f329"}.bi-emoji-wink-fill:before{content:"\f32a"}.bi-emoji-wink:before{content:"\f32b"}.bi-envelope-fill:before{content:"\f32c"}.bi-envelope-open-fill:before{content:"\f32d"}.bi-envelope-open:before{content:"\f32e"}.bi-envelope:before{content:"\f32f"}.bi-eraser-fill:before{content:"\f330"}.bi-eraser:before{content:"\f331"}.bi-exclamation-circle-fill:before{content:"\f332"}.bi-exclamation-circle:before{content:"\f333"}.bi-exclamation-diamond-fill:before{content:"\f334"}.bi-exclamation-diamond:before{content:"\f335"}.bi-exclamation-octagon-fill:before{content:"\f336"}.bi-exclamation-octagon:before{content:"\f337"}.bi-exclamation-square-fill:before{content:"\f338"}.bi-exclamation-square:before{content:"\f339"}.bi-exclamation-triangle-fill:before{content:"\f33a"}.bi-exclamation-triangle:before{content:"\f33b"}.bi-exclamation:before{content:"\f33c"}.bi-exclude:before{content:"\f33d"}.bi-eye-fill:before{content:"\f33e"}.bi-eye-slash-fill:before{content:"\f33f"}.bi-eye-slash:before{content:"\f340"}.bi-eye:before{content:"\f341"}.bi-eyedropper:before{content:"\f342"}.bi-eyeglasses:before{content:"\f343"}.bi-facebook:before{content:"\f344"}.bi-file-arrow-down-fill:before{content:"\f345"}.bi-file-arrow-down:before{content:"\f346"}.bi-file-arrow-up-fill:before{content:"\f347"}.bi-file-arrow-up:before{content:"\f348"}.bi-file-bar-graph-fill:before{content:"\f349"}.bi-file-bar-graph:before{content:"\f34a"}.bi-file-binary-fill:before{content:"\f34b"}.bi-file-binary:before{content:"\f34c"}.bi-file-break-fill:before{content:"\f34d"}.bi-file-break:before{content:"\f34e"}.bi-file-check-fill:before{content:"\f34f"}.bi-file-check:before{content:"\f350"}.bi-file-code-fill:before{content:"\f351"}.bi-file-code:before{content:"\f352"}.bi-file-diff-fill:before{content:"\f353"}.bi-file-diff:before{content:"\f354"}.bi-file-earmark-arrow-down-fill:before{content:"\f355"}.bi-file-earmark-arrow-down:before{content:"\f356"}.bi-file-earmark-arrow-up-fill:before{content:"\f357"}.bi-file-earmark-arrow-up:before{content:"\f358"}.bi-file-earmark-bar-graph-fill:before{content:"\f359"}.bi-file-earmark-bar-graph:before{content:"\f35a"}.bi-file-earmark-binary-fill:before{content:"\f35b"}.bi-file-earmark-binary:before{content:"\f35c"}.bi-file-earmark-break-fill:before{content:"\f35d"}.bi-file-earmark-break:before{content:"\f35e"}.bi-file-earmark-check-fill:before{content:"\f35f"}.bi-file-earmark-check:before{content:"\f360"}.bi-file-earmark-code-fill:before{content:"\f361"}.bi-file-earmark-code:before{content:"\f362"}.bi-file-earmark-diff-fill:before{content:"\f363"}.bi-file-earmark-diff:before{content:"\f364"}.bi-file-earmark-easel-fill:before{content:"\f365"}.bi-file-earmark-easel:before{content:"\f366"}.bi-file-earmark-excel-fill:before{content:"\f367"}.bi-file-earmark-excel:before{content:"\f368"}.bi-file-earmark-fill:before{content:"\f369"}.bi-file-earmark-font-fill:before{content:"\f36a"}.bi-file-earmark-font:before{content:"\f36b"}.bi-file-earmark-image-fill:before{content:"\f36c"}.bi-file-earmark-image:before{content:"\f36d"}.bi-file-earmark-lock-fill:before{content:"\f36e"}.bi-file-earmark-lock:before{content:"\f36f"}.bi-file-earmark-lock2-fill:before{content:"\f370"}.bi-file-earmark-lock2:before{content:"\f371"}.bi-file-earmark-medical-fill:before{content:"\f372"}.bi-file-earmark-medical:before{content:"\f373"}.bi-file-earmark-minus-fill:before{content:"\f374"}.bi-file-earmark-minus:before{content:"\f375"}.bi-file-earmark-music-fill:before{content:"\f376"}.bi-file-earmark-music:before{content:"\f377"}.bi-file-earmark-person-fill:before{content:"\f378"}.bi-file-earmark-person:before{content:"\f379"}.bi-file-earmark-play-fill:before{content:"\f37a"}.bi-file-earmark-play:before{content:"\f37b"}.bi-file-earmark-plus-fill:before{content:"\f37c"}.bi-file-earmark-plus:before{content:"\f37d"}.bi-file-earmark-post-fill:before{content:"\f37e"}.bi-file-earmark-post:before{content:"\f37f"}.bi-file-earmark-ppt-fill:before{content:"\f380"}.bi-file-earmark-ppt:before{content:"\f381"}.bi-file-earmark-richtext-fill:before{content:"\f382"}.bi-file-earmark-richtext:before{content:"\f383"}.bi-file-earmark-ruled-fill:before{content:"\f384"}.bi-file-earmark-ruled:before{content:"\f385"}.bi-file-earmark-slides-fill:before{content:"\f386"}.bi-file-earmark-slides:before{content:"\f387"}.bi-file-earmark-spreadsheet-fill:before{content:"\f388"}.bi-file-earmark-spreadsheet:before{content:"\f389"}.bi-file-earmark-text-fill:before{content:"\f38a"}.bi-file-earmark-text:before{content:"\f38b"}.bi-file-earmark-word-fill:before{content:"\f38c"}.bi-file-earmark-word:before{content:"\f38d"}.bi-file-earmark-x-fill:before{content:"\f38e"}.bi-file-earmark-x:before{content:"\f38f"}.bi-file-earmark-zip-fill:before{content:"\f390"}.bi-file-earmark-zip:before{content:"\f391"}.bi-file-earmark:before{content:"\f392"}.bi-file-easel-fill:before{content:"\f393"}.bi-file-easel:before{content:"\f394"}.bi-file-excel-fill:before{content:"\f395"}.bi-file-excel:before{content:"\f396"}.bi-file-fill:before{content:"\f397"}.bi-file-font-fill:before{content:"\f398"}.bi-file-font:before{content:"\f399"}.bi-file-image-fill:before{content:"\f39a"}.bi-file-image:before{content:"\f39b"}.bi-file-lock-fill:before{content:"\f39c"}.bi-file-lock:before{content:"\f39d"}.bi-file-lock2-fill:before{content:"\f39e"}.bi-file-lock2:before{content:"\f39f"}.bi-file-medical-fill:before{content:"\f3a0"}.bi-file-medical:before{content:"\f3a1"}.bi-file-minus-fill:before{content:"\f3a2"}.bi-file-minus:before{content:"\f3a3"}.bi-file-music-fill:before{content:"\f3a4"}.bi-file-music:before{content:"\f3a5"}.bi-file-person-fill:before{content:"\f3a6"}.bi-file-person:before{content:"\f3a7"}.bi-file-play-fill:before{content:"\f3a8"}.bi-file-play:before{content:"\f3a9"}.bi-file-plus-fill:before{content:"\f3aa"}.bi-file-plus:before{content:"\f3ab"}.bi-file-post-fill:before{content:"\f3ac"}.bi-file-post:before{content:"\f3ad"}.bi-file-ppt-fill:before{content:"\f3ae"}.bi-file-ppt:before{content:"\f3af"}.bi-file-richtext-fill:before{content:"\f3b0"}.bi-file-richtext:before{content:"\f3b1"}.bi-file-ruled-fill:before{content:"\f3b2"}.bi-file-ruled:before{content:"\f3b3"}.bi-file-slides-fill:before{content:"\f3b4"}.bi-file-slides:before{content:"\f3b5"}.bi-file-spreadsheet-fill:before{content:"\f3b6"}.bi-file-spreadsheet:before{content:"\f3b7"}.bi-file-text-fill:before{content:"\f3b8"}.bi-file-text:before{content:"\f3b9"}.bi-file-word-fill:before{content:"\f3ba"}.bi-file-word:before{content:"\f3bb"}.bi-file-x-fill:before{content:"\f3bc"}.bi-file-x:before{content:"\f3bd"}.bi-file-zip-fill:before{content:"\f3be"}.bi-file-zip:before{content:"\f3bf"}.bi-file:before{content:"\f3c0"}.bi-files-alt:before{content:"\f3c1"}.bi-files:before{content:"\f3c2"}.bi-film:before{content:"\f3c3"}.bi-filter-circle-fill:before{content:"\f3c4"}.bi-filter-circle:before{content:"\f3c5"}.bi-filter-left:before{content:"\f3c6"}.bi-filter-right:before{content:"\f3c7"}.bi-filter-square-fill:before{content:"\f3c8"}.bi-filter-square:before{content:"\f3c9"}.bi-filter:before{content:"\f3ca"}.bi-flag-fill:before{content:"\f3cb"}.bi-flag:before{content:"\f3cc"}.bi-flower1:before{content:"\f3cd"}.bi-flower2:before{content:"\f3ce"}.bi-flower3:before{content:"\f3cf"}.bi-folder-check:before{content:"\f3d0"}.bi-folder-fill:before{content:"\f3d1"}.bi-folder-minus:before{content:"\f3d2"}.bi-folder-plus:before{content:"\f3d3"}.bi-folder-symlink-fill:before{content:"\f3d4"}.bi-folder-symlink:before{content:"\f3d5"}.bi-folder-x:before{content:"\f3d6"}.bi-folder:before{content:"\f3d7"}.bi-folder2-open:before{content:"\f3d8"}.bi-folder2:before{content:"\f3d9"}.bi-fonts:before{content:"\f3da"}.bi-forward-fill:before{content:"\f3db"}.bi-forward:before{content:"\f3dc"}.bi-front:before{content:"\f3dd"}.bi-fullscreen-exit:before{content:"\f3de"}.bi-fullscreen:before{content:"\f3df"}.bi-funnel-fill:before{content:"\f3e0"}.bi-funnel:before{content:"\f3e1"}.bi-gear-fill:before{content:"\f3e2"}.bi-gear-wide-connected:before{content:"\f3e3"}.bi-gear-wide:before{content:"\f3e4"}.bi-gear:before{content:"\f3e5"}.bi-gem:before{content:"\f3e6"}.bi-geo-alt-fill:before{content:"\f3e7"}.bi-geo-alt:before{content:"\f3e8"}.bi-geo-fill:before{content:"\f3e9"}.bi-geo:before{content:"\f3ea"}.bi-gift-fill:before{content:"\f3eb"}.bi-gift:before{content:"\f3ec"}.bi-github:before{content:"\f3ed"}.bi-globe:before{content:"\f3ee"}.bi-globe2:before{content:"\f3ef"}.bi-google:before{content:"\f3f0"}.bi-graph-down:before{content:"\f3f1"}.bi-graph-up:before{content:"\f3f2"}.bi-grid-1x2-fill:before{content:"\f3f3"}.bi-grid-1x2:before{content:"\f3f4"}.bi-grid-3x2-gap-fill:before{content:"\f3f5"}.bi-grid-3x2-gap:before{content:"\f3f6"}.bi-grid-3x2:before{content:"\f3f7"}.bi-grid-3x3-gap-fill:before{content:"\f3f8"}.bi-grid-3x3-gap:before{content:"\f3f9"}.bi-grid-3x3:before{content:"\f3fa"}.bi-grid-fill:before{content:"\f3fb"}.bi-grid:before{content:"\f3fc"}.bi-grip-horizontal:before{content:"\f3fd"}.bi-grip-vertical:before{content:"\f3fe"}.bi-hammer:before{content:"\f3ff"}.bi-hand-index-fill:before{content:"\f400"}.bi-hand-index-thumb-fill:before{content:"\f401"}.bi-hand-index-thumb:before{content:"\f402"}.bi-hand-index:before{content:"\f403"}.bi-hand-thumbs-down-fill:before{content:"\f404"}.bi-hand-thumbs-down:before{content:"\f405"}.bi-hand-thumbs-up-fill:before{content:"\f406"}.bi-hand-thumbs-up:before{content:"\f407"}.bi-handbag-fill:before{content:"\f408"}.bi-handbag:before{content:"\f409"}.bi-hash:before{content:"\f40a"}.bi-hdd-fill:before{content:"\f40b"}.bi-hdd-network-fill:before{content:"\f40c"}.bi-hdd-network:before{content:"\f40d"}.bi-hdd-rack-fill:before{content:"\f40e"}.bi-hdd-rack:before{content:"\f40f"}.bi-hdd-stack-fill:before{content:"\f410"}.bi-hdd-stack:before{content:"\f411"}.bi-hdd:before{content:"\f412"}.bi-headphones:before{content:"\f413"}.bi-headset:before{content:"\f414"}.bi-heart-fill:before{content:"\f415"}.bi-heart-half:before{content:"\f416"}.bi-heart:before{content:"\f417"}.bi-heptagon-fill:before{content:"\f418"}.bi-heptagon-half:before{content:"\f419"}.bi-heptagon:before{content:"\f41a"}.bi-hexagon-fill:before{content:"\f41b"}.bi-hexagon-half:before{content:"\f41c"}.bi-hexagon:before{content:"\f41d"}.bi-hourglass-bottom:before{content:"\f41e"}.bi-hourglass-split:before{content:"\f41f"}.bi-hourglass-top:before{content:"\f420"}.bi-hourglass:before{content:"\f421"}.bi-house-door-fill:before{content:"\f422"}.bi-house-door:before{content:"\f423"}.bi-house-fill:before{content:"\f424"}.bi-house:before{content:"\f425"}.bi-hr:before{content:"\f426"}.bi-hurricane:before{content:"\f427"}.bi-image-alt:before{content:"\f428"}.bi-image-fill:before{content:"\f429"}.bi-image:before{content:"\f42a"}.bi-images:before{content:"\f42b"}.bi-inbox-fill:before{content:"\f42c"}.bi-inbox:before{content:"\f42d"}.bi-inboxes-fill:before{content:"\f42e"}.bi-inboxes:before{content:"\f42f"}.bi-info-circle-fill:before{content:"\f430"}.bi-info-circle:before{content:"\f431"}.bi-info-square-fill:before{content:"\f432"}.bi-info-square:before{content:"\f433"}.bi-info:before{content:"\f434"}.bi-input-cursor-text:before{content:"\f435"}.bi-input-cursor:before{content:"\f436"}.bi-instagram:before{content:"\f437"}.bi-intersect:before{content:"\f438"}.bi-journal-album:before{content:"\f439"}.bi-journal-arrow-down:before{content:"\f43a"}.bi-journal-arrow-up:before{content:"\f43b"}.bi-journal-bookmark-fill:before{content:"\f43c"}.bi-journal-bookmark:before{content:"\f43d"}.bi-journal-check:before{content:"\f43e"}.bi-journal-code:before{content:"\f43f"}.bi-journal-medical:before{content:"\f440"}.bi-journal-minus:before{content:"\f441"}.bi-journal-plus:before{content:"\f442"}.bi-journal-richtext:before{content:"\f443"}.bi-journal-text:before{content:"\f444"}.bi-journal-x:before{content:"\f445"}.bi-journal:before{content:"\f446"}.bi-journals:before{content:"\f447"}.bi-joystick:before{content:"\f448"}.bi-justify-left:before{content:"\f449"}.bi-justify-right:before{content:"\f44a"}.bi-justify:before{content:"\f44b"}.bi-kanban-fill:before{content:"\f44c"}.bi-kanban:before{content:"\f44d"}.bi-key-fill:before{content:"\f44e"}.bi-key:before{content:"\f44f"}.bi-keyboard-fill:before{content:"\f450"}.bi-keyboard:before{content:"\f451"}.bi-ladder:before{content:"\f452"}.bi-lamp-fill:before{content:"\f453"}.bi-lamp:before{content:"\f454"}.bi-laptop-fill:before{content:"\f455"}.bi-laptop:before{content:"\f456"}.bi-layer-backward:before{content:"\f457"}.bi-layer-forward:before{content:"\f458"}.bi-layers-fill:before{content:"\f459"}.bi-layers-half:before{content:"\f45a"}.bi-layers:before{content:"\f45b"}.bi-layout-sidebar-inset-reverse:before{content:"\f45c"}.bi-layout-sidebar-inset:before{content:"\f45d"}.bi-layout-sidebar-reverse:before{content:"\f45e"}.bi-layout-sidebar:before{content:"\f45f"}.bi-layout-split:before{content:"\f460"}.bi-layout-text-sidebar-reverse:before{content:"\f461"}.bi-layout-text-sidebar:before{content:"\f462"}.bi-layout-text-window-reverse:before{content:"\f463"}.bi-layout-text-window:before{content:"\f464"}.bi-layout-three-columns:before{content:"\f465"}.bi-layout-wtf:before{content:"\f466"}.bi-life-preserver:before{content:"\f467"}.bi-lightbulb-fill:before{content:"\f468"}.bi-lightbulb-off-fill:before{content:"\f469"}.bi-lightbulb-off:before{content:"\f46a"}.bi-lightbulb:before{content:"\f46b"}.bi-lightning-charge-fill:before{content:"\f46c"}.bi-lightning-charge:before{content:"\f46d"}.bi-lightning-fill:before{content:"\f46e"}.bi-lightning:before{content:"\f46f"}.bi-link-45deg:before{content:"\f470"}.bi-link:before{content:"\f471"}.bi-linkedin:before{content:"\f472"}.bi-list-check:before{content:"\f473"}.bi-list-nested:before{content:"\f474"}.bi-list-ol:before{content:"\f475"}.bi-list-stars:before{content:"\f476"}.bi-list-task:before{content:"\f477"}.bi-list-ul:before{content:"\f478"}.bi-list:before{content:"\f479"}.bi-lock-fill:before{content:"\f47a"}.bi-lock:before{content:"\f47b"}.bi-mailbox:before{content:"\f47c"}.bi-mailbox2:before{content:"\f47d"}.bi-map-fill:before{content:"\f47e"}.bi-map:before{content:"\f47f"}.bi-markdown-fill:before{content:"\f480"}.bi-markdown:before{content:"\f481"}.bi-mask:before{content:"\f482"}.bi-megaphone-fill:before{content:"\f483"}.bi-megaphone:before{content:"\f484"}.bi-menu-app-fill:before{content:"\f485"}.bi-menu-app:before{content:"\f486"}.bi-menu-button-fill:before{content:"\f487"}.bi-menu-button-wide-fill:before{content:"\f488"}.bi-menu-button-wide:before{content:"\f489"}.bi-menu-button:before{content:"\f48a"}.bi-menu-down:before{content:"\f48b"}.bi-menu-up:before{content:"\f48c"}.bi-mic-fill:before{content:"\f48d"}.bi-mic-mute-fill:before{content:"\f48e"}.bi-mic-mute:before{content:"\f48f"}.bi-mic:before{content:"\f490"}.bi-minecart-loaded:before{content:"\f491"}.bi-minecart:before{content:"\f492"}.bi-moisture:before{content:"\f493"}.bi-moon-fill:before{content:"\f494"}.bi-moon-stars-fill:before{content:"\f495"}.bi-moon-stars:before{content:"\f496"}.bi-moon:before{content:"\f497"}.bi-mouse-fill:before{content:"\f498"}.bi-mouse:before{content:"\f499"}.bi-mouse2-fill:before{content:"\f49a"}.bi-mouse2:before{content:"\f49b"}.bi-mouse3-fill:before{content:"\f49c"}.bi-mouse3:before{content:"\f49d"}.bi-music-note-beamed:before{content:"\f49e"}.bi-music-note-list:before{content:"\f49f"}.bi-music-note:before{content:"\f4a0"}.bi-music-player-fill:before{content:"\f4a1"}.bi-music-player:before{content:"\f4a2"}.bi-newspaper:before{content:"\f4a3"}.bi-node-minus-fill:before{content:"\f4a4"}.bi-node-minus:before{content:"\f4a5"}.bi-node-plus-fill:before{content:"\f4a6"}.bi-node-plus:before{content:"\f4a7"}.bi-nut-fill:before{content:"\f4a8"}.bi-nut:before{content:"\f4a9"}.bi-octagon-fill:before{content:"\f4aa"}.bi-octagon-half:before{content:"\f4ab"}.bi-octagon:before{content:"\f4ac"}.bi-option:before{content:"\f4ad"}.bi-outlet:before{content:"\f4ae"}.bi-paint-bucket:before{content:"\f4af"}.bi-palette-fill:before{content:"\f4b0"}.bi-palette:before{content:"\f4b1"}.bi-palette2:before{content:"\f4b2"}.bi-paperclip:before{content:"\f4b3"}.bi-paragraph:before{content:"\f4b4"}.bi-patch-check-fill:before{content:"\f4b5"}.bi-patch-check:before{content:"\f4b6"}.bi-patch-exclamation-fill:before{content:"\f4b7"}.bi-patch-exclamation:before{content:"\f4b8"}.bi-patch-minus-fill:before{content:"\f4b9"}.bi-patch-minus:before{content:"\f4ba"}.bi-patch-plus-fill:before{content:"\f4bb"}.bi-patch-plus:before{content:"\f4bc"}.bi-patch-question-fill:before{content:"\f4bd"}.bi-patch-question:before{content:"\f4be"}.bi-pause-btn-fill:before{content:"\f4bf"}.bi-pause-btn:before{content:"\f4c0"}.bi-pause-circle-fill:before{content:"\f4c1"}.bi-pause-circle:before{content:"\f4c2"}.bi-pause-fill:before{content:"\f4c3"}.bi-pause:before{content:"\f4c4"}.bi-peace-fill:before{content:"\f4c5"}.bi-peace:before{content:"\f4c6"}.bi-pen-fill:before{content:"\f4c7"}.bi-pen:before{content:"\f4c8"}.bi-pencil-fill:before{content:"\f4c9"}.bi-pencil-square:before{content:"\f4ca"}.bi-pencil:before{content:"\f4cb"}.bi-pentagon-fill:before{content:"\f4cc"}.bi-pentagon-half:before{content:"\f4cd"}.bi-pentagon:before{content:"\f4ce"}.bi-people-fill:before{content:"\f4cf"}.bi-people:before{content:"\f4d0"}.bi-percent:before{content:"\f4d1"}.bi-person-badge-fill:before{content:"\f4d2"}.bi-person-badge:before{content:"\f4d3"}.bi-person-bounding-box:before{content:"\f4d4"}.bi-person-check-fill:before{content:"\f4d5"}.bi-person-check:before{content:"\f4d6"}.bi-person-circle:before{content:"\f4d7"}.bi-person-dash-fill:before{content:"\f4d8"}.bi-person-dash:before{content:"\f4d9"}.bi-person-fill:before{content:"\f4da"}.bi-person-lines-fill:before{content:"\f4db"}.bi-person-plus-fill:before{content:"\f4dc"}.bi-person-plus:before{content:"\f4dd"}.bi-person-square:before{content:"\f4de"}.bi-person-x-fill:before{content:"\f4df"}.bi-person-x:before{content:"\f4e0"}.bi-person:before{content:"\f4e1"}.bi-phone-fill:before{content:"\f4e2"}.bi-phone-landscape-fill:before{content:"\f4e3"}.bi-phone-landscape:before{content:"\f4e4"}.bi-phone-vibrate-fill:before{content:"\f4e5"}.bi-phone-vibrate:before{content:"\f4e6"}.bi-phone:before{content:"\f4e7"}.bi-pie-chart-fill:before{content:"\f4e8"}.bi-pie-chart:before{content:"\f4e9"}.bi-pin-angle-fill:before{content:"\f4ea"}.bi-pin-angle:before{content:"\f4eb"}.bi-pin-fill:before{content:"\f4ec"}.bi-pin:before{content:"\f4ed"}.bi-pip-fill:before{content:"\f4ee"}.bi-pip:before{content:"\f4ef"}.bi-play-btn-fill:before{content:"\f4f0"}.bi-play-btn:before{content:"\f4f1"}.bi-play-circle-fill:before{content:"\f4f2"}.bi-play-circle:before{content:"\f4f3"}.bi-play-fill:before{content:"\f4f4"}.bi-play:before{content:"\f4f5"}.bi-plug-fill:before{content:"\f4f6"}.bi-plug:before{content:"\f4f7"}.bi-plus-circle-dotted:before{content:"\f4f8"}.bi-plus-circle-fill:before{content:"\f4f9"}.bi-plus-circle:before{content:"\f4fa"}.bi-plus-square-dotted:before{content:"\f4fb"}.bi-plus-square-fill:before{content:"\f4fc"}.bi-plus-square:before{content:"\f4fd"}.bi-plus:before{content:"\f4fe"}.bi-power:before{content:"\f4ff"}.bi-printer-fill:before{content:"\f500"}.bi-printer:before{content:"\f501"}.bi-puzzle-fill:before{content:"\f502"}.bi-puzzle:before{content:"\f503"}.bi-question-circle-fill:before{content:"\f504"}.bi-question-circle:before{content:"\f505"}.bi-question-diamond-fill:before{content:"\f506"}.bi-question-diamond:before{content:"\f507"}.bi-question-octagon-fill:before{content:"\f508"}.bi-question-octagon:before{content:"\f509"}.bi-question-square-fill:before{content:"\f50a"}.bi-question-square:before{content:"\f50b"}.bi-question:before{content:"\f50c"}.bi-rainbow:before{content:"\f50d"}.bi-receipt-cutoff:before{content:"\f50e"}.bi-receipt:before{content:"\f50f"}.bi-reception-0:before{content:"\f510"}.bi-reception-1:before{content:"\f511"}.bi-reception-2:before{content:"\f512"}.bi-reception-3:before{content:"\f513"}.bi-reception-4:before{content:"\f514"}.bi-record-btn-fill:before{content:"\f515"}.bi-record-btn:before{content:"\f516"}.bi-record-circle-fill:before{content:"\f517"}.bi-record-circle:before{content:"\f518"}.bi-record-fill:before{content:"\f519"}.bi-record:before{content:"\f51a"}.bi-record2-fill:before{content:"\f51b"}.bi-record2:before{content:"\f51c"}.bi-reply-all-fill:before{content:"\f51d"}.bi-reply-all:before{content:"\f51e"}.bi-reply-fill:before{content:"\f51f"}.bi-reply:before{content:"\f520"}.bi-rss-fill:before{content:"\f521"}.bi-rss:before{content:"\f522"}.bi-rulers:before{content:"\f523"}.bi-save-fill:before{content:"\f524"}.bi-save:before{content:"\f525"}.bi-save2-fill:before{content:"\f526"}.bi-save2:before{content:"\f527"}.bi-scissors:before{content:"\f528"}.bi-screwdriver:before{content:"\f529"}.bi-search:before{content:"\f52a"}.bi-segmented-nav:before{content:"\f52b"}.bi-server:before{content:"\f52c"}.bi-share-fill:before{content:"\f52d"}.bi-share:before{content:"\f52e"}.bi-shield-check:before{content:"\f52f"}.bi-shield-exclamation:before{content:"\f530"}.bi-shield-fill-check:before{content:"\f531"}.bi-shield-fill-exclamation:before{content:"\f532"}.bi-shield-fill-minus:before{content:"\f533"}.bi-shield-fill-plus:before{content:"\f534"}.bi-shield-fill-x:before{content:"\f535"}.bi-shield-fill:before{content:"\f536"}.bi-shield-lock-fill:before{content:"\f537"}.bi-shield-lock:before{content:"\f538"}.bi-shield-minus:before{content:"\f539"}.bi-shield-plus:before{content:"\f53a"}.bi-shield-shaded:before{content:"\f53b"}.bi-shield-slash-fill:before{content:"\f53c"}.bi-shield-slash:before{content:"\f53d"}.bi-shield-x:before{content:"\f53e"}.bi-shield:before{content:"\f53f"}.bi-shift-fill:before{content:"\f540"}.bi-shift:before{content:"\f541"}.bi-shop-window:before{content:"\f542"}.bi-shop:before{content:"\f543"}.bi-shuffle:before{content:"\f544"}.bi-signpost-2-fill:before{content:"\f545"}.bi-signpost-2:before{content:"\f546"}.bi-signpost-fill:before{content:"\f547"}.bi-signpost-split-fill:before{content:"\f548"}.bi-signpost-split:before{content:"\f549"}.bi-signpost:before{content:"\f54a"}.bi-sim-fill:before{content:"\f54b"}.bi-sim:before{content:"\f54c"}.bi-skip-backward-btn-fill:before{content:"\f54d"}.bi-skip-backward-btn:before{content:"\f54e"}.bi-skip-backward-circle-fill:before{content:"\f54f"}.bi-skip-backward-circle:before{content:"\f550"}.bi-skip-backward-fill:before{content:"\f551"}.bi-skip-backward:before{content:"\f552"}.bi-skip-end-btn-fill:before{content:"\f553"}.bi-skip-end-btn:before{content:"\f554"}.bi-skip-end-circle-fill:before{content:"\f555"}.bi-skip-end-circle:before{content:"\f556"}.bi-skip-end-fill:before{content:"\f557"}.bi-skip-end:before{content:"\f558"}.bi-skip-forward-btn-fill:before{content:"\f559"}.bi-skip-forward-btn:before{content:"\f55a"}.bi-skip-forward-circle-fill:before{content:"\f55b"}.bi-skip-forward-circle:before{content:"\f55c"}.bi-skip-forward-fill:before{content:"\f55d"}.bi-skip-forward:before{content:"\f55e"}.bi-skip-start-btn-fill:before{content:"\f55f"}.bi-skip-start-btn:before{content:"\f560"}.bi-skip-start-circle-fill:before{content:"\f561"}.bi-skip-start-circle:before{content:"\f562"}.bi-skip-start-fill:before{content:"\f563"}.bi-skip-start:before{content:"\f564"}.bi-slack:before{content:"\f565"}.bi-slash-circle-fill:before{content:"\f566"}.bi-slash-circle:before{content:"\f567"}.bi-slash-square-fill:before{content:"\f568"}.bi-slash-square:before{content:"\f569"}.bi-slash:before{content:"\f56a"}.bi-sliders:before{content:"\f56b"}.bi-smartwatch:before{content:"\f56c"}.bi-snow:before{content:"\f56d"}.bi-snow2:before{content:"\f56e"}.bi-snow3:before{content:"\f56f"}.bi-sort-alpha-down-alt:before{content:"\f570"}.bi-sort-alpha-down:before{content:"\f571"}.bi-sort-alpha-up-alt:before{content:"\f572"}.bi-sort-alpha-up:before{content:"\f573"}.bi-sort-down-alt:before{content:"\f574"}.bi-sort-down:before{content:"\f575"}.bi-sort-numeric-down-alt:before{content:"\f576"}.bi-sort-numeric-down:before{content:"\f577"}.bi-sort-numeric-up-alt:before{content:"\f578"}.bi-sort-numeric-up:before{content:"\f579"}.bi-sort-up-alt:before{content:"\f57a"}.bi-sort-up:before{content:"\f57b"}.bi-soundwave:before{content:"\f57c"}.bi-speaker-fill:before{content:"\f57d"}.bi-speaker:before{content:"\f57e"}.bi-speedometer:before{content:"\f57f"}.bi-speedometer2:before{content:"\f580"}.bi-spellcheck:before{content:"\f581"}.bi-square-fill:before{content:"\f582"}.bi-square-half:before{content:"\f583"}.bi-square:before{content:"\f584"}.bi-stack:before{content:"\f585"}.bi-star-fill:before{content:"\f586"}.bi-star-half:before{content:"\f587"}.bi-star:before{content:"\f588"}.bi-stars:before{content:"\f589"}.bi-stickies-fill:before{content:"\f58a"}.bi-stickies:before{content:"\f58b"}.bi-sticky-fill:before{content:"\f58c"}.bi-sticky:before{content:"\f58d"}.bi-stop-btn-fill:before{content:"\f58e"}.bi-stop-btn:before{content:"\f58f"}.bi-stop-circle-fill:before{content:"\f590"}.bi-stop-circle:before{content:"\f591"}.bi-stop-fill:before{content:"\f592"}.bi-stop:before{content:"\f593"}.bi-stoplights-fill:before{content:"\f594"}.bi-stoplights:before{content:"\f595"}.bi-stopwatch-fill:before{content:"\f596"}.bi-stopwatch:before{content:"\f597"}.bi-subtract:before{content:"\f598"}.bi-suit-club-fill:before{content:"\f599"}.bi-suit-club:before{content:"\f59a"}.bi-suit-diamond-fill:before{content:"\f59b"}.bi-suit-diamond:before{content:"\f59c"}.bi-suit-heart-fill:before{content:"\f59d"}.bi-suit-heart:before{content:"\f59e"}.bi-suit-spade-fill:before{content:"\f59f"}.bi-suit-spade:before{content:"\f5a0"}.bi-sun-fill:before{content:"\f5a1"}.bi-sun:before{content:"\f5a2"}.bi-sunglasses:before{content:"\f5a3"}.bi-sunrise-fill:before{content:"\f5a4"}.bi-sunrise:before{content:"\f5a5"}.bi-sunset-fill:before{content:"\f5a6"}.bi-sunset:before{content:"\f5a7"}.bi-symmetry-horizontal:before{content:"\f5a8"}.bi-symmetry-vertical:before{content:"\f5a9"}.bi-table:before{content:"\f5aa"}.bi-tablet-fill:before{content:"\f5ab"}.bi-tablet-landscape-fill:before{content:"\f5ac"}.bi-tablet-landscape:before{content:"\f5ad"}.bi-tablet:before{content:"\f5ae"}.bi-tag-fill:before{content:"\f5af"}.bi-tag:before{content:"\f5b0"}.bi-tags-fill:before{content:"\f5b1"}.bi-tags:before{content:"\f5b2"}.bi-telegram:before{content:"\f5b3"}.bi-telephone-fill:before{content:"\f5b4"}.bi-telephone-forward-fill:before{content:"\f5b5"}.bi-telephone-forward:before{content:"\f5b6"}.bi-telephone-inbound-fill:before{content:"\f5b7"}.bi-telephone-inbound:before{content:"\f5b8"}.bi-telephone-minus-fill:before{content:"\f5b9"}.bi-telephone-minus:before{content:"\f5ba"}.bi-telephone-outbound-fill:before{content:"\f5bb"}.bi-telephone-outbound:before{content:"\f5bc"}.bi-telephone-plus-fill:before{content:"\f5bd"}.bi-telephone-plus:before{content:"\f5be"}.bi-telephone-x-fill:before{content:"\f5bf"}.bi-telephone-x:before{content:"\f5c0"}.bi-telephone:before{content:"\f5c1"}.bi-terminal-fill:before{content:"\f5c2"}.bi-terminal:before{content:"\f5c3"}.bi-text-center:before{content:"\f5c4"}.bi-text-indent-left:before{content:"\f5c5"}.bi-text-indent-right:before{content:"\f5c6"}.bi-text-left:before{content:"\f5c7"}.bi-text-paragraph:before{content:"\f5c8"}.bi-text-right:before{content:"\f5c9"}.bi-textarea-resize:before{content:"\f5ca"}.bi-textarea-t:before{content:"\f5cb"}.bi-textarea:before{content:"\f5cc"}.bi-thermometer-half:before{content:"\f5cd"}.bi-thermometer-high:before{content:"\f5ce"}.bi-thermometer-low:before{content:"\f5cf"}.bi-thermometer-snow:before{content:"\f5d0"}.bi-thermometer-sun:before{content:"\f5d1"}.bi-thermometer:before{content:"\f5d2"}.bi-three-dots-vertical:before{content:"\f5d3"}.bi-three-dots:before{content:"\f5d4"}.bi-toggle-off:before{content:"\f5d5"}.bi-toggle-on:before{content:"\f5d6"}.bi-toggle2-off:before{content:"\f5d7"}.bi-toggle2-on:before{content:"\f5d8"}.bi-toggles:before{content:"\f5d9"}.bi-toggles2:before{content:"\f5da"}.bi-tools:before{content:"\f5db"}.bi-tornado:before{content:"\f5dc"}.bi-trash-fill:before{content:"\f5dd"}.bi-trash:before{content:"\f5de"}.bi-trash2-fill:before{content:"\f5df"}.bi-trash2:before{content:"\f5e0"}.bi-tree-fill:before{content:"\f5e1"}.bi-tree:before{content:"\f5e2"}.bi-triangle-fill:before{content:"\f5e3"}.bi-triangle-half:before{content:"\f5e4"}.bi-triangle:before{content:"\f5e5"}.bi-trophy-fill:before{content:"\f5e6"}.bi-trophy:before{content:"\f5e7"}.bi-tropical-storm:before{content:"\f5e8"}.bi-truck-flatbed:before{content:"\f5e9"}.bi-truck:before{content:"\f5ea"}.bi-tsunami:before{content:"\f5eb"}.bi-tv-fill:before{content:"\f5ec"}.bi-tv:before{content:"\f5ed"}.bi-twitch:before{content:"\f5ee"}.bi-twitter:before{content:"\f5ef"}.bi-type-bold:before{content:"\f5f0"}.bi-type-h1:before{content:"\f5f1"}.bi-type-h2:before{content:"\f5f2"}.bi-type-h3:before{content:"\f5f3"}.bi-type-italic:before{content:"\f5f4"}.bi-type-strikethrough:before{content:"\f5f5"}.bi-type-underline:before{content:"\f5f6"}.bi-type:before{content:"\f5f7"}.bi-ui-checks-grid:before{content:"\f5f8"}.bi-ui-checks:before{content:"\f5f9"}.bi-ui-radios-grid:before{content:"\f5fa"}.bi-ui-radios:before{content:"\f5fb"}.bi-umbrella-fill:before{content:"\f5fc"}.bi-umbrella:before{content:"\f5fd"}.bi-union:before{content:"\f5fe"}.bi-unlock-fill:before{content:"\f5ff"}.bi-unlock:before{content:"\f600"}.bi-upc-scan:before{content:"\f601"}.bi-upc:before{content:"\f602"}.bi-upload:before{content:"\f603"}.bi-vector-pen:before{content:"\f604"}.bi-view-list:before{content:"\f605"}.bi-view-stacked:before{content:"\f606"}.bi-vinyl-fill:before{content:"\f607"}.bi-vinyl:before{content:"\f608"}.bi-voicemail:before{content:"\f609"}.bi-volume-down-fill:before{content:"\f60a"}.bi-volume-down:before{content:"\f60b"}.bi-volume-mute-fill:before{content:"\f60c"}.bi-volume-mute:before{content:"\f60d"}.bi-volume-off-fill:before{content:"\f60e"}.bi-volume-off:before{content:"\f60f"}.bi-volume-up-fill:before{content:"\f610"}.bi-volume-up:before{content:"\f611"}.bi-vr:before{content:"\f612"}.bi-wallet-fill:before{content:"\f613"}.bi-wallet:before{content:"\f614"}.bi-wallet2:before{content:"\f615"}.bi-watch:before{content:"\f616"}.bi-water:before{content:"\f617"}.bi-whatsapp:before{content:"\f618"}.bi-wifi-1:before{content:"\f619"}.bi-wifi-2:before{content:"\f61a"}.bi-wifi-off:before{content:"\f61b"}.bi-wifi:before{content:"\f61c"}.bi-wind:before{content:"\f61d"}.bi-window-dock:before{content:"\f61e"}.bi-window-sidebar:before{content:"\f61f"}.bi-window:before{content:"\f620"}.bi-wrench:before{content:"\f621"}.bi-x-circle-fill:before{content:"\f622"}.bi-x-circle:before{content:"\f623"}.bi-x-diamond-fill:before{content:"\f624"}.bi-x-diamond:before{content:"\f625"}.bi-x-octagon-fill:before{content:"\f626"}.bi-x-octagon:before{content:"\f627"}.bi-x-square-fill:before{content:"\f628"}.bi-x-square:before{content:"\f629"}.bi-x:before{content:"\f62a"}.bi-youtube:before{content:"\f62b"}.bi-zoom-in:before{content:"\f62c"}.bi-zoom-out:before{content:"\f62d"}.bi-bank:before{content:"\f62e"}.bi-bank2:before{content:"\f62f"}.bi-bell-slash-fill:before{content:"\f630"}.bi-bell-slash:before{content:"\f631"}.bi-cash-coin:before{content:"\f632"}.bi-check-lg:before{content:"\f633"}.bi-coin:before{content:"\f634"}.bi-currency-bitcoin:before{content:"\f635"}.bi-currency-dollar:before{content:"\f636"}.bi-currency-euro:before{content:"\f637"}.bi-currency-exchange:before{content:"\f638"}.bi-currency-pound:before{content:"\f639"}.bi-currency-yen:before{content:"\f63a"}.bi-dash-lg:before{content:"\f63b"}.bi-exclamation-lg:before{content:"\f63c"}.bi-file-earmark-pdf-fill:before{content:"\f63d"}.bi-file-earmark-pdf:before{content:"\f63e"}.bi-file-pdf-fill:before{content:"\f63f"}.bi-file-pdf:before{content:"\f640"}.bi-gender-ambiguous:before{content:"\f641"}.bi-gender-female:before{content:"\f642"}.bi-gender-male:before{content:"\f643"}.bi-gender-trans:before{content:"\f644"}.bi-headset-vr:before{content:"\f645"}.bi-info-lg:before{content:"\f646"}.bi-mastodon:before{content:"\f647"}.bi-messenger:before{content:"\f648"}.bi-piggy-bank-fill:before{content:"\f649"}.bi-piggy-bank:before{content:"\f64a"}.bi-pin-map-fill:before{content:"\f64b"}.bi-pin-map:before{content:"\f64c"}.bi-plus-lg:before{content:"\f64d"}.bi-question-lg:before{content:"\f64e"}.bi-recycle:before{content:"\f64f"}.bi-reddit:before{content:"\f650"}.bi-safe-fill:before{content:"\f651"}.bi-safe2-fill:before{content:"\f652"}.bi-safe2:before{content:"\f653"}.bi-sd-card-fill:before{content:"\f654"}.bi-sd-card:before{content:"\f655"}.bi-skype:before{content:"\f656"}.bi-slash-lg:before{content:"\f657"}.bi-translate:before{content:"\f658"}.bi-x-lg:before{content:"\f659"}.bi-safe:before{content:"\f65a"}.bi-apple:before{content:"\f65b"}.bi-microsoft:before{content:"\f65d"}.bi-windows:before{content:"\f65e"}.bi-behance:before{content:"\f65c"}.bi-dribbble:before{content:"\f65f"}.bi-line:before{content:"\f660"}.bi-medium:before{content:"\f661"}.bi-paypal:before{content:"\f662"}.bi-pinterest:before{content:"\f663"}.bi-signal:before{content:"\f664"}.bi-snapchat:before{content:"\f665"}.bi-spotify:before{content:"\f666"}.bi-stack-overflow:before{content:"\f667"}.bi-strava:before{content:"\f668"}.bi-wordpress:before{content:"\f669"}.bi-vimeo:before{content:"\f66a"}.bi-activity:before{content:"\f66b"}.bi-easel2-fill:before{content:"\f66c"}.bi-easel2:before{content:"\f66d"}.bi-easel3-fill:before{content:"\f66e"}.bi-easel3:before{content:"\f66f"}.bi-fan:before{content:"\f670"}.bi-fingerprint:before{content:"\f671"}.bi-graph-down-arrow:before{content:"\f672"}.bi-graph-up-arrow:before{content:"\f673"}.bi-hypnotize:before{content:"\f674"}.bi-magic:before{content:"\f675"}.bi-person-rolodex:before{content:"\f676"}.bi-person-video:before{content:"\f677"}.bi-person-video2:before{content:"\f678"}.bi-person-video3:before{content:"\f679"}.bi-person-workspace:before{content:"\f67a"}.bi-radioactive:before{content:"\f67b"}.bi-webcam-fill:before{content:"\f67c"}.bi-webcam:before{content:"\f67d"}.bi-yin-yang:before{content:"\f67e"}.bi-bandaid-fill:before{content:"\f680"}.bi-bandaid:before{content:"\f681"}.bi-bluetooth:before{content:"\f682"}.bi-body-text:before{content:"\f683"}.bi-boombox:before{content:"\f684"}.bi-boxes:before{content:"\f685"}.bi-dpad-fill:before{content:"\f686"}.bi-dpad:before{content:"\f687"}.bi-ear-fill:before{content:"\f688"}.bi-ear:before{content:"\f689"}.bi-envelope-check-fill:before{content:"\f68b"}.bi-envelope-check:before{content:"\f68c"}.bi-envelope-dash-fill:before{content:"\f68e"}.bi-envelope-dash:before{content:"\f68f"}.bi-envelope-exclamation-fill:before{content:"\f691"}.bi-envelope-exclamation:before{content:"\f692"}.bi-envelope-plus-fill:before{content:"\f693"}.bi-envelope-plus:before{content:"\f694"}.bi-envelope-slash-fill:before{content:"\f696"}.bi-envelope-slash:before{content:"\f697"}.bi-envelope-x-fill:before{content:"\f699"}.bi-envelope-x:before{content:"\f69a"}.bi-explicit-fill:before{content:"\f69b"}.bi-explicit:before{content:"\f69c"}.bi-git:before{content:"\f69d"}.bi-infinity:before{content:"\f69e"}.bi-list-columns-reverse:before{content:"\f69f"}.bi-list-columns:before{content:"\f6a0"}.bi-meta:before{content:"\f6a1"}.bi-nintendo-switch:before{content:"\f6a4"}.bi-pc-display-horizontal:before{content:"\f6a5"}.bi-pc-display:before{content:"\f6a6"}.bi-pc-horizontal:before{content:"\f6a7"}.bi-pc:before{content:"\f6a8"}.bi-playstation:before{content:"\f6a9"}.bi-plus-slash-minus:before{content:"\f6aa"}.bi-projector-fill:before{content:"\f6ab"}.bi-projector:before{content:"\f6ac"}.bi-qr-code-scan:before{content:"\f6ad"}.bi-qr-code:before{content:"\f6ae"}.bi-quora:before{content:"\f6af"}.bi-quote:before{content:"\f6b0"}.bi-robot:before{content:"\f6b1"}.bi-send-check-fill:before{content:"\f6b2"}.bi-send-check:before{content:"\f6b3"}.bi-send-dash-fill:before{content:"\f6b4"}.bi-send-dash:before{content:"\f6b5"}.bi-send-exclamation-fill:before{content:"\f6b7"}.bi-send-exclamation:before{content:"\f6b8"}.bi-send-fill:before{content:"\f6b9"}.bi-send-plus-fill:before{content:"\f6ba"}.bi-send-plus:before{content:"\f6bb"}.bi-send-slash-fill:before{content:"\f6bc"}.bi-send-slash:before{content:"\f6bd"}.bi-send-x-fill:before{content:"\f6be"}.bi-send-x:before{content:"\f6bf"}.bi-send:before{content:"\f6c0"}.bi-steam:before{content:"\f6c1"}.bi-terminal-dash:before{content:"\f6c3"}.bi-terminal-plus:before{content:"\f6c4"}.bi-terminal-split:before{content:"\f6c5"}.bi-ticket-detailed-fill:before{content:"\f6c6"}.bi-ticket-detailed:before{content:"\f6c7"}.bi-ticket-fill:before{content:"\f6c8"}.bi-ticket-perforated-fill:before{content:"\f6c9"}.bi-ticket-perforated:before{content:"\f6ca"}.bi-ticket:before{content:"\f6cb"}.bi-tiktok:before{content:"\f6cc"}.bi-window-dash:before{content:"\f6cd"}.bi-window-desktop:before{content:"\f6ce"}.bi-window-fullscreen:before{content:"\f6cf"}.bi-window-plus:before{content:"\f6d0"}.bi-window-split:before{content:"\f6d1"}.bi-window-stack:before{content:"\f6d2"}.bi-window-x:before{content:"\f6d3"}.bi-xbox:before{content:"\f6d4"}.bi-ethernet:before{content:"\f6d5"}.bi-hdmi-fill:before{content:"\f6d6"}.bi-hdmi:before{content:"\f6d7"}.bi-usb-c-fill:before{content:"\f6d8"}.bi-usb-c:before{content:"\f6d9"}.bi-usb-fill:before{content:"\f6da"}.bi-usb-plug-fill:before{content:"\f6db"}.bi-usb-plug:before{content:"\f6dc"}.bi-usb-symbol:before{content:"\f6dd"}.bi-usb:before{content:"\f6de"}.bi-boombox-fill:before{content:"\f6df"}.bi-displayport:before{content:"\f6e1"}.bi-gpu-card:before{content:"\f6e2"}.bi-memory:before{content:"\f6e3"}.bi-modem-fill:before{content:"\f6e4"}.bi-modem:before{content:"\f6e5"}.bi-motherboard-fill:before{content:"\f6e6"}.bi-motherboard:before{content:"\f6e7"}.bi-optical-audio-fill:before{content:"\f6e8"}.bi-optical-audio:before{content:"\f6e9"}.bi-pci-card:before{content:"\f6ea"}.bi-router-fill:before{content:"\f6eb"}.bi-router:before{content:"\f6ec"}.bi-thunderbolt-fill:before{content:"\f6ef"}.bi-thunderbolt:before{content:"\f6f0"}.bi-usb-drive-fill:before{content:"\f6f1"}.bi-usb-drive:before{content:"\f6f2"}.bi-usb-micro-fill:before{content:"\f6f3"}.bi-usb-micro:before{content:"\f6f4"}.bi-usb-mini-fill:before{content:"\f6f5"}.bi-usb-mini:before{content:"\f6f6"}.bi-cloud-haze2:before{content:"\f6f7"}.bi-device-hdd-fill:before{content:"\f6f8"}.bi-device-hdd:before{content:"\f6f9"}.bi-device-ssd-fill:before{content:"\f6fa"}.bi-device-ssd:before{content:"\f6fb"}.bi-displayport-fill:before{content:"\f6fc"}.bi-mortarboard-fill:before{content:"\f6fd"}.bi-mortarboard:before{content:"\f6fe"}.bi-terminal-x:before{content:"\f6ff"}.bi-arrow-through-heart-fill:before{content:"\f700"}.bi-arrow-through-heart:before{content:"\f701"}.bi-badge-sd-fill:before{content:"\f702"}.bi-badge-sd:before{content:"\f703"}.bi-bag-heart-fill:before{content:"\f704"}.bi-bag-heart:before{content:"\f705"}.bi-balloon-fill:before{content:"\f706"}.bi-balloon-heart-fill:before{content:"\f707"}.bi-balloon-heart:before{content:"\f708"}.bi-balloon:before{content:"\f709"}.bi-box2-fill:before{content:"\f70a"}.bi-box2-heart-fill:before{content:"\f70b"}.bi-box2-heart:before{content:"\f70c"}.bi-box2:before{content:"\f70d"}.bi-braces-asterisk:before{content:"\f70e"}.bi-calendar-heart-fill:before{content:"\f70f"}.bi-calendar-heart:before{content:"\f710"}.bi-calendar2-heart-fill:before{content:"\f711"}.bi-calendar2-heart:before{content:"\f712"}.bi-chat-heart-fill:before{content:"\f713"}.bi-chat-heart:before{content:"\f714"}.bi-chat-left-heart-fill:before{content:"\f715"}.bi-chat-left-heart:before{content:"\f716"}.bi-chat-right-heart-fill:before{content:"\f717"}.bi-chat-right-heart:before{content:"\f718"}.bi-chat-square-heart-fill:before{content:"\f719"}.bi-chat-square-heart:before{content:"\f71a"}.bi-clipboard-check-fill:before{content:"\f71b"}.bi-clipboard-data-fill:before{content:"\f71c"}.bi-clipboard-fill:before{content:"\f71d"}.bi-clipboard-heart-fill:before{content:"\f71e"}.bi-clipboard-heart:before{content:"\f71f"}.bi-clipboard-minus-fill:before{content:"\f720"}.bi-clipboard-plus-fill:before{content:"\f721"}.bi-clipboard-pulse:before{content:"\f722"}.bi-clipboard-x-fill:before{content:"\f723"}.bi-clipboard2-check-fill:before{content:"\f724"}.bi-clipboard2-check:before{content:"\f725"}.bi-clipboard2-data-fill:before{content:"\f726"}.bi-clipboard2-data:before{content:"\f727"}.bi-clipboard2-fill:before{content:"\f728"}.bi-clipboard2-heart-fill:before{content:"\f729"}.bi-clipboard2-heart:before{content:"\f72a"}.bi-clipboard2-minus-fill:before{content:"\f72b"}.bi-clipboard2-minus:before{content:"\f72c"}.bi-clipboard2-plus-fill:before{content:"\f72d"}.bi-clipboard2-plus:before{content:"\f72e"}.bi-clipboard2-pulse-fill:before{content:"\f72f"}.bi-clipboard2-pulse:before{content:"\f730"}.bi-clipboard2-x-fill:before{content:"\f731"}.bi-clipboard2-x:before{content:"\f732"}.bi-clipboard2:before{content:"\f733"}.bi-emoji-kiss-fill:before{content:"\f734"}.bi-emoji-kiss:before{content:"\f735"}.bi-envelope-heart-fill:before{content:"\f736"}.bi-envelope-heart:before{content:"\f737"}.bi-envelope-open-heart-fill:before{content:"\f738"}.bi-envelope-open-heart:before{content:"\f739"}.bi-envelope-paper-fill:before{content:"\f73a"}.bi-envelope-paper-heart-fill:before{content:"\f73b"}.bi-envelope-paper-heart:before{content:"\f73c"}.bi-envelope-paper:before{content:"\f73d"}.bi-filetype-aac:before{content:"\f73e"}.bi-filetype-ai:before{content:"\f73f"}.bi-filetype-bmp:before{content:"\f740"}.bi-filetype-cs:before{content:"\f741"}.bi-filetype-css:before{content:"\f742"}.bi-filetype-csv:before{content:"\f743"}.bi-filetype-doc:before{content:"\f744"}.bi-filetype-docx:before{content:"\f745"}.bi-filetype-exe:before{content:"\f746"}.bi-filetype-gif:before{content:"\f747"}.bi-filetype-heic:before{content:"\f748"}.bi-filetype-html:before{content:"\f749"}.bi-filetype-java:before{content:"\f74a"}.bi-filetype-jpg:before{content:"\f74b"}.bi-filetype-js:before{content:"\f74c"}.bi-filetype-jsx:before{content:"\f74d"}.bi-filetype-key:before{content:"\f74e"}.bi-filetype-m4p:before{content:"\f74f"}.bi-filetype-md:before{content:"\f750"}.bi-filetype-mdx:before{content:"\f751"}.bi-filetype-mov:before{content:"\f752"}.bi-filetype-mp3:before{content:"\f753"}.bi-filetype-mp4:before{content:"\f754"}.bi-filetype-otf:before{content:"\f755"}.bi-filetype-pdf:before{content:"\f756"}.bi-filetype-php:before{content:"\f757"}.bi-filetype-png:before{content:"\f758"}.bi-filetype-ppt:before{content:"\f75a"}.bi-filetype-psd:before{content:"\f75b"}.bi-filetype-py:before{content:"\f75c"}.bi-filetype-raw:before{content:"\f75d"}.bi-filetype-rb:before{content:"\f75e"}.bi-filetype-sass:before{content:"\f75f"}.bi-filetype-scss:before{content:"\f760"}.bi-filetype-sh:before{content:"\f761"}.bi-filetype-svg:before{content:"\f762"}.bi-filetype-tiff:before{content:"\f763"}.bi-filetype-tsx:before{content:"\f764"}.bi-filetype-ttf:before{content:"\f765"}.bi-filetype-txt:before{content:"\f766"}.bi-filetype-wav:before{content:"\f767"}.bi-filetype-woff:before{content:"\f768"}.bi-filetype-xls:before{content:"\f76a"}.bi-filetype-xml:before{content:"\f76b"}.bi-filetype-yml:before{content:"\f76c"}.bi-heart-arrow:before{content:"\f76d"}.bi-heart-pulse-fill:before{content:"\f76e"}.bi-heart-pulse:before{content:"\f76f"}.bi-heartbreak-fill:before{content:"\f770"}.bi-heartbreak:before{content:"\f771"}.bi-hearts:before{content:"\f772"}.bi-hospital-fill:before{content:"\f773"}.bi-hospital:before{content:"\f774"}.bi-house-heart-fill:before{content:"\f775"}.bi-house-heart:before{content:"\f776"}.bi-incognito:before{content:"\f777"}.bi-magnet-fill:before{content:"\f778"}.bi-magnet:before{content:"\f779"}.bi-person-heart:before{content:"\f77a"}.bi-person-hearts:before{content:"\f77b"}.bi-phone-flip:before{content:"\f77c"}.bi-plugin:before{content:"\f77d"}.bi-postage-fill:before{content:"\f77e"}.bi-postage-heart-fill:before{content:"\f77f"}.bi-postage-heart:before{content:"\f780"}.bi-postage:before{content:"\f781"}.bi-postcard-fill:before{content:"\f782"}.bi-postcard-heart-fill:before{content:"\f783"}.bi-postcard-heart:before{content:"\f784"}.bi-postcard:before{content:"\f785"}.bi-search-heart-fill:before{content:"\f786"}.bi-search-heart:before{content:"\f787"}.bi-sliders2-vertical:before{content:"\f788"}.bi-sliders2:before{content:"\f789"}.bi-trash3-fill:before{content:"\f78a"}.bi-trash3:before{content:"\f78b"}.bi-valentine:before{content:"\f78c"}.bi-valentine2:before{content:"\f78d"}.bi-wrench-adjustable-circle-fill:before{content:"\f78e"}.bi-wrench-adjustable-circle:before{content:"\f78f"}.bi-wrench-adjustable:before{content:"\f790"}.bi-filetype-json:before{content:"\f791"}.bi-filetype-pptx:before{content:"\f792"}.bi-filetype-xlsx:before{content:"\f793"}.bi-1-circle-fill:before{content:"\f796"}.bi-1-circle:before{content:"\f797"}.bi-1-square-fill:before{content:"\f798"}.bi-1-square:before{content:"\f799"}.bi-2-circle-fill:before{content:"\f79c"}.bi-2-circle:before{content:"\f79d"}.bi-2-square-fill:before{content:"\f79e"}.bi-2-square:before{content:"\f79f"}.bi-3-circle-fill:before{content:"\f7a2"}.bi-3-circle:before{content:"\f7a3"}.bi-3-square-fill:before{content:"\f7a4"}.bi-3-square:before{content:"\f7a5"}.bi-4-circle-fill:before{content:"\f7a8"}.bi-4-circle:before{content:"\f7a9"}.bi-4-square-fill:before{content:"\f7aa"}.bi-4-square:before{content:"\f7ab"}.bi-5-circle-fill:before{content:"\f7ae"}.bi-5-circle:before{content:"\f7af"}.bi-5-square-fill:before{content:"\f7b0"}.bi-5-square:before{content:"\f7b1"}.bi-6-circle-fill:before{content:"\f7b4"}.bi-6-circle:before{content:"\f7b5"}.bi-6-square-fill:before{content:"\f7b6"}.bi-6-square:before{content:"\f7b7"}.bi-7-circle-fill:before{content:"\f7ba"}.bi-7-circle:before{content:"\f7bb"}.bi-7-square-fill:before{content:"\f7bc"}.bi-7-square:before{content:"\f7bd"}.bi-8-circle-fill:before{content:"\f7c0"}.bi-8-circle:before{content:"\f7c1"}.bi-8-square-fill:before{content:"\f7c2"}.bi-8-square:before{content:"\f7c3"}.bi-9-circle-fill:before{content:"\f7c6"}.bi-9-circle:before{content:"\f7c7"}.bi-9-square-fill:before{content:"\f7c8"}.bi-9-square:before{content:"\f7c9"}.bi-airplane-engines-fill:before{content:"\f7ca"}.bi-airplane-engines:before{content:"\f7cb"}.bi-airplane-fill:before{content:"\f7cc"}.bi-airplane:before{content:"\f7cd"}.bi-alexa:before{content:"\f7ce"}.bi-alipay:before{content:"\f7cf"}.bi-android:before{content:"\f7d0"}.bi-android2:before{content:"\f7d1"}.bi-box-fill:before{content:"\f7d2"}.bi-box-seam-fill:before{content:"\f7d3"}.bi-browser-chrome:before{content:"\f7d4"}.bi-browser-edge:before{content:"\f7d5"}.bi-browser-firefox:before{content:"\f7d6"}.bi-browser-safari:before{content:"\f7d7"}.bi-c-circle-fill:before{content:"\f7da"}.bi-c-circle:before{content:"\f7db"}.bi-c-square-fill:before{content:"\f7dc"}.bi-c-square:before{content:"\f7dd"}.bi-capsule-pill:before{content:"\f7de"}.bi-capsule:before{content:"\f7df"}.bi-car-front-fill:before{content:"\f7e0"}.bi-car-front:before{content:"\f7e1"}.bi-cassette-fill:before{content:"\f7e2"}.bi-cassette:before{content:"\f7e3"}.bi-cc-circle-fill:before{content:"\f7e6"}.bi-cc-circle:before{content:"\f7e7"}.bi-cc-square-fill:before{content:"\f7e8"}.bi-cc-square:before{content:"\f7e9"}.bi-cup-hot-fill:before{content:"\f7ea"}.bi-cup-hot:before{content:"\f7eb"}.bi-currency-rupee:before{content:"\f7ec"}.bi-dropbox:before{content:"\f7ed"}.bi-escape:before{content:"\f7ee"}.bi-fast-forward-btn-fill:before{content:"\f7ef"}.bi-fast-forward-btn:before{content:"\f7f0"}.bi-fast-forward-circle-fill:before{content:"\f7f1"}.bi-fast-forward-circle:before{content:"\f7f2"}.bi-fast-forward-fill:before{content:"\f7f3"}.bi-fast-forward:before{content:"\f7f4"}.bi-filetype-sql:before{content:"\f7f5"}.bi-fire:before{content:"\f7f6"}.bi-google-play:before{content:"\f7f7"}.bi-h-circle-fill:before{content:"\f7fa"}.bi-h-circle:before{content:"\f7fb"}.bi-h-square-fill:before{content:"\f7fc"}.bi-h-square:before{content:"\f7fd"}.bi-indent:before{content:"\f7fe"}.bi-lungs-fill:before{content:"\f7ff"}.bi-lungs:before{content:"\f800"}.bi-microsoft-teams:before{content:"\f801"}.bi-p-circle-fill:before{content:"\f804"}.bi-p-circle:before{content:"\f805"}.bi-p-square-fill:before{content:"\f806"}.bi-p-square:before{content:"\f807"}.bi-pass-fill:before{content:"\f808"}.bi-pass:before{content:"\f809"}.bi-prescription:before{content:"\f80a"}.bi-prescription2:before{content:"\f80b"}.bi-r-circle-fill:before{content:"\f80e"}.bi-r-circle:before{content:"\f80f"}.bi-r-square-fill:before{content:"\f810"}.bi-r-square:before{content:"\f811"}.bi-repeat-1:before{content:"\f812"}.bi-repeat:before{content:"\f813"}.bi-rewind-btn-fill:before{content:"\f814"}.bi-rewind-btn:before{content:"\f815"}.bi-rewind-circle-fill:before{content:"\f816"}.bi-rewind-circle:before{content:"\f817"}.bi-rewind-fill:before{content:"\f818"}.bi-rewind:before{content:"\f819"}.bi-train-freight-front-fill:before{content:"\f81a"}.bi-train-freight-front:before{content:"\f81b"}.bi-train-front-fill:before{content:"\f81c"}.bi-train-front:before{content:"\f81d"}.bi-train-lightrail-front-fill:before{content:"\f81e"}.bi-train-lightrail-front:before{content:"\f81f"}.bi-truck-front-fill:before{content:"\f820"}.bi-truck-front:before{content:"\f821"}.bi-ubuntu:before{content:"\f822"}.bi-unindent:before{content:"\f823"}.bi-unity:before{content:"\f824"}.bi-universal-access-circle:before{content:"\f825"}.bi-universal-access:before{content:"\f826"}.bi-virus:before{content:"\f827"}.bi-virus2:before{content:"\f828"}.bi-wechat:before{content:"\f829"}.bi-yelp:before{content:"\f82a"}.bi-sign-stop-fill:before{content:"\f82b"}.bi-sign-stop-lights-fill:before{content:"\f82c"}.bi-sign-stop-lights:before{content:"\f82d"}.bi-sign-stop:before{content:"\f82e"}.bi-sign-turn-left-fill:before{content:"\f82f"}.bi-sign-turn-left:before{content:"\f830"}.bi-sign-turn-right-fill:before{content:"\f831"}.bi-sign-turn-right:before{content:"\f832"}.bi-sign-turn-slight-left-fill:before{content:"\f833"}.bi-sign-turn-slight-left:before{content:"\f834"}.bi-sign-turn-slight-right-fill:before{content:"\f835"}.bi-sign-turn-slight-right:before{content:"\f836"}.bi-sign-yield-fill:before{content:"\f837"}.bi-sign-yield:before{content:"\f838"}.bi-ev-station-fill:before{content:"\f839"}.bi-ev-station:before{content:"\f83a"}.bi-fuel-pump-diesel-fill:before{content:"\f83b"}.bi-fuel-pump-diesel:before{content:"\f83c"}.bi-fuel-pump-fill:before{content:"\f83d"}.bi-fuel-pump:before{content:"\f83e"}.bi-0-circle-fill:before{content:"\f83f"}.bi-0-circle:before{content:"\f840"}.bi-0-square-fill:before{content:"\f841"}.bi-0-square:before{content:"\f842"}.bi-rocket-fill:before{content:"\f843"}.bi-rocket-takeoff-fill:before{content:"\f844"}.bi-rocket-takeoff:before{content:"\f845"}.bi-rocket:before{content:"\f846"}.bi-stripe:before{content:"\f847"}.bi-subscript:before{content:"\f848"}.bi-superscript:before{content:"\f849"}.bi-trello:before{content:"\f84a"}.bi-envelope-at-fill:before{content:"\f84b"}.bi-envelope-at:before{content:"\f84c"}.bi-regex:before{content:"\f84d"}.bi-text-wrap:before{content:"\f84e"}.bi-sign-dead-end-fill:before{content:"\f84f"}.bi-sign-dead-end:before{content:"\f850"}.bi-sign-do-not-enter-fill:before{content:"\f851"}.bi-sign-do-not-enter:before{content:"\f852"}.bi-sign-intersection-fill:before{content:"\f853"}.bi-sign-intersection-side-fill:before{content:"\f854"}.bi-sign-intersection-side:before{content:"\f855"}.bi-sign-intersection-t-fill:before{content:"\f856"}.bi-sign-intersection-t:before{content:"\f857"}.bi-sign-intersection-y-fill:before{content:"\f858"}.bi-sign-intersection-y:before{content:"\f859"}.bi-sign-intersection:before{content:"\f85a"}.bi-sign-merge-left-fill:before{content:"\f85b"}.bi-sign-merge-left:before{content:"\f85c"}.bi-sign-merge-right-fill:before{content:"\f85d"}.bi-sign-merge-right:before{content:"\f85e"}.bi-sign-no-left-turn-fill:before{content:"\f85f"}.bi-sign-no-left-turn:before{content:"\f860"}.bi-sign-no-parking-fill:before{content:"\f861"}.bi-sign-no-parking:before{content:"\f862"}.bi-sign-no-right-turn-fill:before{content:"\f863"}.bi-sign-no-right-turn:before{content:"\f864"}.bi-sign-railroad-fill:before{content:"\f865"}.bi-sign-railroad:before{content:"\f866"}.bi-building-add:before{content:"\f867"}.bi-building-check:before{content:"\f868"}.bi-building-dash:before{content:"\f869"}.bi-building-down:before{content:"\f86a"}.bi-building-exclamation:before{content:"\f86b"}.bi-building-fill-add:before{content:"\f86c"}.bi-building-fill-check:before{content:"\f86d"}.bi-building-fill-dash:before{content:"\f86e"}.bi-building-fill-down:before{content:"\f86f"}.bi-building-fill-exclamation:before{content:"\f870"}.bi-building-fill-gear:before{content:"\f871"}.bi-building-fill-lock:before{content:"\f872"}.bi-building-fill-slash:before{content:"\f873"}.bi-building-fill-up:before{content:"\f874"}.bi-building-fill-x:before{content:"\f875"}.bi-building-fill:before{content:"\f876"}.bi-building-gear:before{content:"\f877"}.bi-building-lock:before{content:"\f878"}.bi-building-slash:before{content:"\f879"}.bi-building-up:before{content:"\f87a"}.bi-building-x:before{content:"\f87b"}.bi-buildings-fill:before{content:"\f87c"}.bi-buildings:before{content:"\f87d"}.bi-bus-front-fill:before{content:"\f87e"}.bi-bus-front:before{content:"\f87f"}.bi-ev-front-fill:before{content:"\f880"}.bi-ev-front:before{content:"\f881"}.bi-globe-americas:before{content:"\f882"}.bi-globe-asia-australia:before{content:"\f883"}.bi-globe-central-south-asia:before{content:"\f884"}.bi-globe-europe-africa:before{content:"\f885"}.bi-house-add-fill:before{content:"\f886"}.bi-house-add:before{content:"\f887"}.bi-house-check-fill:before{content:"\f888"}.bi-house-check:before{content:"\f889"}.bi-house-dash-fill:before{content:"\f88a"}.bi-house-dash:before{content:"\f88b"}.bi-house-down-fill:before{content:"\f88c"}.bi-house-down:before{content:"\f88d"}.bi-house-exclamation-fill:before{content:"\f88e"}.bi-house-exclamation:before{content:"\f88f"}.bi-house-gear-fill:before{content:"\f890"}.bi-house-gear:before{content:"\f891"}.bi-house-lock-fill:before{content:"\f892"}.bi-house-lock:before{content:"\f893"}.bi-house-slash-fill:before{content:"\f894"}.bi-house-slash:before{content:"\f895"}.bi-house-up-fill:before{content:"\f896"}.bi-house-up:before{content:"\f897"}.bi-house-x-fill:before{content:"\f898"}.bi-house-x:before{content:"\f899"}.bi-person-add:before{content:"\f89a"}.bi-person-down:before{content:"\f89b"}.bi-person-exclamation:before{content:"\f89c"}.bi-person-fill-add:before{content:"\f89d"}.bi-person-fill-check:before{content:"\f89e"}.bi-person-fill-dash:before{content:"\f89f"}.bi-person-fill-down:before{content:"\f8a0"}.bi-person-fill-exclamation:before{content:"\f8a1"}.bi-person-fill-gear:before{content:"\f8a2"}.bi-person-fill-lock:before{content:"\f8a3"}.bi-person-fill-slash:before{content:"\f8a4"}.bi-person-fill-up:before{content:"\f8a5"}.bi-person-fill-x:before{content:"\f8a6"}.bi-person-gear:before{content:"\f8a7"}.bi-person-lock:before{content:"\f8a8"}.bi-person-slash:before{content:"\f8a9"}.bi-person-up:before{content:"\f8aa"}.bi-scooter:before{content:"\f8ab"}.bi-taxi-front-fill:before{content:"\f8ac"}.bi-taxi-front:before{content:"\f8ad"}.bi-amd:before{content:"\f8ae"}.bi-database-add:before{content:"\f8af"}.bi-database-check:before{content:"\f8b0"}.bi-database-dash:before{content:"\f8b1"}.bi-database-down:before{content:"\f8b2"}.bi-database-exclamation:before{content:"\f8b3"}.bi-database-fill-add:before{content:"\f8b4"}.bi-database-fill-check:before{content:"\f8b5"}.bi-database-fill-dash:before{content:"\f8b6"}.bi-database-fill-down:before{content:"\f8b7"}.bi-database-fill-exclamation:before{content:"\f8b8"}.bi-database-fill-gear:before{content:"\f8b9"}.bi-database-fill-lock:before{content:"\f8ba"}.bi-database-fill-slash:before{content:"\f8bb"}.bi-database-fill-up:before{content:"\f8bc"}.bi-database-fill-x:before{content:"\f8bd"}.bi-database-fill:before{content:"\f8be"}.bi-database-gear:before{content:"\f8bf"}.bi-database-lock:before{content:"\f8c0"}.bi-database-slash:before{content:"\f8c1"}.bi-database-up:before{content:"\f8c2"}.bi-database-x:before{content:"\f8c3"}.bi-database:before{content:"\f8c4"}.bi-houses-fill:before{content:"\f8c5"}.bi-houses:before{content:"\f8c6"}.bi-nvidia:before{content:"\f8c7"}.bi-person-vcard-fill:before{content:"\f8c8"}.bi-person-vcard:before{content:"\f8c9"}.bi-sina-weibo:before{content:"\f8ca"}.bi-tencent-qq:before{content:"\f8cb"}.bi-wikipedia:before{content:"\f8cc"}.bi-alphabet-uppercase:before{content:"\f2a5"}.bi-alphabet:before{content:"\f68a"}.bi-amazon:before{content:"\f68d"}.bi-arrows-collapse-vertical:before{content:"\f690"}.bi-arrows-expand-vertical:before{content:"\f695"}.bi-arrows-vertical:before{content:"\f698"}.bi-arrows:before{content:"\f6a2"}.bi-ban-fill:before{content:"\f6a3"}.bi-ban:before{content:"\f6b6"}.bi-bing:before{content:"\f6c2"}.bi-cake:before{content:"\f6e0"}.bi-cake2:before{content:"\f6ed"}.bi-cookie:before{content:"\f6ee"}.bi-copy:before{content:"\f759"}.bi-crosshair:before{content:"\f769"}.bi-crosshair2:before{content:"\f794"}.bi-emoji-astonished-fill:before{content:"\f795"}.bi-emoji-astonished:before{content:"\f79a"}.bi-emoji-grimace-fill:before{content:"\f79b"}.bi-emoji-grimace:before{content:"\f7a0"}.bi-emoji-grin-fill:before{content:"\f7a1"}.bi-emoji-grin:before{content:"\f7a6"}.bi-emoji-surprise-fill:before{content:"\f7a7"}.bi-emoji-surprise:before{content:"\f7ac"}.bi-emoji-tear-fill:before{content:"\f7ad"}.bi-emoji-tear:before{content:"\f7b2"}.bi-envelope-arrow-down-fill:before{content:"\f7b3"}.bi-envelope-arrow-down:before{content:"\f7b8"}.bi-envelope-arrow-up-fill:before{content:"\f7b9"}.bi-envelope-arrow-up:before{content:"\f7be"}.bi-feather:before{content:"\f7bf"}.bi-feather2:before{content:"\f7c4"}.bi-floppy-fill:before{content:"\f7c5"}.bi-floppy:before{content:"\f7d8"}.bi-floppy2-fill:before{content:"\f7d9"}.bi-floppy2:before{content:"\f7e4"}.bi-gitlab:before{content:"\f7e5"}.bi-highlighter:before{content:"\f7f8"}.bi-marker-tip:before{content:"\f802"}.bi-nvme-fill:before{content:"\f803"}.bi-nvme:before{content:"\f80c"}.bi-opencollective:before{content:"\f80d"}.bi-pci-card-network:before{content:"\f8cd"}.bi-pci-card-sound:before{content:"\f8ce"}.bi-radar:before{content:"\f8cf"}.bi-send-arrow-down-fill:before{content:"\f8d0"}.bi-send-arrow-down:before{content:"\f8d1"}.bi-send-arrow-up-fill:before{content:"\f8d2"}.bi-send-arrow-up:before{content:"\f8d3"}.bi-sim-slash-fill:before{content:"\f8d4"}.bi-sim-slash:before{content:"\f8d5"}.bi-sourceforge:before{content:"\f8d6"}.bi-substack:before{content:"\f8d7"}.bi-threads-fill:before{content:"\f8d8"}.bi-threads:before{content:"\f8d9"}.bi-transparency:before{content:"\f8da"}.bi-twitter-x:before{content:"\f8db"}.bi-type-h4:before{content:"\f8dc"}.bi-type-h5:before{content:"\f8dd"}.bi-type-h6:before{content:"\f8de"}.bi-backpack-fill:before{content:"\f8df"}.bi-backpack:before{content:"\f8e0"}.bi-backpack2-fill:before{content:"\f8e1"}.bi-backpack2:before{content:"\f8e2"}.bi-backpack3-fill:before{content:"\f8e3"}.bi-backpack3:before{content:"\f8e4"}.bi-backpack4-fill:before{content:"\f8e5"}.bi-backpack4:before{content:"\f8e6"}.bi-brilliance:before{content:"\f8e7"}.bi-cake-fill:before{content:"\f8e8"}.bi-cake2-fill:before{content:"\f8e9"}.bi-duffle-fill:before{content:"\f8ea"}.bi-duffle:before{content:"\f8eb"}.bi-exposure:before{content:"\f8ec"}.bi-gender-neuter:before{content:"\f8ed"}.bi-highlights:before{content:"\f8ee"}.bi-luggage-fill:before{content:"\f8ef"}.bi-luggage:before{content:"\f8f0"}.bi-mailbox-flag:before{content:"\f8f1"}.bi-mailbox2-flag:before{content:"\f8f2"}.bi-noise-reduction:before{content:"\f8f3"}.bi-passport-fill:before{content:"\f8f4"}.bi-passport:before{content:"\f8f5"}.bi-person-arms-up:before{content:"\f8f6"}.bi-person-raised-hand:before{content:"\f8f7"}.bi-person-standing-dress:before{content:"\f8f8"}.bi-person-standing:before{content:"\f8f9"}.bi-person-walking:before{content:"\f8fa"}.bi-person-wheelchair:before{content:"\f8fb"}.bi-shadows:before{content:"\f8fc"}.bi-suitcase-fill:before{content:"\f8fd"}.bi-suitcase-lg-fill:before{content:"\f8fe"}.bi-suitcase-lg:before{content:"\f8ff"}.bi-suitcase:before{content:"\f900"}.bi-suitcase2-fill:before{content:"\f901"}.bi-suitcase2:before{content:"\f902"}.bi-vignette:before{content:"\f903"}*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: } -/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}:root,[data-theme]{background-color:var(--fallback-b1,oklch(var(--b1)/1));color:var(--fallback-bc,oklch(var(--bc)/1))}@supports not (color:oklch(0% 0 0)){:root{color-scheme:light;--fallback-p:#491eff;--fallback-pc:#d4dbff;--fallback-s:#ff41c7;--fallback-sc:#fff9fc;--fallback-a:#00cfbd;--fallback-ac:#00100d;--fallback-n:#2b3440;--fallback-nc:#d7dde4;--fallback-b1:#fff;--fallback-b2:#e5e6e6;--fallback-b3:#e5e6e6;--fallback-bc:#1f2937;--fallback-in:#00b3f0;--fallback-inc:#000;--fallback-su:#00ca92;--fallback-suc:#000;--fallback-wa:#ffc22d;--fallback-wac:#000;--fallback-er:#ff6f70;--fallback-erc:#000}@media (prefers-color-scheme:dark){:root{color-scheme:dark;--fallback-p:#7582ff;--fallback-pc:#050617;--fallback-s:#ff71cf;--fallback-sc:#190211;--fallback-a:#00c7b5;--fallback-ac:#000e0c;--fallback-n:#2a323c;--fallback-nc:#a6adbb;--fallback-b1:#1d232a;--fallback-b2:#191e24;--fallback-b3:#15191e;--fallback-bc:#a6adbb;--fallback-in:#00b3f0;--fallback-inc:#000;--fallback-su:#00ca92;--fallback-suc:#000;--fallback-wa:#ffc22d;--fallback-wac:#000;--fallback-er:#ff6f70;--fallback-erc:#000}}}html{-webkit-tap-highlight-color:transparent}*{scrollbar-color:color-mix(in oklch,currentColor 35%,transparent) transparent}:hover{scrollbar-color:color-mix(in oklch,currentColor 60%,transparent) transparent}:root{color-scheme:light;--in:72.06% 0.191 231.6;--su:64.8% 0.150 160;--wa:84.71% 0.199 83.87;--er:71.76% 0.221 22.18;--pc:89.824% 0.06192 275.75;--ac:15.352% 0.0368 183.61;--inc:0% 0 0;--suc:0% 0 0;--wac:0% 0 0;--erc:0% 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:49.12% 0.3096 275.75;--s:69.71% 0.329 342.55;--sc:98.71% 0.0106 342.55;--a:76.76% 0.184 183.61;--n:32.1785% 0.02476 255.701624;--nc:89.4994% 0.011585 252.096176;--b1:100% 0 0;--b2:96.1151% 0 0;--b3:92.4169% 0.00108 197.137559;--bc:27.8078% 0.029596 256.847952}@media (prefers-color-scheme:dark){:root{color-scheme:dark;--in:72.06% 0.191 231.6;--su:64.8% 0.150 160;--wa:84.71% 0.199 83.87;--er:71.76% 0.221 22.18;--pc:13.138% 0.0392 275.75;--sc:14.96% 0.052 342.55;--ac:14.902% 0.0334 183.61;--inc:0% 0 0;--suc:0% 0 0;--wac:0% 0 0;--erc:0% 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:65.69% 0.196 275.75;--s:74.8% 0.26 342.55;--a:74.51% 0.167 183.61;--n:31.3815% 0.021108 254.139175;--nc:74.6477% 0.0216 264.435964;--b1:25.3267% 0.015896 252.417568;--b2:23.2607% 0.013807 253.100675;--b3:21.1484% 0.01165 254.087939;--bc:74.6477% 0.0216 264.435964}}[data-theme=light]{color-scheme:light;--in:72.06% 0.191 231.6;--su:64.8% 0.150 160;--wa:84.71% 0.199 83.87;--er:71.76% 0.221 22.18;--pc:89.824% 0.06192 275.75;--ac:15.352% 0.0368 183.61;--inc:0% 0 0;--suc:0% 0 0;--wac:0% 0 0;--erc:0% 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:49.12% 0.3096 275.75;--s:69.71% 0.329 342.55;--sc:98.71% 0.0106 342.55;--a:76.76% 0.184 183.61;--n:32.1785% 0.02476 255.701624;--nc:89.4994% 0.011585 252.096176;--b1:100% 0 0;--b2:96.1151% 0 0;--b3:92.4169% 0.00108 197.137559;--bc:27.8078% 0.029596 256.847952}:root:has(input.theme-controller[value=light]:checked){color-scheme:light;--in:72.06% 0.191 231.6;--su:64.8% 0.150 160;--wa:84.71% 0.199 83.87;--er:71.76% 0.221 22.18;--pc:89.824% 0.06192 275.75;--ac:15.352% 0.0368 183.61;--inc:0% 0 0;--suc:0% 0 0;--wac:0% 0 0;--erc:0% 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:49.12% 0.3096 275.75;--s:69.71% 0.329 342.55;--sc:98.71% 0.0106 342.55;--a:76.76% 0.184 183.61;--n:32.1785% 0.02476 255.701624;--nc:89.4994% 0.011585 252.096176;--b1:100% 0 0;--b2:96.1151% 0 0;--b3:92.4169% 0.00108 197.137559;--bc:27.8078% 0.029596 256.847952}[data-theme=dark]{color-scheme:dark;--in:72.06% 0.191 231.6;--su:64.8% 0.150 160;--wa:84.71% 0.199 83.87;--er:71.76% 0.221 22.18;--pc:13.138% 0.0392 275.75;--sc:14.96% 0.052 342.55;--ac:14.902% 0.0334 183.61;--inc:0% 0 0;--suc:0% 0 0;--wac:0% 0 0;--erc:0% 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:65.69% 0.196 275.75;--s:74.8% 0.26 342.55;--a:74.51% 0.167 183.61;--n:31.3815% 0.021108 254.139175;--nc:74.6477% 0.0216 264.435964;--b1:25.3267% 0.015896 252.417568;--b2:23.2607% 0.013807 253.100675;--b3:21.1484% 0.01165 254.087939;--bc:74.6477% 0.0216 264.435964}:root:has(input.theme-controller[value=dark]:checked){color-scheme:dark;--in:72.06% 0.191 231.6;--su:64.8% 0.150 160;--wa:84.71% 0.199 83.87;--er:71.76% 0.221 22.18;--pc:13.138% 0.0392 275.75;--sc:14.96% 0.052 342.55;--ac:14.902% 0.0334 183.61;--inc:0% 0 0;--suc:0% 0 0;--wac:0% 0 0;--erc:0% 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:65.69% 0.196 275.75;--s:74.8% 0.26 342.55;--a:74.51% 0.167 183.61;--n:31.3815% 0.021108 254.139175;--nc:74.6477% 0.0216 264.435964;--b1:25.3267% 0.015896 252.417568;--b2:23.2607% 0.013807 253.100675;--b3:21.1484% 0.01165 254.087939;--bc:74.6477% 0.0216 264.435964}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.alert{display:grid;width:100%;grid-auto-flow:row;align-content:flex-start;align-items:center;justify-items:center;gap:1rem;text-align:center;border-radius:var(--rounded-box,1rem);border-width:1px;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));padding:1rem;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-b2,oklch(var(--b2)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1));background-color:var(--alert-bg)}@media (min-width:640px){.alert{grid-auto-flow:column;grid-template-columns:auto minmax(auto,1fr);justify-items:start;text-align:start}}.avatar.placeholder>div{display:flex;align-items:center;justify-content:center}.badge{display:inline-flex;align-items:center;justify-content:center;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1);transition-duration:.2s;height:1.25rem;font-size:.875rem;line-height:1.25rem;width:-moz-fit-content;width:fit-content;padding-left:.563rem;padding-right:.563rem;border-radius:var(--rounded-badge,1.9rem);border-width:1px;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}@media (hover:hover){.link-hover:hover{text-decoration-line:underline}.label a:hover{--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.menu li>:not(ul,.menu-title,details,.btn).active,.menu li>:not(ul,.menu-title,details,.btn):active,.menu li>details>summary:active{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.tab:hover{--tw-text-opacity:1}.table tr.hover:hover,.table tr.hover:nth-child(2n):hover{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))}}.btn{display:inline-flex;height:3rem;min-height:3rem;flex-shrink:0;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;flex-wrap:wrap;align-items:center;justify-content:center;border-radius:var(--rounded-btn,.5rem);border-color:transparent;border-color:oklch(var(--btn-color,var(--b2))/var(--tw-border-opacity));padding-left:1rem;padding-right:1rem;text-align:center;font-size:.875rem;line-height:1em;gap:.5rem;font-weight:600;text-decoration-line:none;transition-duration:.2s;transition-timing-function:cubic-bezier(0,0,.2,1);border-width:var(--border-btn,1px);transition-property:color,background-color,border-color,opacity,box-shadow,transform;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:var(--fallback-bc,oklch(var(--bc)/1));background-color:oklch(var(--btn-color,var(--b2))/var(--tw-bg-opacity));--tw-bg-opacity:1;--tw-border-opacity:1}.btn-disabled,.btn:disabled,.btn[disabled]{pointer-events:none}.btn-circle{height:3rem;width:3rem;border-radius:9999px;padding:0}:where(.btn:is(input[type=checkbox])),:where(.btn:is(input[type=radio])){width:auto;-webkit-appearance:none;-moz-appearance:none;appearance:none}.btn:is(input[type=checkbox]):after,.btn:is(input[type=radio]):after{--tw-content:attr(aria-label);content:var(--tw-content)}.card{position:relative;display:flex;flex-direction:column;border-radius:var(--rounded-box,1rem)}.card:focus{outline:2px solid transparent;outline-offset:2px}.card-body{display:flex;flex:1 1 auto;flex-direction:column;padding:var(--padding-card,2rem);gap:.5rem}.card-body :where(p){flex-grow:1}.card figure{display:flex;align-items:center;justify-content:center}.card.image-full{display:grid}.card.image-full:before{position:relative;content:"";z-index:10;border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));opacity:.75}.card.image-full:before,.card.image-full>*{grid-column-start:1;grid-row-start:1}.card.image-full>figure img{height:100%;-o-object-fit:cover;object-fit:cover}.card.image-full>.card-body{position:relative;z-index:20;--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.checkbox{flex-shrink:0;--chkbg:var(--fallback-bc,oklch(var(--bc)/1));--chkfg:var(--fallback-b1,oklch(var(--b1)/1));height:1.5rem;width:1.5rem;cursor:pointer;-webkit-appearance:none;-moz-appearance:none;appearance:none;border-radius:var(--rounded-btn,.5rem);border-width:1px;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:0.2}.divider{display:flex;flex-direction:row;align-items:center;align-self:stretch;margin-top:1rem;margin-bottom:1rem;height:1rem;white-space:nowrap}.divider:after,.divider:before{height:.125rem;width:100%;flex-grow:1;--tw-content:"";content:var(--tw-content);background-color:var(--fallback-bc,oklch(var(--bc)/.1))}.dropdown{position:relative;display:inline-block}.dropdown>:not(summary):focus{outline:2px solid transparent;outline-offset:2px}.dropdown .dropdown-content{position:absolute}.dropdown:is(:not(details)) .dropdown-content{visibility:hidden;opacity:0;transform-origin:top;--tw-scale-x:.95;--tw-scale-y:.95;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1);transition-duration:.2s}.dropdown-end .dropdown-content{inset-inline-end:0}.dropdown-left .dropdown-content{bottom:auto;inset-inline-end:100%;top:0;transform-origin:right}.dropdown-right .dropdown-content{bottom:auto;inset-inline-start:100%;top:0;transform-origin:left}.dropdown-bottom .dropdown-content{bottom:auto;top:100%;transform-origin:top}.dropdown-top .dropdown-content{bottom:100%;top:auto;transform-origin:bottom}.dropdown-end.dropdown-left .dropdown-content,.dropdown-end.dropdown-right .dropdown-content{bottom:0;top:auto}.dropdown.dropdown-open .dropdown-content,.dropdown:focus-within .dropdown-content,.dropdown:not(.dropdown-hover):focus .dropdown-content{visibility:visible;opacity:1}@media (hover:hover){.dropdown.dropdown-hover:hover .dropdown-content{visibility:visible;opacity:1}.btm-nav>.\!disabled:hover{pointer-events:none!important;--tw-border-opacity:0!important;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)))!important;--tw-bg-opacity:0.1!important;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))!important;--tw-text-opacity:0.2!important}.btm-nav>.disabled:hover,.btm-nav>[disabled]:hover{pointer-events:none;--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.btn:hover{--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn:hover{background-color:color-mix(in oklab,oklch(var(--btn-color,var(--b2))/var(--tw-bg-opacity,1)) 90%,#000);border-color:color-mix(in oklab,oklch(var(--btn-color,var(--b2))/var(--tw-border-opacity,1)) 90%,#000)}}@supports not (color:oklch(0% 0 0)){.btn:hover{background-color:var(--btn-color,var(--fallback-b2));border-color:var(--btn-color,var(--fallback-b2))}}.btn.glass:hover{--glass-opacity:25%;--glass-border-opacity:15%}.btn-ghost:hover{border-color:transparent}@supports (color:oklch(0% 0 0)){.btn-ghost:hover{background-color:var(--fallback-bc,oklch(var(--bc)/.2))}}.btn-outline:hover{--tw-border-opacity:1;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-b1,oklch(var(--b1)/var(--tw-text-opacity)))}.btn-outline.btn-primary:hover{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-primary:hover{background-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000)}}.btn-outline.btn-secondary:hover{--tw-text-opacity:1;color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-secondary:hover{background-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000)}}.btn-outline.btn-accent:hover{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-accent:hover{background-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000)}}.btn-outline.btn-success:hover{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-success:hover{background-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000)}}.btn-outline.btn-info:hover{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-info:hover{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}}.btn-outline.btn-warning:hover{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-warning:hover{background-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000)}}.btn-outline.btn-error:hover{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-error:hover{background-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000)}}.btn-disabled:hover,.btn:disabled:hover,.btn[disabled]:hover{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}@supports (color:color-mix(in oklab,black,black)){.btn:is(input[type=checkbox]:checked):hover,.btn:is(input[type=radio]:checked):hover{background-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000)}}.dropdown.dropdown-hover:hover .dropdown-content{--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(.active,.btn):hover,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.active,.btn):hover{cursor:pointer;outline:2px solid transparent;outline-offset:2px}@supports (color:oklch(0% 0 0)){:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(.active,.btn):hover,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.active,.btn):hover{background-color:var(--fallback-bc,oklch(var(--bc)/.1))}}.tab[disabled],.tab[disabled]:hover{cursor:not-allowed;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}}.dropdown:is(details) summary::-webkit-details-marker{display:none}.file-input{height:3rem;flex-shrink:1;padding-inline-end:1rem;font-size:1rem;line-height:2;line-height:1.5rem;overflow:hidden;border-radius:var(--rounded-btn,.5rem);border-width:1px;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:0;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.file-input::file-selector-button{margin-inline-end:1rem;display:inline-flex;height:100%;flex-shrink:0;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;flex-wrap:wrap;align-items:center;justify-content:center;padding-left:1rem;padding-right:1rem;text-align:center;font-size:.875rem;line-height:1.25rem;line-height:1em;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1);transition-duration:.2s;border-style:solid;--tw-border-opacity:1;border-color:var(--fallback-n,oklch(var(--n)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));font-weight:600;text-transform:uppercase;--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)));text-decoration-line:none;border-width:var(--border-btn,1px);animation:button-pop var(--animation-btn,.25s) ease-out}.footer{width:100%;grid-auto-flow:row;-moz-column-gap:1rem;column-gap:1rem;row-gap:2.5rem;font-size:.875rem;line-height:1.25rem}.footer,.footer>*{display:grid;place-items:start}.footer>*{gap:.5rem}.footer-center{text-align:center}.footer-center,.footer-center>*{place-items:center}@media (min-width:48rem){.footer{grid-auto-flow:column}.footer-center{grid-auto-flow:row dense}}.form-control{flex-direction:column}.form-control,.label{display:flex}.label{-webkit-user-select:none;-moz-user-select:none;user-select:none;align-items:center;justify-content:space-between;padding:.5rem .25rem}.hero{display:grid;width:100%;place-items:center;background-size:cover;background-position:50%}.hero>*{grid-column-start:1;grid-row-start:1}.hero-content{z-index:0;display:flex;align-items:center;justify-content:center;max-width:80rem;gap:1rem;padding:1rem}.indicator{position:relative;display:inline-flex;width:-moz-max-content;width:max-content}.indicator :where(.indicator-item){z-index:1;position:absolute;white-space:nowrap}.\!input{flex-shrink:1!important;-webkit-appearance:none!important;-moz-appearance:none!important;appearance:none!important;height:3rem!important;padding-left:1rem!important;padding-right:1rem!important;font-size:1rem!important;line-height:2!important;line-height:1.5rem!important;border-radius:var(--rounded-btn,.5rem)!important;border-width:1px!important;border-color:transparent!important;--tw-bg-opacity:1!important;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))!important}.input{flex-shrink:1;-webkit-appearance:none;-moz-appearance:none;appearance:none;height:3rem;padding-left:1rem;padding-right:1rem;font-size:1rem;line-height:2;line-height:1.5rem;border-radius:var(--rounded-btn,.5rem);border-width:1px;border-color:transparent;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.\!input[type=number]::-webkit-inner-spin-button{margin-top:-1rem!important;margin-bottom:-1rem!important;margin-inline-end:-1rem!important}.input-md[type=number]::-webkit-inner-spin-button,.input[type=number]::-webkit-inner-spin-button{margin-top:-1rem;margin-bottom:-1rem;margin-inline-end:-1rem}.input-sm[type=number]::-webkit-inner-spin-button{margin-top:0;margin-bottom:0;margin-inline-end:0}.join{display:inline-flex;align-items:stretch;border-radius:var(--rounded-btn,.5rem)}.join :where(.join-item){border-start-end-radius:0;border-end-end-radius:0;border-end-start-radius:0;border-start-start-radius:0}.join .join-item:not(:first-child):not(:last-child),.join :not(:first-child):not(:last-child) .join-item{border-start-end-radius:0;border-end-end-radius:0;border-end-start-radius:0;border-start-start-radius:0}.join .join-item:first-child:not(:last-child),.join :first-child:not(:last-child) .join-item{border-start-end-radius:0;border-end-end-radius:0}.join .dropdown .join-item:first-child:not(:last-child),.join :first-child:not(:last-child) .dropdown .join-item{border-start-end-radius:inherit;border-end-end-radius:inherit}.join :where(.join-item:first-child:not(:last-child)),.join :where(:first-child:not(:last-child) .join-item){border-end-start-radius:inherit;border-start-start-radius:inherit}.join .join-item:last-child:not(:first-child),.join :last-child:not(:first-child) .join-item{border-end-start-radius:0;border-start-start-radius:0}.join :where(.join-item:last-child:not(:first-child)),.join :where(:last-child:not(:first-child) .join-item){border-start-end-radius:inherit;border-end-end-radius:inherit}@supports not selector(:has(*)){:where(.join *){border-radius:inherit}}@supports selector(:has(*)){:where(.join :has(.join-item)){border-radius:inherit}}.kbd{display:inline-flex;align-items:center;justify-content:center;border-radius:var(--rounded-btn,.5rem);border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:0.2;--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));padding-left:.5rem;padding-right:.5rem;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));border-width:1px 1px 2px;min-height:2.2em;min-width:2.2em}.link{cursor:pointer;text-decoration-line:underline}.link-hover{text-decoration-line:none}.menu{display:flex;flex-direction:column;flex-wrap:wrap;font-size:.875rem;line-height:1.25rem;padding:.5rem}.menu :where(li ul){position:relative;white-space:nowrap;margin-inline-start:1rem;padding-inline-start:.5rem}.menu :where(li:not(.menu-title)>:not(ul,details,.menu-title,.btn)),.menu :where(li:not(.menu-title)>details>summary:not(.menu-title)){display:grid;grid-auto-flow:column;align-content:flex-start;align-items:center;gap:.5rem;grid-auto-columns:minmax(auto,max-content) auto max-content;-webkit-user-select:none;-moz-user-select:none;user-select:none}.menu li.disabled{cursor:not-allowed;-webkit-user-select:none;-moz-user-select:none;user-select:none;color:var(--fallback-bc,oklch(var(--bc)/.3))}.menu li.\!disabled{cursor:not-allowed!important;-webkit-user-select:none!important;-moz-user-select:none!important;user-select:none!important;color:var(--fallback-bc,oklch(var(--bc)/.3))!important}.menu :where(li>.menu-dropdown:not(.menu-dropdown-show)){display:none}:where(.menu li){position:relative;display:flex;flex-shrink:0;flex-direction:column;flex-wrap:wrap;align-items:stretch}:where(.menu li) .badge{justify-self:end}.modal{pointer-events:none;position:fixed;inset:0;margin:0;display:grid;height:100%;max-height:none;width:100%;max-width:none;justify-items:center;padding:0;opacity:0;overscroll-behavior:contain;z-index:999;background-color:transparent;color:inherit;transition-duration:.2s;transition-timing-function:cubic-bezier(0,0,.2,1);transition-property:transform,opacity,visibility;overflow-y:hidden}:where(.modal){align-items:center}.modal-box{max-height:calc(100vh - 5em);grid-column-start:1;grid-row-start:1;width:91.666667%;max-width:32rem;--tw-scale-x:.9;--tw-scale-y:.9;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));border-bottom-right-radius:var(--rounded-box,1rem);border-bottom-left-radius:var(--rounded-box,1rem);border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));padding:1.5rem;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1);transition-duration:.2s;box-shadow:0 25px 50px -12px rgba(0,0,0,.25);overflow-y:auto;overscroll-behavior:contain}.modal-open,.modal-toggle:checked+.modal,.modal:target,.modal[open]{pointer-events:auto;visibility:visible;opacity:1}.modal-action{display:flex;margin-top:1.5rem;justify-content:flex-end}:root:has(:is(.modal-open,.modal:target,.modal-toggle:checked+.modal,.modal[open])){overflow:hidden;scrollbar-gutter:stable}.navbar{display:flex;align-items:center;padding:var(--navbar-padding,.5rem);min-height:4rem;width:100%}:where(.navbar>:not(script,style)){display:inline-flex;align-items:center}.navbar-start{width:50%;justify-content:flex-start}.navbar-center{flex-shrink:0}.navbar-end{width:50%;justify-content:flex-end}.progress{position:relative;width:100%;-webkit-appearance:none;-moz-appearance:none;appearance:none;overflow:hidden;height:.5rem;border-radius:var(--rounded-box,1rem);background-color:var(--fallback-bc,oklch(var(--bc)/.2))}.radio{flex-shrink:0;--chkbg:var(--bc);width:1.5rem;-webkit-appearance:none;border-radius:9999px;border-width:1px;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:0.2}.radio,.range{height:1.5rem;cursor:pointer;-moz-appearance:none;appearance:none}.range{width:100%;-webkit-appearance:none;--range-shdw:var(--fallback-bc,oklch(var(--bc)/1));overflow:hidden;border-radius:var(--rounded-box,1rem);background-color:transparent}.range:focus{outline:none}.select{display:inline-flex;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;-webkit-appearance:none;-moz-appearance:none;appearance:none;height:3rem;min-height:3rem;padding-inline-start:1rem;padding-inline-end:2.5rem;font-size:.875rem;line-height:1.25rem;line-height:2;border-radius:var(--rounded-btn,.5rem);border-width:1px;border-color:transparent;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));background-image:linear-gradient(45deg,transparent 50%,currentColor 0),linear-gradient(135deg,currentColor 50%,transparent 0);background-position:calc(100% - 20px) calc(1px + 50%),calc(100% - 16.1px) calc(1px + 50%);background-size:4px 4px,4px 4px;background-repeat:no-repeat}.select[multiple]{height:auto}.stack{display:inline-grid;place-items:center;align-items:flex-end}.stack>*{grid-column-start:1;grid-row-start:1;transform:translateY(10%) scale(.9);z-index:1;width:100%;opacity:.6}.stack>:nth-child(2){transform:translateY(5%) scale(.95);z-index:2;opacity:.8}.stack>:first-child{transform:translateY(0) scale(1);z-index:3;opacity:1}.stats{display:inline-grid;border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}:where(.stats){grid-auto-flow:column;overflow-x:auto}.steps .step{display:grid;grid-template-columns:repeat(1,minmax(0,1fr));grid-template-columns:auto;grid-template-rows:repeat(2,minmax(0,1fr));grid-template-rows:40px 1fr;place-items:center;text-align:center;min-width:4rem}.swap{position:relative;display:inline-grid;-webkit-user-select:none;-moz-user-select:none;user-select:none;place-content:center;cursor:pointer}.swap>*{grid-column-start:1;grid-row-start:1;transition-duration:.3s;transition-timing-function:cubic-bezier(0,0,.2,1);transition-property:transform,opacity}.swap input{-webkit-appearance:none;-moz-appearance:none;appearance:none}.swap .swap-indeterminate,.swap .swap-on,.swap input:indeterminate~.swap-on{opacity:0}.swap input:checked~.swap-off,.swap input:indeterminate~.swap-off,.swap-active .swap-off{opacity:0}.swap input:checked~.swap-on,.swap input:indeterminate~.swap-indeterminate,.swap-active .swap-on{opacity:1}.tabs{display:grid;align-items:flex-end}.tabs-lifted:has(.tab-content[class*=" rounded-"]) .tab:first-child:not(:is(.tab-active,[aria-selected=true])),.tabs-lifted:has(.tab-content[class^=rounded-]) .tab:first-child:not(:is(.tab-active,[aria-selected=true])){border-bottom-color:transparent}.tab{position:relative;grid-row-start:1;display:inline-flex;height:2rem;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;-webkit-appearance:none;-moz-appearance:none;appearance:none;flex-wrap:wrap;align-items:center;justify-content:center;text-align:center;font-size:.875rem;line-height:1.25rem;line-height:2;--tab-padding:1rem;--tw-text-opacity:0.5;--tab-color:var(--fallback-bc,oklch(var(--bc)/1));--tab-bg:var(--fallback-b1,oklch(var(--b1)/1));--tab-border-color:var(--fallback-b3,oklch(var(--b3)/1));color:var(--tab-color);padding-inline-start:var(--tab-padding,1rem);padding-inline-end:var(--tab-padding,1rem)}.tab:is(input[type=radio]){width:auto;border-bottom-right-radius:0;border-bottom-left-radius:0}.tab:is(input[type=radio]):after{--tw-content:attr(aria-label);content:var(--tw-content)}.tab:not(input):empty{cursor:default;grid-column-start:span 9999}.tab-content{grid-column-start:1;grid-column-end:span 9999;grid-row-start:2;margin-top:calc(var(--tab-border)*-1);display:none;border-color:transparent;border-width:var(--tab-border,0)}:checked+.tab-content:nth-child(2),:is(.tab-active,[aria-selected=true])+.tab-content:nth-child(2){border-start-start-radius:0}:is(.tab-active,[aria-selected=true])+.tab-content,input.tab:checked+.tab-content{display:block}.table{position:relative;width:100%;border-radius:var(--rounded-box,1rem);text-align:left;font-size:.875rem;line-height:1.25rem}.table :where(.table-pin-rows thead tr){position:sticky;top:0;z-index:1;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.table :where(.table-pin-rows tfoot tr){position:sticky;bottom:0;z-index:1;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.table :where(.table-pin-cols tr th){position:sticky;left:0;right:0;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.textarea{min-height:3rem;flex-shrink:1;padding:.5rem 1rem;font-size:.875rem;line-height:1.25rem;line-height:2;border-radius:var(--rounded-btn,.5rem);border-width:1px;border-color:transparent;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.toast{position:fixed;display:flex;min-width:-moz-fit-content;min-width:fit-content;flex-direction:column;white-space:nowrap;gap:.5rem;padding:1rem}.toggle{flex-shrink:0;--tglbg:var(--fallback-b1,oklch(var(--b1)/1));--handleoffset:1.5rem;--handleoffsetcalculator:calc(var(--handleoffset)*-1);--togglehandleborder:0 0;height:1.5rem;width:3rem;cursor:pointer;-webkit-appearance:none;-moz-appearance:none;appearance:none;border-radius:var(--rounded-badge,1.9rem);border-width:1px;border-color:currentColor;background-color:currentColor;color:var(--fallback-bc,oklch(var(--bc)/.5));transition:background,box-shadow var(--animation-input,.2s) ease-out;box-shadow:var(--handleoffsetcalculator) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset,var(--togglehandleborder)}.alert-info{border-color:var(--fallback-in,oklch(var(--in)/.2));--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-in,oklch(var(--in)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.alert-success{border-color:var(--fallback-su,oklch(var(--su)/.2));--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-su,oklch(var(--su)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.alert-warning{border-color:var(--fallback-wa,oklch(var(--wa)/.2));--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)));--alert-bg:var(--fallback-wa,oklch(var(--wa)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.alert-error{border-color:var(--fallback-er,oklch(var(--er)/.2));--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-er,oklch(var(--er)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.badge-primary{border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.badge-primary,.badge-secondary{--tw-border-opacity:1;--tw-bg-opacity:1;--tw-text-opacity:1}.badge-secondary{border-color:var(--fallback-s,oklch(var(--s)/var(--tw-border-opacity)));background-color:var(--fallback-s,oklch(var(--s)/var(--tw-bg-opacity)));color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)))}.badge-accent{--tw-border-opacity:1;border-color:var(--fallback-a,oklch(var(--a)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-a,oklch(var(--a)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity)))}.badge-info{background-color:var(--fallback-in,oklch(var(--in)/var(--tw-bg-opacity)));color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}.badge-info,.badge-success{border-color:transparent;--tw-bg-opacity:1;--tw-text-opacity:1}.badge-success{background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity)));color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}.badge-warning{background-color:var(--fallback-wa,oklch(var(--wa)/var(--tw-bg-opacity)));color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}.badge-error,.badge-warning{border-color:transparent;--tw-bg-opacity:1;--tw-text-opacity:1}.badge-error{background-color:var(--fallback-er,oklch(var(--er)/var(--tw-bg-opacity)));color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)))}.badge-ghost{--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.badge-outline.badge-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}.badge-outline.badge-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity)))}.badge-outline.badge-accent{--tw-text-opacity:1;color:var(--fallback-a,oklch(var(--a)/var(--tw-text-opacity)))}.badge-outline.badge-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.badge-outline.badge-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity)))}.badge-outline.badge-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity)))}.badge-outline.badge-error{--tw-text-opacity:1;color:var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity)))}.btm-nav>:where(.active){border-top-width:2px;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.btm-nav>.\!disabled{pointer-events:none!important;--tw-border-opacity:0!important;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)))!important;--tw-bg-opacity:0.1!important;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))!important;--tw-text-opacity:0.2!important}.btm-nav>.disabled,.btm-nav>[disabled]{pointer-events:none;--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.btm-nav>* .label{font-size:1rem;line-height:1.5rem}@media (prefers-reduced-motion:no-preference){.btn{animation:button-pop var(--animation-btn,.25s) ease-out}}.btn:active:focus,.btn:active:hover{animation:button-pop 0s ease-out;transform:scale(var(--btn-focus-scale,.97))}@supports not (color:oklch(0% 0 0)){.btn{background-color:var(--btn-color,var(--fallback-b2));border-color:var(--btn-color,var(--fallback-b2))}.btn-primary{--btn-color:var(--fallback-p)}.btn-secondary{--btn-color:var(--fallback-s)}.btn-success{--btn-color:var(--fallback-su)}.btn-warning{--btn-color:var(--fallback-wa)}.btn-error{--btn-color:var(--fallback-er)}}@supports (color:color-mix(in oklab,black,black)){.btn-active{background-color:color-mix(in oklab,oklch(var(--btn-color,var(--b3))/var(--tw-bg-opacity,1)) 90%,#000);border-color:color-mix(in oklab,oklch(var(--btn-color,var(--b3))/var(--tw-border-opacity,1)) 90%,#000)}.btn-outline.btn-primary.btn-active{background-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000)}.btn-outline.btn-secondary.btn-active{background-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000)}.btn-outline.btn-accent.btn-active{background-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000)}.btn-outline.btn-success.btn-active{background-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000)}.btn-outline.btn-info.btn-active{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}.btn-outline.btn-warning.btn-active{background-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000)}.btn-outline.btn-error.btn-active{background-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000)}}.btn:focus-visible{outline-style:solid;outline-width:2px;outline-offset:2px}.btn-primary{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)));outline-color:var(--fallback-p,oklch(var(--p)/1))}@supports (color:oklch(0% 0 0)){.btn-primary{--btn-color:var(--p)}.btn-secondary{--btn-color:var(--s)}.btn-success{--btn-color:var(--su)}.btn-warning{--btn-color:var(--wa)}.btn-error{--btn-color:var(--er)}}.btn-secondary{--tw-text-opacity:1;color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)));outline-color:var(--fallback-s,oklch(var(--s)/1))}.btn-success{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)));outline-color:var(--fallback-su,oklch(var(--su)/1))}.btn-warning{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)));outline-color:var(--fallback-wa,oklch(var(--wa)/1))}.btn-error{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)));outline-color:var(--fallback-er,oklch(var(--er)/1))}.btn.glass{--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.btn.glass.btn-active{--glass-opacity:25%;--glass-border-opacity:15%}.btn-ghost{border-width:1px;border-color:transparent;background-color:transparent;color:currentColor;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.btn-ghost.btn-active{border-color:transparent;background-color:var(--fallback-bc,oklch(var(--bc)/.2))}.btn-link.btn-active{border-color:transparent;background-color:transparent;text-decoration-line:underline}.btn-outline{border-color:currentColor;background-color:transparent;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.btn-outline.btn-active{--tw-border-opacity:1;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-b1,oklch(var(--b1)/var(--tw-text-opacity)))}.btn-outline.btn-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}.btn-outline.btn-primary.btn-active{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.btn-outline.btn-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity)))}.btn-outline.btn-secondary.btn-active{--tw-text-opacity:1;color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)))}.btn-outline.btn-accent{--tw-text-opacity:1;color:var(--fallback-a,oklch(var(--a)/var(--tw-text-opacity)))}.btn-outline.btn-accent.btn-active{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity)))}.btn-outline.btn-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity)))}.btn-outline.btn-success.btn-active{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}.btn-outline.btn-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.btn-outline.btn-info.btn-active{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}.btn-outline.btn-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity)))}.btn-outline.btn-warning.btn-active{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}.btn-outline.btn-error{--tw-text-opacity:1;color:var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity)))}.btn-outline.btn-error.btn-active{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)))}.btn.btn-disabled,.btn:disabled,.btn[disabled]{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.btn:is(input[type=checkbox]:checked),.btn:is(input[type=radio]:checked){--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.btn:is(input[type=checkbox]:checked):focus-visible,.btn:is(input[type=radio]:checked):focus-visible{outline-color:var(--fallback-p,oklch(var(--p)/1))}@keyframes button-pop{0%{transform:scale(var(--btn-focus-scale,.98))}40%{transform:scale(1.02)}to{transform:scale(1)}}.card :where(figure:first-child){overflow:hidden;border-start-start-radius:inherit;border-start-end-radius:inherit;border-end-start-radius:unset;border-end-end-radius:unset}.card :where(figure:last-child){overflow:hidden;border-start-start-radius:unset;border-start-end-radius:unset;border-end-start-radius:inherit;border-end-end-radius:inherit}.card:focus-visible{outline:2px solid currentColor;outline-offset:2px}.card.bordered{border-width:1px;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))}.card.compact .card-body{padding:1rem;font-size:.875rem;line-height:1.25rem}.card-title{display:flex;align-items:center;gap:.5rem;font-size:1.25rem;line-height:1.75rem;font-weight:600}.card.image-full :where(figure){overflow:hidden;border-radius:inherit}.checkbox:focus{box-shadow:none}.checkbox:focus-visible{outline-style:solid;outline-width:2px;outline-offset:2px;outline-color:var(--fallback-bc,oklch(var(--bc)/1))}.checkbox:disabled{border-width:0;cursor:not-allowed;border-color:transparent;--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));opacity:.2}.checkbox:checked,.checkbox[aria-checked=true]{background-repeat:no-repeat;animation:checkmark var(--animation-input,.2s) ease-out;background-color:var(--chkbg);background-image:linear-gradient(-45deg,transparent 65%,var(--chkbg) 65.99%),linear-gradient(45deg,transparent 75%,var(--chkbg) 75.99%),linear-gradient(-45deg,var(--chkbg) 40%,transparent 40.99%),linear-gradient(45deg,var(--chkbg) 30%,var(--chkfg) 30.99%,var(--chkfg) 40%,transparent 40.99%),linear-gradient(-45deg,var(--chkfg) 50%,var(--chkbg) 50.99%)}.checkbox:indeterminate{--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));background-repeat:no-repeat;animation:checkmark var(--animation-input,.2s) ease-out;background-image:linear-gradient(90deg,transparent 80%,var(--chkbg) 80%),linear-gradient(-90deg,transparent 80%,var(--chkbg) 80%),linear-gradient(0deg,var(--chkbg) 43%,var(--chkfg) 43%,var(--chkfg) 57%,var(--chkbg) 57%)}@keyframes checkmark{0%{background-position-y:5px}50%{background-position-y:-2px}to{background-position-y:0}}.divider:not(:empty){gap:1rem}.dropdown.dropdown-open .dropdown-content,.dropdown:focus .dropdown-content,.dropdown:focus-within .dropdown-content{--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.file-input-bordered{--tw-border-opacity:0.2}.file-input:focus{outline-style:solid;outline-width:2px;outline-offset:2px;outline-color:var(--fallback-bc,oklch(var(--bc)/.2))}.file-input-disabled,.file-input[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));--tw-text-opacity:0.2}.file-input-disabled::-moz-placeholder,.file-input[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.file-input-disabled::placeholder,.file-input[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.file-input-disabled::file-selector-button,.file-input[disabled]::file-selector-button{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.label-text{font-size:.875rem;line-height:1.25rem}.label-text,.label-text-alt{--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.label-text-alt{font-size:.75rem;line-height:1rem}.\!input input{--tw-bg-opacity:1!important;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)))!important;background-color:transparent!important}.input input{--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));background-color:transparent}.\!input input:focus{outline:2px solid transparent!important;outline-offset:2px!important}.input input:focus{outline:2px solid transparent;outline-offset:2px}.\!input[list]::-webkit-calendar-picker-indicator{line-height:1em!important}.input[list]::-webkit-calendar-picker-indicator{line-height:1em}.input-bordered{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.input:focus,.input:focus-within{box-shadow:none;border-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-style:solid;outline-width:2px;outline-offset:2px;outline-color:var(--fallback-bc,oklch(var(--bc)/.2))}.\!input:focus,.\!input:focus-within{box-shadow:none!important;border-color:var(--fallback-bc,oklch(var(--bc)/.2))!important;outline-style:solid!important;outline-width:2px!important;outline-offset:2px!important;outline-color:var(--fallback-bc,oklch(var(--bc)/.2))!important}.input-error{--tw-border-opacity:1;border-color:var(--fallback-er,oklch(var(--er)/var(--tw-border-opacity)))}.input-error:focus,.input-error:focus-within{--tw-border-opacity:1;border-color:var(--fallback-er,oklch(var(--er)/var(--tw-border-opacity)));outline-color:var(--fallback-er,oklch(var(--er)/1))}.input-disabled,.input:disabled,.input:has(>input[disabled]),.input[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));color:var(--fallback-bc,oklch(var(--bc)/.4))}.\!input:disabled,.\!input:has(>input[disabled]),.\!input[disabled]{cursor:not-allowed!important;--tw-border-opacity:1!important;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))!important;--tw-bg-opacity:1!important;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))!important;color:var(--fallback-bc,oklch(var(--bc)/.4))!important}.input-disabled::-moz-placeholder,.input:disabled::-moz-placeholder,.input:has(>input[disabled])::-moz-placeholder,.input[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.input-disabled::placeholder,.input:disabled::placeholder,.input:has(>input[disabled])::placeholder,.input[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.\!input:disabled::-moz-placeholder,.\!input:has(>input[disabled])::-moz-placeholder,.\!input[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)))!important;--tw-placeholder-opacity:0.2!important}.\!input:disabled::placeholder,.\!input:has(>input[disabled])::placeholder,.\!input[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)))!important;--tw-placeholder-opacity:0.2!important}.\!input:has(>input[disabled])>input[disabled]{cursor:not-allowed!important}.input:has(>input[disabled])>input[disabled]{cursor:not-allowed}.\!input::-webkit-date-and-time-value{text-align:inherit!important}.input::-webkit-date-and-time-value{text-align:inherit}.join>:where(:not(:first-child)){margin-top:0;margin-bottom:0;margin-inline-start:-1px}.join>:where(:not(:first-child)):is(.btn){margin-inline-start:calc(var(--border-btn)*-1)}.join-item:focus{isolation:isolate}.link-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){@media (hover:hover){.link-primary:hover{color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 80%,#000)}}}.link:focus{outline:2px solid transparent;outline-offset:2px}.link:focus-visible{outline:2px solid currentColor;outline-offset:2px}.loading{pointer-events:none;display:inline-block;aspect-ratio:1/1;width:1.5rem;background-color:currentColor;-webkit-mask-size:100%;mask-size:100%;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-position:center;mask-position:center}.loading,.loading-spinner{-webkit-mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' stroke='%23000'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-linecap='round' stroke-width='3'%3E%3CanimateTransform attributeName='transform' dur='2s' from='0 12 12' repeatCount='indefinite' to='360 12 12' type='rotate'/%3E%3Canimate attributeName='stroke-dasharray' dur='1.5s' keyTimes='0;0.475;1' repeatCount='indefinite' values='0,150;42,150;42,150'/%3E%3Canimate attributeName='stroke-dashoffset' dur='1.5s' keyTimes='0;0.475;1' repeatCount='indefinite' values='0;-16;-59'/%3E%3C/circle%3E%3C/svg%3E");mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' stroke='%23000'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-linecap='round' stroke-width='3'%3E%3CanimateTransform attributeName='transform' dur='2s' from='0 12 12' repeatCount='indefinite' to='360 12 12' type='rotate'/%3E%3Canimate attributeName='stroke-dasharray' dur='1.5s' keyTimes='0;0.475;1' repeatCount='indefinite' values='0,150;42,150;42,150'/%3E%3Canimate attributeName='stroke-dashoffset' dur='1.5s' keyTimes='0;0.475;1' repeatCount='indefinite' values='0;-16;-59'/%3E%3C/circle%3E%3C/svg%3E")}.loading-sm{width:1.25rem}.loading-lg{width:2.5rem}:where(.menu li:empty){--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));opacity:.1;margin:.5rem 1rem;height:1px}.menu :where(li ul):before{position:absolute;bottom:.75rem;inset-inline-start:0;top:.75rem;width:1px;--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));opacity:.1;content:""}.menu :where(li:not(.menu-title)>:not(ul,details,.menu-title,.btn)),.menu :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--rounded-btn,.5rem);padding:.5rem 1rem;text-align:start;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1);transition-duration:.2s;text-wrap:balance}:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):is(summary):not(.active,.btn):focus-visible,:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(summary,.active,.btn).focus,:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(summary,.active,.btn):focus,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):is(summary):not(.active,.btn):focus-visible,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(summary,.active,.btn).focus,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(summary,.active,.btn):focus{cursor:pointer;background-color:var(--fallback-bc,oklch(var(--bc)/.1));--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));outline:2px solid transparent;outline-offset:2px}.menu li>:not(ul,.menu-title,details,.btn).active,.menu li>:not(ul,.menu-title,details,.btn):active,.menu li>details>summary:active{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.menu :where(li>details>summary)::-webkit-details-marker{display:none}.menu :where(li>.menu-dropdown-toggle):after,.menu :where(li>details>summary):after{justify-self:end;display:block;margin-top:-.5rem;height:.5rem;width:.5rem;transform:rotate(45deg);transition-property:transform,margin-top;transition-duration:.3s;transition-timing-function:cubic-bezier(.4,0,.2,1);content:"";transform-origin:75% 75%;box-shadow:2px 2px;pointer-events:none}.menu :where(li>.menu-dropdown-toggle.menu-dropdown-show):after,.menu :where(li>details[open]>summary):after{transform:rotate(225deg);margin-top:0}.menu-title{padding:.5rem 1rem;font-size:.875rem;line-height:1.25rem;font-weight:700;color:var(--fallback-bc,oklch(var(--bc)/.4))}.mockup-phone .display{overflow:hidden;border-radius:40px;margin-top:-25px}.mockup-browser .mockup-browser-toolbar .\!input{position:relative!important;margin-left:auto!important;margin-right:auto!important;display:block!important;height:1.75rem!important;width:24rem!important;overflow:hidden!important;text-overflow:ellipsis!important;white-space:nowrap!important;--tw-bg-opacity:1!important;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))!important;padding-left:2rem!important;direction:ltr!important}.mockup-browser .mockup-browser-toolbar .input{position:relative;margin-left:auto;margin-right:auto;display:block;height:1.75rem;width:24rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));padding-left:2rem;direction:ltr}.mockup-browser .mockup-browser-toolbar .\!input:before{content:""!important;position:absolute!important;left:.5rem!important;top:50%!important;aspect-ratio:1/1!important;height:.75rem!important;--tw-translate-y:-50%!important;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))!important;border-radius:9999px!important;border-width:2px!important;border-color:currentColor!important;opacity:.6!important}.mockup-browser .mockup-browser-toolbar .input:before{content:"";position:absolute;left:.5rem;top:50%;aspect-ratio:1/1;height:.75rem;--tw-translate-y:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));border-radius:9999px;border-width:2px;border-color:currentColor;opacity:.6}.mockup-browser .mockup-browser-toolbar .\!input:after{content:""!important;position:absolute!important;left:1.25rem!important;top:50%!important;height:.5rem!important;--tw-translate-y:25%!important;--tw-rotate:-45deg!important;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))!important;border-radius:9999px!important;border-width:1px!important;border-color:currentColor!important;opacity:.6!important}.mockup-browser .mockup-browser-toolbar .input:after{content:"";position:absolute;left:1.25rem;top:50%;height:.5rem;--tw-translate-y:25%;--tw-rotate:-45deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));border-radius:9999px;border-width:1px;border-color:currentColor;opacity:.6}.modal::backdrop,.modal:not(dialog:not(.modal-open)){background-color:#0006;animation:modal-pop .2s ease-out}.modal-open .modal-box,.modal-toggle:checked+.modal .modal-box,.modal:target .modal-box,.modal[open] .modal-box{--tw-translate-y:0px;--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.modal-action>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.5rem*var(--tw-space-x-reverse));margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)))}.modal-action:where([dir=rtl],[dir=rtl] *)>:not([hidden])~:not([hidden]){--tw-space-x-reverse:1}@keyframes modal-pop{0%{opacity:0}}.progress::-moz-progress-bar{border-radius:var(--rounded-box,1rem);background-color:currentColor}.progress-primary::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)))}.progress:indeterminate{--progress-color:var(--fallback-bc,oklch(var(--bc)/1));background-image:repeating-linear-gradient(90deg,var(--progress-color) -1%,var(--progress-color) 10%,transparent 10%,transparent 90%);background-size:200%;background-position-x:15%;animation:progress-loading 5s ease-in-out infinite}.progress-primary:indeterminate{--progress-color:var(--fallback-p,oklch(var(--p)/1))}.progress::-webkit-progress-bar{border-radius:var(--rounded-box,1rem);background-color:transparent}.progress::-webkit-progress-value{border-radius:var(--rounded-box,1rem);background-color:currentColor}.progress-primary::-webkit-progress-value{--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)))}.progress:indeterminate::-moz-progress-bar{background-color:transparent;background-image:repeating-linear-gradient(90deg,var(--progress-color) -1%,var(--progress-color) 10%,transparent 10%,transparent 90%);background-size:200%;background-position-x:15%;animation:progress-loading 5s ease-in-out infinite}@keyframes progress-loading{50%{background-position-x:-115%}}.radio:focus{box-shadow:none}.radio:focus-visible{outline-style:solid;outline-width:2px;outline-offset:2px;outline-color:var(--fallback-bc,oklch(var(--bc)/1))}.radio:checked,.radio[aria-checked=true]{--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));background-image:none;animation:radiomark var(--animation-input,.2s) ease-out;box-shadow:0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset}.radio:disabled{cursor:not-allowed;opacity:.2}@keyframes radiomark{0%{box-shadow:0 0 0 12px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 12px var(--fallback-b1,oklch(var(--b1)/1)) inset}50%{box-shadow:0 0 0 3px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 3px var(--fallback-b1,oklch(var(--b1)/1)) inset}to{box-shadow:0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset}}.range:focus-visible::-webkit-slider-thumb{--focus-shadow:0 0 0 6px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 2rem var(--range-shdw) inset}.range:focus-visible::-moz-range-thumb{--focus-shadow:0 0 0 6px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 2rem var(--range-shdw) inset}.range::-webkit-slider-runnable-track{height:.5rem;width:100%;border-radius:var(--rounded-box,1rem);background-color:var(--fallback-bc,oklch(var(--bc)/.1))}.range::-moz-range-track{height:.5rem;width:100%;border-radius:var(--rounded-box,1rem);background-color:var(--fallback-bc,oklch(var(--bc)/.1))}.range::-webkit-slider-thumb{position:relative;height:1.5rem;width:1.5rem;border-radius:var(--rounded-box,1rem);border-style:none;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));appearance:none;-webkit-appearance:none;top:50%;color:var(--range-shdw);transform:translateY(-50%);--filler-size:100rem;--filler-offset:0.6rem;box-shadow:0 0 0 3px var(--range-shdw) inset,var(--focus-shadow,0 0),calc(var(--filler-size)*-1 - var(--filler-offset)) 0 0 var(--filler-size)}.range::-moz-range-thumb{position:relative;height:1.5rem;width:1.5rem;border-radius:var(--rounded-box,1rem);border-style:none;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));top:50%;color:var(--range-shdw);--filler-size:100rem;--filler-offset:0.5rem;box-shadow:0 0 0 3px var(--range-shdw) inset,var(--focus-shadow,0 0),calc(var(--filler-size)*-1 - var(--filler-offset)) 0 0 var(--filler-size)}@keyframes rating-pop{0%{transform:translateY(-.125em)}40%{transform:translateY(-.125em)}to{transform:translateY(0)}}.select-bordered,.select:focus{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.select:focus{box-shadow:none;outline-style:solid;outline-width:2px;outline-offset:2px;outline-color:var(--fallback-bc,oklch(var(--bc)/.2))}.select-disabled,.select:disabled,.select[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));color:var(--fallback-bc,oklch(var(--bc)/.4))}.select-disabled::-moz-placeholder,.select:disabled::-moz-placeholder,.select[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.select-disabled::placeholder,.select:disabled::placeholder,.select[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.select-multiple,.select[multiple],.select[size].select:not([size="1"]){background-image:none;padding-right:1rem}[dir=rtl] .select{background-position:12px calc(1px + 50%),16px calc(1px + 50%)}@keyframes skeleton{0%{background-position:150%}to{background-position:-50%}}:where(.stats)>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(0px*(1 - var(--tw-divide-y-reverse))) calc(1px*var(--tw-divide-x-reverse)) calc(0px*var(--tw-divide-y-reverse)) calc(1px*(1 - var(--tw-divide-x-reverse)))}[dir=rtl] .stats>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:1}.steps .step:before{top:0;height:.5rem;width:100%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));content:"";margin-inline-start:-100%}.steps .step:after,.steps .step:before{grid-column-start:1;grid-row-start:1;--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)));--tw-text-opacity:1}.steps .step:after{content:counter(step);counter-increment:step;z-index:1;position:relative;display:grid;height:2rem;width:2rem;place-items:center;place-self:center;border-radius:9999px;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.steps .step:first-child:before{content:none}.steps .step[data-content]:after{content:attr(data-content)}.swap-rotate .swap-indeterminate,.swap-rotate .swap-on,.swap-rotate input:indeterminate~.swap-on{--tw-rotate:45deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.swap-active:where(.swap-rotate) .swap-off,.swap-rotate input:checked~.swap-off,.swap-rotate input:indeterminate~.swap-off{--tw-rotate:-45deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.swap-active:where(.swap-rotate) .swap-on,.swap-rotate input:checked~.swap-on,.swap-rotate input:indeterminate~.swap-indeterminate{--tw-rotate:0deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.swap-flip .swap-indeterminate,.swap-flip .swap-on,.swap-flip input:indeterminate~.swap-on{transform:rotateY(180deg);backface-visibility:hidden;opacity:1}.swap-active:where(.swap-flip) .swap-off,.swap-flip input:checked~.swap-off,.swap-flip input:indeterminate~.swap-off{transform:rotateY(-180deg);backface-visibility:hidden;opacity:1}.swap-active:where(.swap-flip) .swap-on,.swap-flip input:checked~.swap-on,.swap-flip input:indeterminate~.swap-indeterminate{transform:rotateY(0deg)}.tabs-lifted>.tab:focus-visible{border-end-end-radius:0;border-end-start-radius:0}.tab:is(.tab-active,[aria-selected=true]):not(.tab-disabled):not([disabled]),.tab:is(input:checked){border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:1;--tw-text-opacity:1}.tab:focus{outline:2px solid transparent;outline-offset:2px}.tab:focus-visible{outline:2px solid currentColor;outline-offset:-5px}.tab-disabled,.tab[disabled]{cursor:not-allowed;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.tabs-bordered>.tab{border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:0.2;border-style:solid;border-bottom-width:calc(var(--tab-border, 1px) + 1px)}.tabs-lifted>.tab{border:var(--tab-border,1px) solid transparent;border-width:0 0 var(--tab-border,1px) 0;border-start-start-radius:var(--tab-radius,.5rem);border-start-end-radius:var(--tab-radius,.5rem);border-bottom-color:var(--tab-border-color);padding-inline-start:var(--tab-padding,1rem);padding-inline-end:var(--tab-padding,1rem);padding-top:var(--tab-border,1px)}.tabs-lifted>.tab:is(.tab-active,[aria-selected=true]):not(.tab-disabled):not([disabled]),.tabs-lifted>.tab:is(input:checked){background-color:var(--tab-bg);border-width:var(--tab-border,1px) var(--tab-border,1px) 0 var(--tab-border,1px);border-inline-start-color:var(--tab-border-color);border-inline-end-color:var(--tab-border-color);border-top-color:var(--tab-border-color);padding-inline-start:calc(var(--tab-padding, 1rem) - var(--tab-border, 1px));padding-inline-end:calc(var(--tab-padding, 1rem) - var(--tab-border, 1px));padding-bottom:var(--tab-border,1px);padding-top:0}.tabs-lifted>.tab:is(.tab-active,[aria-selected=true]):not(.tab-disabled):not([disabled]):before,.tabs-lifted>.tab:is(input:checked):before{z-index:1;content:"";display:block;position:absolute;width:calc(100% + var(--tab-radius, .5rem)*2);height:var(--tab-radius,.5rem);bottom:0;background-size:var(--tab-radius,.5rem);background-position:0 0,100% 0;background-repeat:no-repeat;--tab-grad:calc(69% - var(--tab-border, 1px));--radius-start:radial-gradient(circle at top left,transparent var(--tab-grad),var(--tab-border-color) calc(var(--tab-grad) + 0.25px),var(--tab-border-color) calc(var(--tab-grad) + var(--tab-border, 1px)),var(--tab-bg) calc(var(--tab-grad) + var(--tab-border, 1px) + 0.25px));--radius-end:radial-gradient(circle at top right,transparent var(--tab-grad),var(--tab-border-color) calc(var(--tab-grad) + 0.25px),var(--tab-border-color) calc(var(--tab-grad) + var(--tab-border, 1px)),var(--tab-bg) calc(var(--tab-grad) + var(--tab-border, 1px) + 0.25px));background-image:var(--radius-start),var(--radius-end)}.tabs-lifted>.tab:is(.tab-active,[aria-selected=true]):not(.tab-disabled):not([disabled]):first-child:before,.tabs-lifted>.tab:is(input:checked):first-child:before{background-image:var(--radius-end);background-position:100% 0}[dir=rtl] .tabs-lifted>.tab:is(.tab-active,[aria-selected=true]):not(.tab-disabled):not([disabled]):first-child:before,[dir=rtl] .tabs-lifted>.tab:is(input:checked):first-child:before{background-image:var(--radius-start);background-position:0 0}.tabs-lifted>.tab:is(.tab-active,[aria-selected=true]):not(.tab-disabled):not([disabled]):last-child:before,.tabs-lifted>.tab:is(input:checked):last-child:before{background-image:var(--radius-start);background-position:0 0}[dir=rtl] .tabs-lifted>.tab:is(.tab-active,[aria-selected=true]):not(.tab-disabled):not([disabled]):last-child:before,[dir=rtl] .tabs-lifted>.tab:is(input:checked):last-child:before{background-image:var(--radius-end);background-position:100% 0}.tabs-lifted>.tab:is(input:checked)+.tabs-lifted .tab:is(input:checked):before,.tabs-lifted>:is(.tab-active,[aria-selected=true]):not(.tab-disabled):not([disabled])+.tabs-lifted :is(.tab-active,[aria-selected=true]):not(.tab-disabled):not([disabled]):before{background-image:var(--radius-end);background-position:100% 0}.tabs-boxed .tab{border-radius:var(--rounded-btn,.5rem)}.table:where([dir=rtl],[dir=rtl] *){text-align:right}.table :where(th,td){padding:.75rem 1rem;vertical-align:middle}.table tr.active,.table tr.active:nth-child(2n),.table-zebra tbody tr:nth-child(2n){--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))}.table-zebra tr.active,.table-zebra tr.active:nth-child(2n),.table-zebra-zebra tbody tr:nth-child(2n){--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}.table :where(thead tr,tbody tr:not(:last-child),tbody tr:first-child:last-child){border-bottom-width:1px;--tw-border-opacity:1;border-bottom-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))}.table :where(thead,tfoot){white-space:nowrap;font-size:.75rem;line-height:1rem;font-weight:700;color:var(--fallback-bc,oklch(var(--bc)/.6))}.table :where(tfoot){border-top-width:1px;--tw-border-opacity:1;border-top-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))}.textarea-bordered,.textarea:focus{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.textarea:focus{box-shadow:none;outline-style:solid;outline-width:2px;outline-offset:2px;outline-color:var(--fallback-bc,oklch(var(--bc)/.2))}.textarea-disabled,.textarea:disabled,.textarea[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));color:var(--fallback-bc,oklch(var(--bc)/.4))}.textarea-disabled::-moz-placeholder,.textarea:disabled::-moz-placeholder,.textarea[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.textarea-disabled::placeholder,.textarea:disabled::placeholder,.textarea[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.toast>*{animation:toast-pop .25s ease-out}@keyframes toast-pop{0%{transform:scale(.9);opacity:0}to{transform:scale(1);opacity:1}}[dir=rtl] .toggle{--handleoffsetcalculator:calc(var(--handleoffset)*1)}.toggle:focus-visible{outline-style:solid;outline-width:2px;outline-offset:2px;outline-color:var(--fallback-bc,oklch(var(--bc)/.2))}.toggle:hover{background-color:currentColor}.toggle:checked,.toggle[aria-checked=true]{background-image:none;--handleoffsetcalculator:var(--handleoffset);--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}[dir=rtl] .toggle:checked,[dir=rtl] .toggle[aria-checked=true]{--handleoffsetcalculator:calc(var(--handleoffset)*-1)}.toggle:indeterminate{--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));box-shadow:calc(var(--handleoffset)/2) 0 0 2px var(--tglbg) inset,calc(var(--handleoffset)/-2) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset}[dir=rtl] .toggle:indeterminate{box-shadow:calc(var(--handleoffset)/2) 0 0 2px var(--tglbg) inset,calc(var(--handleoffset)/-2) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset}.toggle:disabled{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));background-color:transparent;opacity:.3;--togglehandleborder:0 0 0 3px var(--fallback-bc,oklch(var(--bc)/1)) inset,var(--handleoffsetcalculator) 0 0 3px var(--fallback-bc,oklch(var(--bc)/1)) inset}.glass,.glass.btn-active{border:none;-webkit-backdrop-filter:blur(var(--glass-blur,40px));backdrop-filter:blur(var(--glass-blur,40px));background-color:transparent;background-image:linear-gradient(135deg,rgb(255 255 255/var(--glass-opacity,30%)) 0,transparent 100%),linear-gradient(var(--glass-reflex-degree,100deg),rgb(255 255 255/var(--glass-reflex-opacity,10%)) 25%,transparent 25%);box-shadow:0 0 0 1px rgb(255 255 255/var(--glass-border-opacity,10%)) inset,0 0 0 2px rgb(0 0 0/5%);text-shadow:0 1px rgb(0 0 0/var(--glass-text-shadow-opacity,5%))}@media (hover:hover){.glass.btn-active{border:none;-webkit-backdrop-filter:blur(var(--glass-blur,40px));backdrop-filter:blur(var(--glass-blur,40px));background-color:transparent;background-image:linear-gradient(135deg,rgb(255 255 255/var(--glass-opacity,30%)) 0,transparent 100%),linear-gradient(var(--glass-reflex-degree,100deg),rgb(255 255 255/var(--glass-reflex-opacity,10%)) 25%,transparent 25%);box-shadow:0 0 0 1px rgb(255 255 255/var(--glass-border-opacity,10%)) inset,0 0 0 2px rgb(0 0 0/5%);text-shadow:0 1px rgb(0 0 0/var(--glass-text-shadow-opacity,5%))}}.badge-xs{height:.75rem;font-size:.75rem;line-height:.75rem;padding-left:.313rem;padding-right:.313rem}.badge-sm{height:1rem;font-size:.75rem;line-height:1rem;padding-left:.438rem;padding-right:.438rem}.btm-nav-xs>:where(.active){border-top-width:1px}.btm-nav-sm>:where(.active){border-top-width:2px}.btm-nav-md>:where(.active){border-top-width:2px}.btm-nav-lg>:where(.active){border-top-width:4px}.btn-xs{height:1.5rem;min-height:1.5rem;padding-left:.5rem;padding-right:.5rem;font-size:.75rem}.btn-sm{height:2rem;min-height:2rem;padding-left:.75rem;padding-right:.75rem;font-size:.875rem}.btn-lg{height:4rem;min-height:4rem;padding-left:1.5rem;padding-right:1.5rem;font-size:1.125rem}.btn-square:where(.btn-xs){height:1.5rem;width:1.5rem;padding:0}.btn-square:where(.btn-sm){height:2rem;width:2rem;padding:0}.btn-square:where(.btn-lg){height:4rem;width:4rem;padding:0}.btn-circle:where(.btn-xs){height:1.5rem;width:1.5rem;border-radius:9999px;padding:0}.btn-circle:where(.btn-sm){height:2rem;width:2rem;border-radius:9999px;padding:0}.btn-circle:where(.btn-md){height:3rem;width:3rem;border-radius:9999px;padding:0}.btn-circle:where(.btn-lg){height:4rem;width:4rem;border-radius:9999px;padding:0}[type=checkbox].checkbox-sm{height:1.25rem;width:1.25rem}[type=checkbox].checkbox-lg{height:2rem;width:2rem}.indicator :where(.indicator-item){bottom:auto;inset-inline-end:0;inset-inline-start:auto;top:0;--tw-translate-y:-50%;--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item):where([dir=rtl],[dir=rtl] *){--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-start){inset-inline-end:auto;inset-inline-start:0;--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-start):where([dir=rtl],[dir=rtl] *){--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-center){inset-inline-end:50%;inset-inline-start:50%;--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-center):where([dir=rtl],[dir=rtl] *){--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-end){inset-inline-end:0;inset-inline-start:auto;--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-end):where([dir=rtl],[dir=rtl] *){--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-bottom){bottom:0;top:auto;--tw-translate-y:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-middle){bottom:50%;top:50%;--tw-translate-y:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-top){bottom:auto;top:0;--tw-translate-y:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.input-sm{height:2rem;padding-left:.75rem;padding-right:.75rem;font-size:.875rem;line-height:2rem}.join.join-vertical{flex-direction:column}.join.join-vertical .join-item:first-child:not(:last-child),.join.join-vertical :first-child:not(:last-child) .join-item{border-end-start-radius:0;border-end-end-radius:0;border-start-start-radius:inherit;border-start-end-radius:inherit}.join.join-vertical .join-item:last-child:not(:first-child),.join.join-vertical :last-child:not(:first-child) .join-item{border-start-start-radius:0;border-start-end-radius:0;border-end-start-radius:inherit;border-end-end-radius:inherit}.join.join-horizontal{flex-direction:row}.join.join-horizontal .join-item:first-child:not(:last-child),.join.join-horizontal :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-start-end-radius:0;border-end-start-radius:inherit;border-start-start-radius:inherit}.join.join-horizontal .join-item:last-child:not(:first-child),.join.join-horizontal :last-child:not(:first-child) .join-item{border-end-start-radius:0;border-start-start-radius:0;border-end-end-radius:inherit;border-start-end-radius:inherit}.kbd-sm{padding-left:.25rem;padding-right:.25rem;font-size:.875rem;line-height:1.25rem;min-height:1.6em;min-width:1.6em}.menu-horizontal{display:inline-flex;flex-direction:row}.menu-horizontal>li:not(.menu-title)>details>ul{position:absolute}.select-sm{height:2rem;min-height:2rem;padding-left:.75rem;padding-right:2rem;font-size:.875rem;line-height:2rem}[dir=rtl] .select-sm{padding-left:2rem;padding-right:.75rem}.steps-horizontal .step{display:grid;grid-template-columns:repeat(1,minmax(0,1fr));grid-template-rows:repeat(2,minmax(0,1fr));place-items:center;text-align:center}.steps-vertical .step{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));grid-template-rows:repeat(1,minmax(0,1fr))}.tabs-md :where(.tab){height:2rem;font-size:.875rem;line-height:1.25rem;line-height:2;--tab-padding:1rem}.tabs-lg :where(.tab){height:3rem;font-size:1.125rem;line-height:1.75rem;line-height:2;--tab-padding:1.25rem}.tabs-sm :where(.tab){height:1.5rem;font-size:.875rem;line-height:.75rem;--tab-padding:0.75rem}.tabs-xs :where(.tab){height:1.25rem;font-size:.75rem;line-height:.75rem;--tab-padding:0.5rem}:where(.toast){bottom:0;inset-inline-end:0;inset-inline-start:auto;top:auto;--tw-translate-x:0px;--tw-translate-y:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.toast:where(.toast-start){inset-inline-end:auto;inset-inline-start:0;--tw-translate-x:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.toast:where(.toast-center){inset-inline-end:50%;inset-inline-start:50%;--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.toast:where(.toast-center):where([dir=rtl],[dir=rtl] *){--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.toast:where(.toast-end){inset-inline-end:0;inset-inline-start:auto;--tw-translate-x:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.toast:where(.toast-bottom){bottom:0;top:auto;--tw-translate-y:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.toast:where(.toast-middle){bottom:auto;top:50%;--tw-translate-y:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.toast:where(.toast-top){bottom:auto;top:0;--tw-translate-y:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.tooltip{--tooltip-offset:calc(100% + 1px + var(--tooltip-tail, 0px))}.tooltip:before{position:absolute;pointer-events:none;z-index:1;content:var(--tw-content);--tw-content:attr(data-tip)}.tooltip-top:before,.tooltip:before{transform:translateX(-50%);top:auto;left:50%;right:auto;bottom:var(--tooltip-offset)}.tooltip-bottom:before{transform:translateX(-50%);top:var(--tooltip-offset);left:50%;right:auto;bottom:auto}.tooltip-left:before{transform:translateY(-50%);top:50%;left:auto;right:var(--tooltip-offset);bottom:auto}.avatar.online:before{background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity)))}.avatar.offline:before,.avatar.online:before{content:"";position:absolute;z-index:10;display:block;border-radius:9999px;--tw-bg-opacity:1;outline-style:solid;outline-width:2px;outline-color:var(--fallback-b1,oklch(var(--b1)/1));width:15%;height:15%;top:7%;right:7%}.avatar.offline:before{background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}.card-compact .card-body{padding:1rem;font-size:.875rem;line-height:1.25rem}.card-compact .card-title{margin-bottom:.25rem}.card-normal .card-body{padding:var(--padding-card,2rem);font-size:1rem;line-height:1.5rem}.card-normal .card-title{margin-bottom:.75rem}.join.join-vertical>:where(:not(:first-child)){margin-left:0;margin-right:0;margin-top:-1px}.join.join-vertical>:where(:not(:first-child)):is(.btn){margin-top:calc(var(--border-btn)*-1)}.join.join-horizontal>:where(:not(:first-child)){margin-top:0;margin-bottom:0;margin-inline-start:-1px}.join.join-horizontal>:where(:not(:first-child)):is(.btn){margin-inline-start:calc(var(--border-btn)*-1);margin-top:0}.menu-horizontal>li:not(.menu-title)>details>ul{margin-inline-start:0;margin-top:1rem;padding-top:.5rem;padding-bottom:.5rem;padding-inline-end:.5rem}.menu-horizontal>li>details>ul:before{content:none}:where(.menu-horizontal>li:not(.menu-title)>details>ul){border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.menu-xs .menu-title{padding:.25rem .5rem}.menu-sm :where(li:not(.menu-title)>:not(ul,details,.menu-title)),.menu-sm :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--rounded-btn,.5rem);padding:.25rem .75rem;font-size:.875rem;line-height:1.25rem}.menu-sm .menu-title{padding:.5rem .75rem}.menu-md .menu-title{padding:.5rem 1rem}.menu-lg .menu-title{padding:.75rem 1.5rem}.modal-top :where(.modal-box){width:100%;max-width:none;--tw-translate-y:-2.5rem;--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));border-bottom-right-radius:var(--rounded-box,1rem);border-bottom-left-radius:var(--rounded-box,1rem);border-top-left-radius:0;border-top-right-radius:0}.modal-middle :where(.modal-box){width:91.666667%;max-width:32rem;--tw-translate-y:0px;--tw-scale-x:.9;--tw-scale-y:.9;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);border-bottom-right-radius:var(--rounded-box,1rem);border-bottom-left-radius:var(--rounded-box,1rem)}.modal-bottom :where(.modal-box){width:100%;max-width:none;--tw-translate-y:2.5rem;--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);border-bottom-right-radius:0;border-bottom-left-radius:0}.steps-horizontal .step{grid-template-rows:40px 1fr;grid-template-columns:auto;min-width:4rem}.steps-horizontal .step:before{height:.5rem;width:100%;--tw-translate-x:0px;--tw-translate-y:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));content:"";margin-inline-start:-100%}.steps-horizontal .step:where([dir=rtl],[dir=rtl] *):before{--tw-translate-x:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.steps-vertical .step{gap:.5rem;grid-template-columns:40px 1fr;grid-template-rows:auto;min-height:4rem;justify-items:start}.steps-vertical .step:before{height:100%;width:.5rem;--tw-translate-x:-50%;--tw-translate-y:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));margin-inline-start:50%}.steps-vertical .step:where([dir=rtl],[dir=rtl] *):before{--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.table-sm :not(thead):not(tfoot) tr{font-size:.875rem;line-height:1.25rem}.table-sm :where(th,td){padding:.5rem .75rem}.tooltip{position:relative;display:inline-block;text-align:center;--tooltip-tail:0.1875rem;--tooltip-color:var(--fallback-n,oklch(var(--n)/1));--tooltip-text-color:var(--fallback-nc,oklch(var(--nc)/1));--tooltip-tail-offset:calc(100% + 0.0625rem - var(--tooltip-tail))}.tooltip:after,.tooltip:before{opacity:0;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-delay:.1s;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1)}.tooltip:after{position:absolute;content:"";border-style:solid;border-width:var(--tooltip-tail,0);width:0;height:0;display:block}.tooltip:before{max-width:20rem;white-space:normal;border-radius:.25rem;padding:.25rem .5rem;font-size:.875rem;line-height:1.25rem;background-color:var(--tooltip-color);color:var(--tooltip-text-color);width:-moz-max-content;width:max-content}.tooltip.tooltip-open:after,.tooltip.tooltip-open:before,.tooltip:hover:after,.tooltip:hover:before{opacity:1;transition-delay:75ms}.tooltip:has(:focus-visible):after,.tooltip:has(:focus-visible):before{opacity:1;transition-delay:75ms}.tooltip:not([data-tip]):hover:after,.tooltip:not([data-tip]):hover:before{visibility:hidden;opacity:0}.tooltip-top:after,.tooltip:after{transform:translateX(-50%);border-color:var(--tooltip-color) transparent transparent transparent;top:auto;left:50%;right:auto;bottom:var(--tooltip-tail-offset)}.tooltip-bottom:after{transform:translateX(-50%);border-color:transparent transparent var(--tooltip-color) transparent;top:var(--tooltip-tail-offset);left:50%;right:auto;bottom:auto}.tooltip-left:after{transform:translateY(-50%);border-color:transparent transparent transparent var(--tooltip-color);top:50%;left:auto;right:calc(var(--tooltip-tail-offset) + .0625rem);bottom:auto}.visible{visibility:visible}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.bottom-4{bottom:1rem}.right-2{right:.5rem}.right-4{right:1rem}.top-0{top:0}.top-2{top:.5rem}.top-20{top:5rem}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.z-\[1\]{z-index:1}.mx-auto{margin-left:auto;margin-right:auto}.mb-2{margin-bottom:.5rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-4{margin-left:1rem}.mr-1{margin-right:.25rem}.mr-2{margin-right:.5rem}.mr-3{margin-right:.75rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.table{display:table}.grid{display:grid}.contents{display:contents}.hidden{display:none}.h-2{height:.5rem}.h-32{height:8rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-8{height:2rem}.h-full{height:100%}.max-h-96{max-height:24rem}.max-h-\[90vh\]{max-height:90vh}.min-h-96{min-height:24rem}.min-h-\[200px\]{min-height:200px}.min-h-screen{min-height:100vh}.w-12{width:3rem}.w-20{width:5rem}.w-24{width:6rem}.w-32{width:8rem}.w-4{width:1rem}.w-48{width:12rem}.w-5{width:1.25rem}.w-52{width:13rem}.w-6{width:1.5rem}.w-8{width:2rem}.w-full{width:100%}.min-w-12{min-width:3rem}.min-w-32{min-width:8rem}.min-w-36{min-width:9rem}.min-w-48{min-width:12rem}.max-w-2xl{max-width:42rem}.max-w-48{max-width:12rem}.max-w-6xl{max-width:72rem}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.resize{resize:both}.list-inside{list-style-position:inside}.list-disc{list-style-type:disc}.grid-flow-col{grid-auto-flow:column}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-start{justify-content:flex-start}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.space-x-8>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(2rem*var(--tw-space-x-reverse));margin-left:calc(2rem*(1 - var(--tw-space-x-reverse)))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem*var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem*var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem*var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem*var(--tw-space-y-reverse))}.overflow-x-auto{overflow-x:auto}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.text-nowrap{text-wrap:nowrap}.rounded-box{border-radius:var(--rounded-box,1rem)}.rounded-lg{border-radius:.5rem}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-base-200{--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity,1)))}.border-base-300{--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity,1)))}.border-info{--tw-border-opacity:1;border-color:var(--fallback-in,oklch(var(--in)/var(--tw-border-opacity,1)))}.border-primary{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity,1)))}.border-transparent{border-color:transparent}.bg-base-100{--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity,1)))}.bg-base-200{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity,1)))}.bg-base-300{--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity,1)))}.bg-black\/50{background-color:rgba(0,0,0,.5)}.bg-primary\/10{background-color:var(--fallback-p,oklch(var(--p)/.1))}.bg-primary\/5{background-color:var(--fallback-p,oklch(var(--p)/.05))}.bg-gradient-to-r{background-image:linear-gradient(to right,var(--tw-gradient-stops))}.from-primary{--tw-gradient-from:var(--fallback-p,oklch(var(--p)/1)) var(--tw-gradient-from-position);--tw-gradient-to:var(--fallback-p,oklch(var(--p)/0)) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.to-secondary{--tw-gradient-to:var(--fallback-s,oklch(var(--s)/1)) var(--tw-gradient-to-position)}.bg-clip-text{-webkit-background-clip:text;background-clip:text}.p-0{padding:0}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-6{padding:1.5rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-4{padding-left:1rem;padding-right:1rem}.py-16{padding-top:4rem;padding-bottom:4rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.py-8{padding-top:2rem;padding-bottom:2rem}.text-center{text-align:center}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-6xl{font-size:3.75rem;line-height:1}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.text-accent{--tw-text-opacity:1;color:var(--fallback-a,oklch(var(--a)/var(--tw-text-opacity,1)))}.text-base-content{--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity,1)))}.text-base-content\/30{color:var(--fallback-bc,oklch(var(--bc)/.3))}.text-base-content\/50{color:var(--fallback-bc,oklch(var(--bc)/.5))}.text-base-content\/60{color:var(--fallback-bc,oklch(var(--bc)/.6))}.text-base-content\/70{color:var(--fallback-bc,oklch(var(--bc)/.7))}.text-current{color:currentColor}.text-error{--tw-text-opacity:1;color:var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity,1)))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.text-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity,1)))}.text-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity,1)))}.text-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity,1)))}.text-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity,1)))}.text-transparent{color:transparent}.text-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity,1)))}.opacity-50{opacity:.5}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-2xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-lg,.shadow-sm{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.blur{--tw-blur:blur(8px)}.blur,.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur-sm{--tw-backdrop-blur:blur(4px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.ease-in{transition-timing-function:cubic-bezier(.4,0,1,1)}*{transition:transform .2s ease-in-out,opacity .2s ease-in-out,box-shadow .2s ease-in-out,border-color .2s ease-in-out,background-color .2s ease-in-out}:root{--warning-color:#f59e0b}[data-theme=dark]{--warning-color:#fbbf24}.context-menu{position:absolute;z-index:1000;-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);border:1px solid hsl(var(--bc)/.2);box-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 10px 10px -5px rgba(0,0,0,.04)}.context-menu.hidden{opacity:0;pointer-events:none;transform:scale(.95) translateY(-5px)}.context-menu:not(.hidden){opacity:1;pointer-events:all;transform:scale(1) translateY(0);animation:contextMenuAppear .15s ease-out}@keyframes contextMenuAppear{0%{opacity:0;transform:scale(.95) translateY(-5px)}to{opacity:1;transform:scale(1) translateY(0)}}.progress{transition:all .3s ease-in-out}.progress::-webkit-progress-value{-webkit-transition:width .5s ease-in-out;transition:width .5s ease-in-out}.progress::-moz-progress-bar{-moz-transition:width .5s ease-in-out;transition:width .5s ease-in-out}.btn{transition:all .2s ease-in-out;transform:translateY(0)}.btn:hover:not(:disabled){transform:translateY(-1px);box-shadow:0 4px 12px rgba(0,0,0,.15)}.btn:active:not(:disabled){transform:translateY(0);transition:all .1s ease-in-out}.card{transition:all .3s ease-in-out}.card:hover{transform:translateY(-2px);box-shadow:0 8px 25px rgba(0,0,0,.15)}.table tbody tr{transition:all .2s ease-in-out}.table tbody tr:hover{background-color:hsl(var(--b2));transform:scale(1.005)}.item-row.selected{background-color:hsl(var(--p)/.1)!important;border-left:4px solid hsl(var(--p))}.item-row{cursor:pointer;transition:all .2s ease-in-out}.stepper-nav .nav-link{transition:all .2s ease-in-out;position:relative;overflow:hidden}.stepper-nav .nav-link:before{content:"";position:absolute;top:0;left:-100%;width:100%;height:100%;background:linear-gradient(90deg,transparent,hsla(0,0%,100%,.2),transparent);transition:left .5s ease-in-out}.stepper-nav .nav-link:hover:before{left:100%}.modal{transition:all .3s ease-in-out}.modal-box{animation:modalSlideIn .3s ease-out}@keyframes modalSlideIn{0%{opacity:0;transform:scale(.9) translateY(-20px)}to{opacity:1;transform:scale(1) translateY(0)}}.toast-container .alert{animation:toastSlideIn .3s ease-out}@keyframes toastSlideIn{0%{opacity:0;transform:translateX(100%)}to{opacity:1;transform:translateX(0)}}.badge,.loading{transition:all .2s ease-in-out}.badge:hover{transform:scale(1.05)}.form-control input:focus,.form-control select:focus,.form-control textarea:focus{transform:scale(1.02);box-shadow:0 0 0 3px hsl(var(--p)/.2)}.join .btn{transition:all .2s ease-in-out}.join .btn:not(.btn-active):hover{background-color:hsl(var(--b3));transform:translateY(-1px)}.password-toggle-container{position:relative;display:flex;align-items:center}.password-toggle-btn{position:absolute;right:0;top:0;bottom:0;width:40px;background:none;border:none;cursor:pointer;z-index:10;color:hsl(var(--bc)/.6);transition:all .2s ease-in-out;display:flex;align-items:center;justify-content:center;border-top-right-radius:var(--rounded-btn,.5rem);border-bottom-right-radius:var(--rounded-btn,.5rem)}.password-toggle-btn:hover{background-color:hsl(var(--bc)/.1);color:hsl(var(--bc)/.8)}.input.input-has-toggle{padding-right:40px!important;width:100%}.textarea.has-toggle{padding-right:40px!important;font-family:JetBrains Mono,Consolas,Monaco,Courier New,monospace!important;line-height:1.4;resize:vertical}.password-toggle-btn.textarea-toggle{top:0;right:0;bottom:auto;height:40px;border-radius:0;border-top-right-radius:var(--rounded-btn,.5rem)}.textarea.has-toggle:not([data-password-visible=true]),.textarea.has-toggle[data-password-visible=false]{-webkit-text-security:disc;text-security:disc;font-family:monospace!important;letter-spacing:2px}.textarea.has-toggle[data-password-visible=true]{-webkit-text-security:none;text-security:none;font-family:JetBrains Mono,Consolas,Monaco,Courier New,monospace!important;letter-spacing:normal}.input[type=password]{font-family:Courier New,monospace;letter-spacing:1px}.input[type=text].was-password{font-family:JetBrains Mono,Consolas,Monaco,Courier New,monospace;letter-spacing:normal}[data-theme=dark] .password-toggle-btn{color:hsl(var(--bc)/.6);background-color:transparent}[data-theme=dark] .password-toggle-btn:hover{background-color:hsl(var(--bc)/.15);color:hsl(var(--bc)/.9)}[data-theme=light] .password-toggle-btn{color:hsl(var(--bc)/.7);background-color:transparent}[data-theme=light] .password-toggle-btn:hover{background-color:hsl(var(--bc)/.1);color:hsl(var(--bc)/.9)}.password-toggle-btn i{font-size:14px;line-height:1}.password-toggle-container,.password-toggle-container .input,.password-toggle-container .textarea{width:100%}.drawer-side{transition:transform .3s ease-in-out}.dropdown-content{animation:dropdownSlideIn .2s ease-out}@keyframes dropdownSlideIn{0%{opacity:0;transform:scale(.95) translateY(-10px)}to{opacity:1;transform:scale(1) translateY(0)}}.filters-container .filter-item{animation:filterItemAppear .2s ease-out;transition:all .2s ease-in-out}@keyframes filterItemAppear{0%{opacity:0;transform:translateX(-10px)}to{opacity:1;transform:translateX(0)}}.filter-item:hover{background-color:hsl(var(--b2));border-radius:4px;padding:4px;margin:-4px}[data-theme=dark] .glass{background:hsla(0,0%,100%,.05);-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);border:1px solid hsla(0,0%,100%,.1)}[data-theme=dark] .input,[data-theme=dark] .select,[data-theme=dark] .textarea{color:#fff!important;background-color:hsl(var(--b1))}[data-theme=dark] .input::-moz-placeholder,[data-theme=dark] .textarea::-moz-placeholder{color:#6b7280!important;opacity:1!important;font-style:italic}[data-theme=dark] .input::placeholder,[data-theme=dark] .textarea::placeholder{color:#6b7280!important;opacity:1!important;font-style:italic}[data-theme=dark] .input:focus,[data-theme=dark] .select:focus,[data-theme=dark] .textarea:focus{color:#fff!important;background-color:hsl(var(--b1));border-color:hsl(var(--p));outline:none;box-shadow:0 0 0 2px hsl(var(--p)/.2)}[data-theme=dark] .select option{background-color:hsl(var(--b1));color:#fff}[data-theme=dark] .input:disabled,[data-theme=dark] .select:disabled,[data-theme=dark] .textarea:disabled{color:#9ca3af!important;background-color:hsl(var(--b2));border-color:hsl(var(--bc)/.2)}[data-theme=dark] .input:disabled::-moz-placeholder,[data-theme=dark] .textarea:disabled::-moz-placeholder{color:#6b7280!important}[data-theme=dark] .input:disabled::placeholder,[data-theme=dark] .textarea:disabled::placeholder{color:#6b7280!important}[data-theme=dark] .input[readonly],[data-theme=dark] .select[readonly],[data-theme=dark] .textarea[readonly]{color:#d1d5db!important;background-color:hsl(var(--b2));border-color:hsl(var(--bc)/.15)}[data-theme=dark] .password-toggle-container .input{color:#fff!important}[data-theme=dark] .password-toggle-container .textarea{color:#fff!important;font-family:JetBrains Mono,Consolas,Monaco,Courier New,monospace}[data-theme=dark] .label-text{color:#f3f4f6}[data-theme=dark] .label-text-alt{color:#9ca3af}.text-warning,[data-theme=dark] .label-text-alt.text-warning,[data-theme=dark] .text-warning{color:var(--warning-color)!important}[data-theme=dark] .file-input{color:#fff!important}[data-theme=dark] .file-input::file-selector-button{background-color:hsl(var(--b3));color:#fff;border:none;border-right:1px solid hsl(var(--bc)/.2)}[data-theme=dark] input[type=search]{color:#fff!important}[data-theme=dark] input[type=search]::-moz-placeholder{color:#6b7280!important;font-style:italic}[data-theme=dark] input[type=search]::placeholder{color:#6b7280!important;font-style:italic}[data-theme=dark] input[type=date],[data-theme=dark] input[type=datetime-local],[data-theme=dark] input[type=email],[data-theme=dark] input[type=number],[data-theme=dark] input[type=time],[data-theme=dark] input[type=url]{color:#fff!important}[data-theme=dark] .input:-webkit-autofill,[data-theme=dark] .input:-webkit-autofill:focus,[data-theme=dark] .input:-webkit-autofill:hover{-webkit-box-shadow:0 0 0 1000px hsl(var(--b1)) inset!important;-webkit-text-fill-color:#fff!important;-webkit-transition:background-color 5000s ease-in-out 0s;transition:background-color 5000s ease-in-out 0s}[data-theme=dark] .input-error{color:#fff!important;border-color:hsl(var(--er));background-color:hsl(var(--er)/.1)}[data-theme=dark] .input-error::-moz-placeholder{color:#ef4444!important}[data-theme=dark] .input-error::placeholder{color:#ef4444!important}@media (max-width:768px){.card:hover{box-shadow:0 4px 15px rgba(0,0,0,.1)}.btn:hover:not(:disabled),.card:hover,.table tbody tr:hover{transform:none}}::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-track{background:hsl(var(--b1))}::-webkit-scrollbar-thumb{background:hsl(var(--bc)/.3);border-radius:4px}::-webkit-scrollbar-thumb:hover{background:hsl(var(--bc)/.5)}.tab-button{transition:all .2s ease-in-out;white-space:nowrap;cursor:pointer;background:none;border:none;outline:none}.tab-button.active{color:hsl(var(--p))!important;border-color:hsl(var(--p))!important}.tab-button:not(.active){color:hsl(var(--bc)/.7);border-color:transparent}.tab-button:not(.active):hover{color:hsl(var(--bc));border-color:hsl(var(--bc)/.3)}.tab-content{min-height:400px}.tab-content.hidden{display:none!important}.tab-content:not(.hidden){display:block;animation:fadeIn .3s ease-in-out}@keyframes fadeIn{0%{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}@media (max-width:640px){.tab-button{padding:12px 8px;font-size:12px}.tab-button i{font-size:16px}nav[aria-label="Configuration Tabs"]{overflow-x:auto;white-space:nowrap;padding-bottom:8px}nav[aria-label="Configuration Tabs"]::-webkit-scrollbar{height:4px}nav[aria-label="Configuration Tabs"]::-webkit-scrollbar-track{background:hsl(var(--b2))}nav[aria-label="Configuration Tabs"]::-webkit-scrollbar-thumb{background:hsl(var(--bc)/.3);border-radius:2px}}[data-theme=dark] .tab-button:not(.active){color:hsl(var(--bc)/.6)}[data-theme=dark] .tab-button:not(.active):hover{color:hsl(var(--bc)/.9);border-color:hsl(var(--bc)/.3)}[data-theme=light] .tab-button:not(.active){color:hsl(var(--bc)/.7)}[data-theme=light] .tab-button:not(.active):hover{color:hsl(var(--bc)/.9);border-color:hsl(var(--bc)/.4)}.tab-button{border-bottom-width:2px;border-bottom-style:solid}nav[aria-label="Configuration Tabs"]{display:flex;gap:2rem;min-height:60px;align-items:end}.tab-button .flex{align-items:center;justify-content:center;gap:.5rem}.tab-button:focus{outline:2px solid hsl(var(--p));outline-offset:2px;border-radius:4px}.tab-button:focus:not(:focus-visible){outline:none}.hover\:border-base-300:hover{--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity,1)))}.hover\:bg-base-200:hover{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity,1)))}.hover\:bg-base-300:hover{--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity,1)))}.hover\:text-base-content:hover{--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity,1)))}.hover\:text-primary:hover{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity,1)))}@media (min-width:640px){.sm\:inline{display:inline}.sm\:w-auto{width:auto}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}}@media (min-width:768px){.md\:inline{display:inline}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:1024px){.lg\:flex{display:flex}.lg\:hidden{display:none}.lg\:w-auto{width:auto}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:flex-row{flex-direction:row}.lg\:items-center{align-items:center}}@media (min-width:1280px){.xl\:inline{display:inline}} \ No newline at end of file +/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}:root,[data-theme]{background-color:var(--fallback-b1,oklch(var(--b1)/1));color:var(--fallback-bc,oklch(var(--bc)/1))}@supports not (color:oklch(0% 0 0)){:root{color-scheme:light;--fallback-p:#491eff;--fallback-pc:#d4dbff;--fallback-s:#ff41c7;--fallback-sc:#fff9fc;--fallback-a:#00cfbd;--fallback-ac:#00100d;--fallback-n:#2b3440;--fallback-nc:#d7dde4;--fallback-b1:#fff;--fallback-b2:#e5e6e6;--fallback-b3:#e5e6e6;--fallback-bc:#1f2937;--fallback-in:#00b3f0;--fallback-inc:#000;--fallback-su:#00ca92;--fallback-suc:#000;--fallback-wa:#ffc22d;--fallback-wac:#000;--fallback-er:#ff6f70;--fallback-erc:#000}@media (prefers-color-scheme:dark){:root{color-scheme:dark;--fallback-p:#7582ff;--fallback-pc:#050617;--fallback-s:#ff71cf;--fallback-sc:#190211;--fallback-a:#00c7b5;--fallback-ac:#000e0c;--fallback-n:#2a323c;--fallback-nc:#a6adbb;--fallback-b1:#1d232a;--fallback-b2:#191e24;--fallback-b3:#15191e;--fallback-bc:#a6adbb;--fallback-in:#00b3f0;--fallback-inc:#000;--fallback-su:#00ca92;--fallback-suc:#000;--fallback-wa:#ffc22d;--fallback-wac:#000;--fallback-er:#ff6f70;--fallback-erc:#000}}}html{-webkit-tap-highlight-color:transparent}*{scrollbar-color:color-mix(in oklch,currentColor 35%,transparent) transparent}:hover{scrollbar-color:color-mix(in oklch,currentColor 60%,transparent) transparent}:root{color-scheme:light;--in:72.06% 0.191 231.6;--su:64.8% 0.150 160;--wa:84.71% 0.199 83.87;--er:71.76% 0.221 22.18;--pc:89.824% 0.06192 275.75;--ac:15.352% 0.0368 183.61;--inc:0% 0 0;--suc:0% 0 0;--wac:0% 0 0;--erc:0% 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:49.12% 0.3096 275.75;--s:69.71% 0.329 342.55;--sc:98.71% 0.0106 342.55;--a:76.76% 0.184 183.61;--n:32.1785% 0.02476 255.701624;--nc:89.4994% 0.011585 252.096176;--b1:100% 0 0;--b2:96.1151% 0 0;--b3:92.4169% 0.00108 197.137559;--bc:27.8078% 0.029596 256.847952}@media (prefers-color-scheme:dark){:root{color-scheme:dark;--in:72.06% 0.191 231.6;--su:64.8% 0.150 160;--wa:84.71% 0.199 83.87;--er:71.76% 0.221 22.18;--pc:13.138% 0.0392 275.75;--sc:14.96% 0.052 342.55;--ac:14.902% 0.0334 183.61;--inc:0% 0 0;--suc:0% 0 0;--wac:0% 0 0;--erc:0% 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:65.69% 0.196 275.75;--s:74.8% 0.26 342.55;--a:74.51% 0.167 183.61;--n:31.3815% 0.021108 254.139175;--nc:74.6477% 0.0216 264.435964;--b1:25.3267% 0.015896 252.417568;--b2:23.2607% 0.013807 253.100675;--b3:21.1484% 0.01165 254.087939;--bc:74.6477% 0.0216 264.435964}}[data-theme=light]{color-scheme:light;--in:72.06% 0.191 231.6;--su:64.8% 0.150 160;--wa:84.71% 0.199 83.87;--er:71.76% 0.221 22.18;--pc:89.824% 0.06192 275.75;--ac:15.352% 0.0368 183.61;--inc:0% 0 0;--suc:0% 0 0;--wac:0% 0 0;--erc:0% 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:49.12% 0.3096 275.75;--s:69.71% 0.329 342.55;--sc:98.71% 0.0106 342.55;--a:76.76% 0.184 183.61;--n:32.1785% 0.02476 255.701624;--nc:89.4994% 0.011585 252.096176;--b1:100% 0 0;--b2:96.1151% 0 0;--b3:92.4169% 0.00108 197.137559;--bc:27.8078% 0.029596 256.847952}:root:has(input.theme-controller[value=light]:checked){color-scheme:light;--in:72.06% 0.191 231.6;--su:64.8% 0.150 160;--wa:84.71% 0.199 83.87;--er:71.76% 0.221 22.18;--pc:89.824% 0.06192 275.75;--ac:15.352% 0.0368 183.61;--inc:0% 0 0;--suc:0% 0 0;--wac:0% 0 0;--erc:0% 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:49.12% 0.3096 275.75;--s:69.71% 0.329 342.55;--sc:98.71% 0.0106 342.55;--a:76.76% 0.184 183.61;--n:32.1785% 0.02476 255.701624;--nc:89.4994% 0.011585 252.096176;--b1:100% 0 0;--b2:96.1151% 0 0;--b3:92.4169% 0.00108 197.137559;--bc:27.8078% 0.029596 256.847952}[data-theme=dark]{color-scheme:dark;--in:72.06% 0.191 231.6;--su:64.8% 0.150 160;--wa:84.71% 0.199 83.87;--er:71.76% 0.221 22.18;--pc:13.138% 0.0392 275.75;--sc:14.96% 0.052 342.55;--ac:14.902% 0.0334 183.61;--inc:0% 0 0;--suc:0% 0 0;--wac:0% 0 0;--erc:0% 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:65.69% 0.196 275.75;--s:74.8% 0.26 342.55;--a:74.51% 0.167 183.61;--n:31.3815% 0.021108 254.139175;--nc:74.6477% 0.0216 264.435964;--b1:25.3267% 0.015896 252.417568;--b2:23.2607% 0.013807 253.100675;--b3:21.1484% 0.01165 254.087939;--bc:74.6477% 0.0216 264.435964}:root:has(input.theme-controller[value=dark]:checked){color-scheme:dark;--in:72.06% 0.191 231.6;--su:64.8% 0.150 160;--wa:84.71% 0.199 83.87;--er:71.76% 0.221 22.18;--pc:13.138% 0.0392 275.75;--sc:14.96% 0.052 342.55;--ac:14.902% 0.0334 183.61;--inc:0% 0 0;--suc:0% 0 0;--wac:0% 0 0;--erc:0% 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:65.69% 0.196 275.75;--s:74.8% 0.26 342.55;--a:74.51% 0.167 183.61;--n:31.3815% 0.021108 254.139175;--nc:74.6477% 0.0216 264.435964;--b1:25.3267% 0.015896 252.417568;--b2:23.2607% 0.013807 253.100675;--b3:21.1484% 0.01165 254.087939;--bc:74.6477% 0.0216 264.435964}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.alert{display:grid;width:100%;grid-auto-flow:row;align-content:flex-start;align-items:center;justify-items:center;gap:1rem;text-align:center;border-radius:var(--rounded-box,1rem);border-width:1px;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));padding:1rem;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-b2,oklch(var(--b2)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1));background-color:var(--alert-bg)}@media (min-width:640px){.alert{grid-auto-flow:column;grid-template-columns:auto minmax(auto,1fr);justify-items:start;text-align:start}}.avatar.placeholder>div{display:flex;align-items:center;justify-content:center}.badge{display:inline-flex;align-items:center;justify-content:center;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1);transition-duration:.2s;height:1.25rem;font-size:.875rem;line-height:1.25rem;width:-moz-fit-content;width:fit-content;padding-left:.563rem;padding-right:.563rem;border-radius:var(--rounded-badge,1.9rem);border-width:1px;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}@media (hover:hover){.link-hover:hover{text-decoration-line:underline}.label a:hover{--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.menu li>:not(ul,.menu-title,details,.btn).active,.menu li>:not(ul,.menu-title,details,.btn):active,.menu li>details>summary:active{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.tab:hover{--tw-text-opacity:1}.table tr.hover:hover,.table tr.hover:nth-child(2n):hover{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))}.table-zebra tr.hover:hover,.table-zebra tr.hover:nth-child(2n):hover{--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}}.btn{display:inline-flex;height:3rem;min-height:3rem;flex-shrink:0;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;flex-wrap:wrap;align-items:center;justify-content:center;border-radius:var(--rounded-btn,.5rem);border-color:transparent;border-color:oklch(var(--btn-color,var(--b2))/var(--tw-border-opacity));padding-left:1rem;padding-right:1rem;text-align:center;font-size:.875rem;line-height:1em;gap:.5rem;font-weight:600;text-decoration-line:none;transition-duration:.2s;transition-timing-function:cubic-bezier(0,0,.2,1);border-width:var(--border-btn,1px);transition-property:color,background-color,border-color,opacity,box-shadow,transform;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:var(--fallback-bc,oklch(var(--bc)/1));background-color:oklch(var(--btn-color,var(--b2))/var(--tw-bg-opacity));--tw-bg-opacity:1;--tw-border-opacity:1}.btn-disabled,.btn:disabled,.btn[disabled]{pointer-events:none}.btn-circle{height:3rem;width:3rem;border-radius:9999px;padding:0}:where(.btn:is(input[type=checkbox])),:where(.btn:is(input[type=radio])){width:auto;-webkit-appearance:none;-moz-appearance:none;appearance:none}.btn:is(input[type=checkbox]):after,.btn:is(input[type=radio]):after{--tw-content:attr(aria-label);content:var(--tw-content)}.card{position:relative;display:flex;flex-direction:column;border-radius:var(--rounded-box,1rem)}.card:focus{outline:2px solid transparent;outline-offset:2px}.card-body{display:flex;flex:1 1 auto;flex-direction:column;padding:var(--padding-card,2rem);gap:.5rem}.card-body :where(p){flex-grow:1}.card figure{display:flex;align-items:center;justify-content:center}.card.image-full{display:grid}.card.image-full:before{position:relative;content:"";z-index:10;border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));opacity:.75}.card.image-full:before,.card.image-full>*{grid-column-start:1;grid-row-start:1}.card.image-full>figure img{height:100%;-o-object-fit:cover;object-fit:cover}.card.image-full>.card-body{position:relative;z-index:20;--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.checkbox{flex-shrink:0;--chkbg:var(--fallback-bc,oklch(var(--bc)/1));--chkfg:var(--fallback-b1,oklch(var(--b1)/1));height:1.5rem;width:1.5rem;cursor:pointer;-webkit-appearance:none;-moz-appearance:none;appearance:none;border-radius:var(--rounded-btn,.5rem);border-width:1px;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:0.2}.divider{display:flex;flex-direction:row;align-items:center;align-self:stretch;margin-top:1rem;margin-bottom:1rem;height:1rem;white-space:nowrap}.divider:after,.divider:before{height:.125rem;width:100%;flex-grow:1;--tw-content:"";content:var(--tw-content);background-color:var(--fallback-bc,oklch(var(--bc)/.1))}.dropdown{position:relative;display:inline-block}.dropdown>:not(summary):focus{outline:2px solid transparent;outline-offset:2px}.dropdown .dropdown-content{position:absolute}.dropdown:is(:not(details)) .dropdown-content{visibility:hidden;opacity:0;transform-origin:top;--tw-scale-x:.95;--tw-scale-y:.95;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1);transition-duration:.2s}.dropdown-end .dropdown-content{inset-inline-end:0}.dropdown-left .dropdown-content{bottom:auto;inset-inline-end:100%;top:0;transform-origin:right}.dropdown-right .dropdown-content{bottom:auto;inset-inline-start:100%;top:0;transform-origin:left}.dropdown-bottom .dropdown-content{bottom:auto;top:100%;transform-origin:top}.dropdown-top .dropdown-content{bottom:100%;top:auto;transform-origin:bottom}.dropdown-end.dropdown-left .dropdown-content,.dropdown-end.dropdown-right .dropdown-content{bottom:0;top:auto}.dropdown.dropdown-open .dropdown-content,.dropdown:focus-within .dropdown-content,.dropdown:not(.dropdown-hover):focus .dropdown-content{visibility:visible;opacity:1}@media (hover:hover){.dropdown.dropdown-hover:hover .dropdown-content{visibility:visible;opacity:1}.btm-nav>.\!disabled:hover{pointer-events:none!important;--tw-border-opacity:0!important;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)))!important;--tw-bg-opacity:0.1!important;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))!important;--tw-text-opacity:0.2!important}.btm-nav>.disabled:hover,.btm-nav>[disabled]:hover{pointer-events:none;--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.btn:hover{--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn:hover{background-color:color-mix(in oklab,oklch(var(--btn-color,var(--b2))/var(--tw-bg-opacity,1)) 90%,#000);border-color:color-mix(in oklab,oklch(var(--btn-color,var(--b2))/var(--tw-border-opacity,1)) 90%,#000)}}@supports not (color:oklch(0% 0 0)){.btn:hover{background-color:var(--btn-color,var(--fallback-b2));border-color:var(--btn-color,var(--fallback-b2))}}.btn.glass:hover{--glass-opacity:25%;--glass-border-opacity:15%}.btn-ghost:hover{border-color:transparent}@supports (color:oklch(0% 0 0)){.btn-ghost:hover{background-color:var(--fallback-bc,oklch(var(--bc)/.2))}}.btn-outline:hover{--tw-border-opacity:1;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-b1,oklch(var(--b1)/var(--tw-text-opacity)))}.btn-outline.btn-primary:hover{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-primary:hover{background-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000)}}.btn-outline.btn-secondary:hover{--tw-text-opacity:1;color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-secondary:hover{background-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000)}}.btn-outline.btn-accent:hover{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-accent:hover{background-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000)}}.btn-outline.btn-success:hover{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-success:hover{background-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000)}}.btn-outline.btn-info:hover{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-info:hover{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}}.btn-outline.btn-warning:hover{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-warning:hover{background-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000)}}.btn-outline.btn-error:hover{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-error:hover{background-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000)}}.btn-disabled:hover,.btn:disabled:hover,.btn[disabled]:hover{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}@supports (color:color-mix(in oklab,black,black)){.btn:is(input[type=checkbox]:checked):hover,.btn:is(input[type=radio]:checked):hover{background-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000)}}.dropdown.dropdown-hover:hover .dropdown-content{--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(.active,.btn):hover,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.active,.btn):hover{cursor:pointer;outline:2px solid transparent;outline-offset:2px}@supports (color:oklch(0% 0 0)){:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(.active,.btn):hover,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.active,.btn):hover{background-color:var(--fallback-bc,oklch(var(--bc)/.1))}}.tab[disabled],.tab[disabled]:hover{cursor:not-allowed;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}}.dropdown:is(details) summary::-webkit-details-marker{display:none}.file-input{height:3rem;flex-shrink:1;padding-inline-end:1rem;font-size:1rem;line-height:2;line-height:1.5rem;overflow:hidden;border-radius:var(--rounded-btn,.5rem);border-width:1px;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:0;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.file-input::file-selector-button{margin-inline-end:1rem;display:inline-flex;height:100%;flex-shrink:0;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;flex-wrap:wrap;align-items:center;justify-content:center;padding-left:1rem;padding-right:1rem;text-align:center;font-size:.875rem;line-height:1.25rem;line-height:1em;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1);transition-duration:.2s;border-style:solid;--tw-border-opacity:1;border-color:var(--fallback-n,oklch(var(--n)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));font-weight:600;text-transform:uppercase;--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)));text-decoration-line:none;border-width:var(--border-btn,1px);animation:button-pop var(--animation-btn,.25s) ease-out}.footer{width:100%;grid-auto-flow:row;-moz-column-gap:1rem;column-gap:1rem;row-gap:2.5rem;font-size:.875rem;line-height:1.25rem}.footer,.footer>*{display:grid;place-items:start}.footer>*{gap:.5rem}.footer-center{text-align:center}.footer-center,.footer-center>*{place-items:center}@media (min-width:48rem){.footer{grid-auto-flow:column}.footer-center{grid-auto-flow:row dense}}.form-control{flex-direction:column}.form-control,.label{display:flex}.label{-webkit-user-select:none;-moz-user-select:none;user-select:none;align-items:center;justify-content:space-between;padding:.5rem .25rem}.hero{display:grid;width:100%;place-items:center;background-size:cover;background-position:50%}.hero>*{grid-column-start:1;grid-row-start:1}.hero-content{z-index:0;display:flex;align-items:center;justify-content:center;max-width:80rem;gap:1rem;padding:1rem}.indicator{position:relative;display:inline-flex;width:-moz-max-content;width:max-content}.indicator :where(.indicator-item){z-index:1;position:absolute;white-space:nowrap}.\!input{flex-shrink:1!important;-webkit-appearance:none!important;-moz-appearance:none!important;appearance:none!important;height:3rem!important;padding-left:1rem!important;padding-right:1rem!important;font-size:1rem!important;line-height:2!important;line-height:1.5rem!important;border-radius:var(--rounded-btn,.5rem)!important;border-width:1px!important;border-color:transparent!important;--tw-bg-opacity:1!important;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))!important}.input{flex-shrink:1;-webkit-appearance:none;-moz-appearance:none;appearance:none;height:3rem;padding-left:1rem;padding-right:1rem;font-size:1rem;line-height:2;line-height:1.5rem;border-radius:var(--rounded-btn,.5rem);border-width:1px;border-color:transparent;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.\!input[type=number]::-webkit-inner-spin-button{margin-top:-1rem!important;margin-bottom:-1rem!important;margin-inline-end:-1rem!important}.input-md[type=number]::-webkit-inner-spin-button,.input[type=number]::-webkit-inner-spin-button{margin-top:-1rem;margin-bottom:-1rem;margin-inline-end:-1rem}.input-sm[type=number]::-webkit-inner-spin-button{margin-top:0;margin-bottom:0;margin-inline-end:0}.join{display:inline-flex;align-items:stretch;border-radius:var(--rounded-btn,.5rem)}.join :where(.join-item){border-start-end-radius:0;border-end-end-radius:0;border-end-start-radius:0;border-start-start-radius:0}.join .join-item:not(:first-child):not(:last-child),.join :not(:first-child):not(:last-child) .join-item{border-start-end-radius:0;border-end-end-radius:0;border-end-start-radius:0;border-start-start-radius:0}.join .join-item:first-child:not(:last-child),.join :first-child:not(:last-child) .join-item{border-start-end-radius:0;border-end-end-radius:0}.join .dropdown .join-item:first-child:not(:last-child),.join :first-child:not(:last-child) .dropdown .join-item{border-start-end-radius:inherit;border-end-end-radius:inherit}.join :where(.join-item:first-child:not(:last-child)),.join :where(:first-child:not(:last-child) .join-item){border-end-start-radius:inherit;border-start-start-radius:inherit}.join .join-item:last-child:not(:first-child),.join :last-child:not(:first-child) .join-item{border-end-start-radius:0;border-start-start-radius:0}.join :where(.join-item:last-child:not(:first-child)),.join :where(:last-child:not(:first-child) .join-item){border-start-end-radius:inherit;border-end-end-radius:inherit}@supports not selector(:has(*)){:where(.join *){border-radius:inherit}}@supports selector(:has(*)){:where(.join :has(.join-item)){border-radius:inherit}}.kbd{display:inline-flex;align-items:center;justify-content:center;border-radius:var(--rounded-btn,.5rem);border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:0.2;--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));padding-left:.5rem;padding-right:.5rem;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));border-width:1px 1px 2px;min-height:2.2em;min-width:2.2em}.link{cursor:pointer;text-decoration-line:underline}.link-hover{text-decoration-line:none}.menu{display:flex;flex-direction:column;flex-wrap:wrap;font-size:.875rem;line-height:1.25rem;padding:.5rem}.menu :where(li ul){position:relative;white-space:nowrap;margin-inline-start:1rem;padding-inline-start:.5rem}.menu :where(li:not(.menu-title)>:not(ul,details,.menu-title,.btn)),.menu :where(li:not(.menu-title)>details>summary:not(.menu-title)){display:grid;grid-auto-flow:column;align-content:flex-start;align-items:center;gap:.5rem;grid-auto-columns:minmax(auto,max-content) auto max-content;-webkit-user-select:none;-moz-user-select:none;user-select:none}.menu li.disabled{cursor:not-allowed;-webkit-user-select:none;-moz-user-select:none;user-select:none;color:var(--fallback-bc,oklch(var(--bc)/.3))}.menu li.\!disabled{cursor:not-allowed!important;-webkit-user-select:none!important;-moz-user-select:none!important;user-select:none!important;color:var(--fallback-bc,oklch(var(--bc)/.3))!important}.menu :where(li>.menu-dropdown:not(.menu-dropdown-show)){display:none}:where(.menu li){position:relative;display:flex;flex-shrink:0;flex-direction:column;flex-wrap:wrap;align-items:stretch}:where(.menu li) .badge{justify-self:end}.modal{pointer-events:none;position:fixed;inset:0;margin:0;display:grid;height:100%;max-height:none;width:100%;max-width:none;justify-items:center;padding:0;opacity:0;overscroll-behavior:contain;z-index:999;background-color:transparent;color:inherit;transition-duration:.2s;transition-timing-function:cubic-bezier(0,0,.2,1);transition-property:transform,opacity,visibility;overflow-y:hidden}:where(.modal){align-items:center}.modal-box{max-height:calc(100vh - 5em);grid-column-start:1;grid-row-start:1;width:91.666667%;max-width:32rem;--tw-scale-x:.9;--tw-scale-y:.9;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));border-bottom-right-radius:var(--rounded-box,1rem);border-bottom-left-radius:var(--rounded-box,1rem);border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));padding:1.5rem;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1);transition-duration:.2s;box-shadow:0 25px 50px -12px rgba(0,0,0,.25);overflow-y:auto;overscroll-behavior:contain}.modal-open,.modal-toggle:checked+.modal,.modal:target,.modal[open]{pointer-events:auto;visibility:visible;opacity:1}.modal-action{display:flex;margin-top:1.5rem;justify-content:flex-end}:root:has(:is(.modal-open,.modal:target,.modal-toggle:checked+.modal,.modal[open])){overflow:hidden;scrollbar-gutter:stable}.navbar{display:flex;align-items:center;padding:var(--navbar-padding,.5rem);min-height:4rem;width:100%}:where(.navbar>:not(script,style)){display:inline-flex;align-items:center}.navbar-start{width:50%;justify-content:flex-start}.navbar-center{flex-shrink:0}.navbar-end{width:50%;justify-content:flex-end}.progress{position:relative;width:100%;-webkit-appearance:none;-moz-appearance:none;appearance:none;overflow:hidden;height:.5rem;border-radius:var(--rounded-box,1rem);background-color:var(--fallback-bc,oklch(var(--bc)/.2))}.radio{flex-shrink:0;--chkbg:var(--bc);width:1.5rem;-webkit-appearance:none;border-radius:9999px;border-width:1px;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:0.2}.radio,.range{height:1.5rem;cursor:pointer;-moz-appearance:none;appearance:none}.range{width:100%;-webkit-appearance:none;--range-shdw:var(--fallback-bc,oklch(var(--bc)/1));overflow:hidden;border-radius:var(--rounded-box,1rem);background-color:transparent}.range:focus{outline:none}.select{display:inline-flex;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;-webkit-appearance:none;-moz-appearance:none;appearance:none;height:3rem;min-height:3rem;padding-inline-start:1rem;padding-inline-end:2.5rem;font-size:.875rem;line-height:1.25rem;line-height:2;border-radius:var(--rounded-btn,.5rem);border-width:1px;border-color:transparent;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));background-image:linear-gradient(45deg,transparent 50%,currentColor 0),linear-gradient(135deg,currentColor 50%,transparent 0);background-position:calc(100% - 20px) calc(1px + 50%),calc(100% - 16.1px) calc(1px + 50%);background-size:4px 4px,4px 4px;background-repeat:no-repeat}.select[multiple]{height:auto}.stack{display:inline-grid;place-items:center;align-items:flex-end}.stack>*{grid-column-start:1;grid-row-start:1;transform:translateY(10%) scale(.9);z-index:1;width:100%;opacity:.6}.stack>:nth-child(2){transform:translateY(5%) scale(.95);z-index:2;opacity:.8}.stack>:first-child{transform:translateY(0) scale(1);z-index:3;opacity:1}.stats{display:inline-grid;border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}:where(.stats){grid-auto-flow:column;overflow-x:auto}.steps .step{display:grid;grid-template-columns:repeat(1,minmax(0,1fr));grid-template-columns:auto;grid-template-rows:repeat(2,minmax(0,1fr));grid-template-rows:40px 1fr;place-items:center;text-align:center;min-width:4rem}.swap{position:relative;display:inline-grid;-webkit-user-select:none;-moz-user-select:none;user-select:none;place-content:center;cursor:pointer}.swap>*{grid-column-start:1;grid-row-start:1;transition-duration:.3s;transition-timing-function:cubic-bezier(0,0,.2,1);transition-property:transform,opacity}.swap input{-webkit-appearance:none;-moz-appearance:none;appearance:none}.swap .swap-indeterminate,.swap .swap-on,.swap input:indeterminate~.swap-on{opacity:0}.swap input:checked~.swap-off,.swap input:indeterminate~.swap-off,.swap-active .swap-off{opacity:0}.swap input:checked~.swap-on,.swap input:indeterminate~.swap-indeterminate,.swap-active .swap-on{opacity:1}.tabs{display:grid;align-items:flex-end}.tabs-lifted:has(.tab-content[class*=" rounded-"]) .tab:first-child:not(:is(.tab-active,[aria-selected=true])),.tabs-lifted:has(.tab-content[class^=rounded-]) .tab:first-child:not(:is(.tab-active,[aria-selected=true])){border-bottom-color:transparent}.tab{position:relative;grid-row-start:1;display:inline-flex;height:2rem;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;-webkit-appearance:none;-moz-appearance:none;appearance:none;flex-wrap:wrap;align-items:center;justify-content:center;text-align:center;font-size:.875rem;line-height:1.25rem;line-height:2;--tab-padding:1rem;--tw-text-opacity:0.5;--tab-color:var(--fallback-bc,oklch(var(--bc)/1));--tab-bg:var(--fallback-b1,oklch(var(--b1)/1));--tab-border-color:var(--fallback-b3,oklch(var(--b3)/1));color:var(--tab-color);padding-inline-start:var(--tab-padding,1rem);padding-inline-end:var(--tab-padding,1rem)}.tab:is(input[type=radio]){width:auto;border-bottom-right-radius:0;border-bottom-left-radius:0}.tab:is(input[type=radio]):after{--tw-content:attr(aria-label);content:var(--tw-content)}.tab:not(input):empty{cursor:default;grid-column-start:span 9999}.tab-content{grid-column-start:1;grid-column-end:span 9999;grid-row-start:2;margin-top:calc(var(--tab-border)*-1);display:none;border-color:transparent;border-width:var(--tab-border,0)}:checked+.tab-content:nth-child(2),:is(.tab-active,[aria-selected=true])+.tab-content:nth-child(2){border-start-start-radius:0}:is(.tab-active,[aria-selected=true])+.tab-content,input.tab:checked+.tab-content{display:block}.table{position:relative;width:100%;border-radius:var(--rounded-box,1rem);text-align:left;font-size:.875rem;line-height:1.25rem}.table :where(.table-pin-rows thead tr){position:sticky;top:0;z-index:1;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.table :where(.table-pin-rows tfoot tr){position:sticky;bottom:0;z-index:1;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.table :where(.table-pin-cols tr th){position:sticky;left:0;right:0;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.textarea{min-height:3rem;flex-shrink:1;padding:.5rem 1rem;font-size:.875rem;line-height:1.25rem;line-height:2;border-radius:var(--rounded-btn,.5rem);border-width:1px;border-color:transparent;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.toast{position:fixed;display:flex;min-width:-moz-fit-content;min-width:fit-content;flex-direction:column;white-space:nowrap;gap:.5rem;padding:1rem}.toggle{flex-shrink:0;--tglbg:var(--fallback-b1,oklch(var(--b1)/1));--handleoffset:1.5rem;--handleoffsetcalculator:calc(var(--handleoffset)*-1);--togglehandleborder:0 0;height:1.5rem;width:3rem;cursor:pointer;-webkit-appearance:none;-moz-appearance:none;appearance:none;border-radius:var(--rounded-badge,1.9rem);border-width:1px;border-color:currentColor;background-color:currentColor;color:var(--fallback-bc,oklch(var(--bc)/.5));transition:background,box-shadow var(--animation-input,.2s) ease-out;box-shadow:var(--handleoffsetcalculator) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset,var(--togglehandleborder)}.alert-info{border-color:var(--fallback-in,oklch(var(--in)/.2));--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-in,oklch(var(--in)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.alert-success{border-color:var(--fallback-su,oklch(var(--su)/.2));--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-su,oklch(var(--su)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.alert-warning{border-color:var(--fallback-wa,oklch(var(--wa)/.2));--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)));--alert-bg:var(--fallback-wa,oklch(var(--wa)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.alert-error{border-color:var(--fallback-er,oklch(var(--er)/.2));--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-er,oklch(var(--er)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.badge-primary{border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.badge-primary,.badge-secondary{--tw-border-opacity:1;--tw-bg-opacity:1;--tw-text-opacity:1}.badge-secondary{border-color:var(--fallback-s,oklch(var(--s)/var(--tw-border-opacity)));background-color:var(--fallback-s,oklch(var(--s)/var(--tw-bg-opacity)));color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)))}.badge-accent{--tw-border-opacity:1;border-color:var(--fallback-a,oklch(var(--a)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-a,oklch(var(--a)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity)))}.badge-info{background-color:var(--fallback-in,oklch(var(--in)/var(--tw-bg-opacity)));color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}.badge-info,.badge-success{border-color:transparent;--tw-bg-opacity:1;--tw-text-opacity:1}.badge-success{background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity)));color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}.badge-warning{background-color:var(--fallback-wa,oklch(var(--wa)/var(--tw-bg-opacity)));color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}.badge-error,.badge-warning{border-color:transparent;--tw-bg-opacity:1;--tw-text-opacity:1}.badge-error{background-color:var(--fallback-er,oklch(var(--er)/var(--tw-bg-opacity)));color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)))}.badge-ghost{--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.badge-outline.badge-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}.badge-outline.badge-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity)))}.badge-outline.badge-accent{--tw-text-opacity:1;color:var(--fallback-a,oklch(var(--a)/var(--tw-text-opacity)))}.badge-outline.badge-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.badge-outline.badge-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity)))}.badge-outline.badge-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity)))}.badge-outline.badge-error{--tw-text-opacity:1;color:var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity)))}.btm-nav>:where(.active){border-top-width:2px;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.btm-nav>.\!disabled{pointer-events:none!important;--tw-border-opacity:0!important;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)))!important;--tw-bg-opacity:0.1!important;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))!important;--tw-text-opacity:0.2!important}.btm-nav>.disabled,.btm-nav>[disabled]{pointer-events:none;--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.btm-nav>* .label{font-size:1rem;line-height:1.5rem}@media (prefers-reduced-motion:no-preference){.btn{animation:button-pop var(--animation-btn,.25s) ease-out}}.btn:active:focus,.btn:active:hover{animation:button-pop 0s ease-out;transform:scale(var(--btn-focus-scale,.97))}@supports not (color:oklch(0% 0 0)){.btn{background-color:var(--btn-color,var(--fallback-b2));border-color:var(--btn-color,var(--fallback-b2))}.btn-primary{--btn-color:var(--fallback-p)}.btn-secondary{--btn-color:var(--fallback-s)}.btn-info{--btn-color:var(--fallback-in)}.btn-success{--btn-color:var(--fallback-su)}.btn-warning{--btn-color:var(--fallback-wa)}.btn-error{--btn-color:var(--fallback-er)}}@supports (color:color-mix(in oklab,black,black)){.btn-active{background-color:color-mix(in oklab,oklch(var(--btn-color,var(--b3))/var(--tw-bg-opacity,1)) 90%,#000);border-color:color-mix(in oklab,oklch(var(--btn-color,var(--b3))/var(--tw-border-opacity,1)) 90%,#000)}.btn-outline.btn-primary.btn-active{background-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000)}.btn-outline.btn-secondary.btn-active{background-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000)}.btn-outline.btn-accent.btn-active{background-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000)}.btn-outline.btn-success.btn-active{background-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000)}.btn-outline.btn-info.btn-active{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}.btn-outline.btn-warning.btn-active{background-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000)}.btn-outline.btn-error.btn-active{background-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000)}}.btn:focus-visible{outline-style:solid;outline-width:2px;outline-offset:2px}.btn-primary{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)));outline-color:var(--fallback-p,oklch(var(--p)/1))}@supports (color:oklch(0% 0 0)){.btn-primary{--btn-color:var(--p)}.btn-secondary{--btn-color:var(--s)}.btn-info{--btn-color:var(--in)}.btn-success{--btn-color:var(--su)}.btn-warning{--btn-color:var(--wa)}.btn-error{--btn-color:var(--er)}}.btn-secondary{--tw-text-opacity:1;color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)));outline-color:var(--fallback-s,oklch(var(--s)/1))}.btn-info{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));outline-color:var(--fallback-in,oklch(var(--in)/1))}.btn-success{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)));outline-color:var(--fallback-su,oklch(var(--su)/1))}.btn-warning{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)));outline-color:var(--fallback-wa,oklch(var(--wa)/1))}.btn-error{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)));outline-color:var(--fallback-er,oklch(var(--er)/1))}.btn.glass{--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.btn.glass.btn-active{--glass-opacity:25%;--glass-border-opacity:15%}.btn-ghost{border-width:1px;border-color:transparent;background-color:transparent;color:currentColor;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.btn-ghost.btn-active{border-color:transparent;background-color:var(--fallback-bc,oklch(var(--bc)/.2))}.btn-link.btn-active{border-color:transparent;background-color:transparent;text-decoration-line:underline}.btn-outline{border-color:currentColor;background-color:transparent;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.btn-outline.btn-active{--tw-border-opacity:1;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-b1,oklch(var(--b1)/var(--tw-text-opacity)))}.btn-outline.btn-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}.btn-outline.btn-primary.btn-active{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.btn-outline.btn-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity)))}.btn-outline.btn-secondary.btn-active{--tw-text-opacity:1;color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)))}.btn-outline.btn-accent{--tw-text-opacity:1;color:var(--fallback-a,oklch(var(--a)/var(--tw-text-opacity)))}.btn-outline.btn-accent.btn-active{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity)))}.btn-outline.btn-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity)))}.btn-outline.btn-success.btn-active{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}.btn-outline.btn-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.btn-outline.btn-info.btn-active{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}.btn-outline.btn-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity)))}.btn-outline.btn-warning.btn-active{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}.btn-outline.btn-error{--tw-text-opacity:1;color:var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity)))}.btn-outline.btn-error.btn-active{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)))}.btn.btn-disabled,.btn:disabled,.btn[disabled]{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.btn:is(input[type=checkbox]:checked),.btn:is(input[type=radio]:checked){--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.btn:is(input[type=checkbox]:checked):focus-visible,.btn:is(input[type=radio]:checked):focus-visible{outline-color:var(--fallback-p,oklch(var(--p)/1))}@keyframes button-pop{0%{transform:scale(var(--btn-focus-scale,.98))}40%{transform:scale(1.02)}to{transform:scale(1)}}.card :where(figure:first-child){overflow:hidden;border-start-start-radius:inherit;border-start-end-radius:inherit;border-end-start-radius:unset;border-end-end-radius:unset}.card :where(figure:last-child){overflow:hidden;border-start-start-radius:unset;border-start-end-radius:unset;border-end-start-radius:inherit;border-end-end-radius:inherit}.card:focus-visible{outline:2px solid currentColor;outline-offset:2px}.card.bordered{border-width:1px;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))}.card.compact .card-body{padding:1rem;font-size:.875rem;line-height:1.25rem}.card-title{display:flex;align-items:center;gap:.5rem;font-size:1.25rem;line-height:1.75rem;font-weight:600}.card.image-full :where(figure){overflow:hidden;border-radius:inherit}.checkbox:focus{box-shadow:none}.checkbox:focus-visible{outline-style:solid;outline-width:2px;outline-offset:2px;outline-color:var(--fallback-bc,oklch(var(--bc)/1))}.checkbox:disabled{border-width:0;cursor:not-allowed;border-color:transparent;--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));opacity:.2}.checkbox:checked,.checkbox[aria-checked=true]{background-repeat:no-repeat;animation:checkmark var(--animation-input,.2s) ease-out;background-color:var(--chkbg);background-image:linear-gradient(-45deg,transparent 65%,var(--chkbg) 65.99%),linear-gradient(45deg,transparent 75%,var(--chkbg) 75.99%),linear-gradient(-45deg,var(--chkbg) 40%,transparent 40.99%),linear-gradient(45deg,var(--chkbg) 30%,var(--chkfg) 30.99%,var(--chkfg) 40%,transparent 40.99%),linear-gradient(-45deg,var(--chkfg) 50%,var(--chkbg) 50.99%)}.checkbox:indeterminate{--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));background-repeat:no-repeat;animation:checkmark var(--animation-input,.2s) ease-out;background-image:linear-gradient(90deg,transparent 80%,var(--chkbg) 80%),linear-gradient(-90deg,transparent 80%,var(--chkbg) 80%),linear-gradient(0deg,var(--chkbg) 43%,var(--chkfg) 43%,var(--chkfg) 57%,var(--chkbg) 57%)}@keyframes checkmark{0%{background-position-y:5px}50%{background-position-y:-2px}to{background-position-y:0}}.divider:not(:empty){gap:1rem}.dropdown.dropdown-open .dropdown-content,.dropdown:focus .dropdown-content,.dropdown:focus-within .dropdown-content{--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.file-input-bordered{--tw-border-opacity:0.2}.file-input:focus{outline-style:solid;outline-width:2px;outline-offset:2px;outline-color:var(--fallback-bc,oklch(var(--bc)/.2))}.file-input-disabled,.file-input[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));--tw-text-opacity:0.2}.file-input-disabled::-moz-placeholder,.file-input[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.file-input-disabled::placeholder,.file-input[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.file-input-disabled::file-selector-button,.file-input[disabled]::file-selector-button{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.label-text{font-size:.875rem;line-height:1.25rem}.label-text,.label-text-alt{--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.label-text-alt{font-size:.75rem;line-height:1rem}.\!input input{--tw-bg-opacity:1!important;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)))!important;background-color:transparent!important}.input input{--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));background-color:transparent}.\!input input:focus{outline:2px solid transparent!important;outline-offset:2px!important}.input input:focus{outline:2px solid transparent;outline-offset:2px}.\!input[list]::-webkit-calendar-picker-indicator{line-height:1em!important}.input[list]::-webkit-calendar-picker-indicator{line-height:1em}.input-bordered{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.input:focus,.input:focus-within{box-shadow:none;border-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-style:solid;outline-width:2px;outline-offset:2px;outline-color:var(--fallback-bc,oklch(var(--bc)/.2))}.\!input:focus,.\!input:focus-within{box-shadow:none!important;border-color:var(--fallback-bc,oklch(var(--bc)/.2))!important;outline-style:solid!important;outline-width:2px!important;outline-offset:2px!important;outline-color:var(--fallback-bc,oklch(var(--bc)/.2))!important}.input-error{--tw-border-opacity:1;border-color:var(--fallback-er,oklch(var(--er)/var(--tw-border-opacity)))}.input-error:focus,.input-error:focus-within{--tw-border-opacity:1;border-color:var(--fallback-er,oklch(var(--er)/var(--tw-border-opacity)));outline-color:var(--fallback-er,oklch(var(--er)/1))}.input-disabled,.input:disabled,.input:has(>input[disabled]),.input[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));color:var(--fallback-bc,oklch(var(--bc)/.4))}.\!input:disabled,.\!input:has(>input[disabled]),.\!input[disabled]{cursor:not-allowed!important;--tw-border-opacity:1!important;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))!important;--tw-bg-opacity:1!important;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))!important;color:var(--fallback-bc,oklch(var(--bc)/.4))!important}.input-disabled::-moz-placeholder,.input:disabled::-moz-placeholder,.input:has(>input[disabled])::-moz-placeholder,.input[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.input-disabled::placeholder,.input:disabled::placeholder,.input:has(>input[disabled])::placeholder,.input[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.\!input:disabled::-moz-placeholder,.\!input:has(>input[disabled])::-moz-placeholder,.\!input[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)))!important;--tw-placeholder-opacity:0.2!important}.\!input:disabled::placeholder,.\!input:has(>input[disabled])::placeholder,.\!input[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)))!important;--tw-placeholder-opacity:0.2!important}.\!input:has(>input[disabled])>input[disabled]{cursor:not-allowed!important}.input:has(>input[disabled])>input[disabled]{cursor:not-allowed}.\!input::-webkit-date-and-time-value{text-align:inherit!important}.input::-webkit-date-and-time-value{text-align:inherit}.join>:where(:not(:first-child)){margin-top:0;margin-bottom:0;margin-inline-start:-1px}.join>:where(:not(:first-child)):is(.btn){margin-inline-start:calc(var(--border-btn)*-1)}.join-item:focus{isolation:isolate}.link-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){@media (hover:hover){.link-primary:hover{color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 80%,#000)}}}.link:focus{outline:2px solid transparent;outline-offset:2px}.link:focus-visible{outline:2px solid currentColor;outline-offset:2px}.loading{pointer-events:none;display:inline-block;aspect-ratio:1/1;width:1.5rem;background-color:currentColor;-webkit-mask-size:100%;mask-size:100%;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-position:center;mask-position:center}.loading,.loading-spinner{-webkit-mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' stroke='%23000'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-linecap='round' stroke-width='3'%3E%3CanimateTransform attributeName='transform' dur='2s' from='0 12 12' repeatCount='indefinite' to='360 12 12' type='rotate'/%3E%3Canimate attributeName='stroke-dasharray' dur='1.5s' keyTimes='0;0.475;1' repeatCount='indefinite' values='0,150;42,150;42,150'/%3E%3Canimate attributeName='stroke-dashoffset' dur='1.5s' keyTimes='0;0.475;1' repeatCount='indefinite' values='0;-16;-59'/%3E%3C/circle%3E%3C/svg%3E");mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' stroke='%23000'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-linecap='round' stroke-width='3'%3E%3CanimateTransform attributeName='transform' dur='2s' from='0 12 12' repeatCount='indefinite' to='360 12 12' type='rotate'/%3E%3Canimate attributeName='stroke-dasharray' dur='1.5s' keyTimes='0;0.475;1' repeatCount='indefinite' values='0,150;42,150;42,150'/%3E%3Canimate attributeName='stroke-dashoffset' dur='1.5s' keyTimes='0;0.475;1' repeatCount='indefinite' values='0;-16;-59'/%3E%3C/circle%3E%3C/svg%3E")}.loading-sm{width:1.25rem}.loading-lg{width:2.5rem}:where(.menu li:empty){--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));opacity:.1;margin:.5rem 1rem;height:1px}.menu :where(li ul):before{position:absolute;bottom:.75rem;inset-inline-start:0;top:.75rem;width:1px;--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));opacity:.1;content:""}.menu :where(li:not(.menu-title)>:not(ul,details,.menu-title,.btn)),.menu :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--rounded-btn,.5rem);padding:.5rem 1rem;text-align:start;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1);transition-duration:.2s;text-wrap:balance}:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):is(summary):not(.active,.btn):focus-visible,:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(summary,.active,.btn).focus,:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(summary,.active,.btn):focus,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):is(summary):not(.active,.btn):focus-visible,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(summary,.active,.btn).focus,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(summary,.active,.btn):focus{cursor:pointer;background-color:var(--fallback-bc,oklch(var(--bc)/.1));--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));outline:2px solid transparent;outline-offset:2px}.menu li>:not(ul,.menu-title,details,.btn).active,.menu li>:not(ul,.menu-title,details,.btn):active,.menu li>details>summary:active{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.menu :where(li>details>summary)::-webkit-details-marker{display:none}.menu :where(li>.menu-dropdown-toggle):after,.menu :where(li>details>summary):after{justify-self:end;display:block;margin-top:-.5rem;height:.5rem;width:.5rem;transform:rotate(45deg);transition-property:transform,margin-top;transition-duration:.3s;transition-timing-function:cubic-bezier(.4,0,.2,1);content:"";transform-origin:75% 75%;box-shadow:2px 2px;pointer-events:none}.menu :where(li>.menu-dropdown-toggle.menu-dropdown-show):after,.menu :where(li>details[open]>summary):after{transform:rotate(225deg);margin-top:0}.menu-title{padding:.5rem 1rem;font-size:.875rem;line-height:1.25rem;font-weight:700;color:var(--fallback-bc,oklch(var(--bc)/.4))}.mockup-phone .display{overflow:hidden;border-radius:40px;margin-top:-25px}.mockup-browser .mockup-browser-toolbar .\!input{position:relative!important;margin-left:auto!important;margin-right:auto!important;display:block!important;height:1.75rem!important;width:24rem!important;overflow:hidden!important;text-overflow:ellipsis!important;white-space:nowrap!important;--tw-bg-opacity:1!important;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))!important;padding-left:2rem!important;direction:ltr!important}.mockup-browser .mockup-browser-toolbar .input{position:relative;margin-left:auto;margin-right:auto;display:block;height:1.75rem;width:24rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));padding-left:2rem;direction:ltr}.mockup-browser .mockup-browser-toolbar .\!input:before{content:""!important;position:absolute!important;left:.5rem!important;top:50%!important;aspect-ratio:1/1!important;height:.75rem!important;--tw-translate-y:-50%!important;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))!important;border-radius:9999px!important;border-width:2px!important;border-color:currentColor!important;opacity:.6!important}.mockup-browser .mockup-browser-toolbar .input:before{content:"";position:absolute;left:.5rem;top:50%;aspect-ratio:1/1;height:.75rem;--tw-translate-y:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));border-radius:9999px;border-width:2px;border-color:currentColor;opacity:.6}.mockup-browser .mockup-browser-toolbar .\!input:after{content:""!important;position:absolute!important;left:1.25rem!important;top:50%!important;height:.5rem!important;--tw-translate-y:25%!important;--tw-rotate:-45deg!important;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))!important;border-radius:9999px!important;border-width:1px!important;border-color:currentColor!important;opacity:.6!important}.mockup-browser .mockup-browser-toolbar .input:after{content:"";position:absolute;left:1.25rem;top:50%;height:.5rem;--tw-translate-y:25%;--tw-rotate:-45deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));border-radius:9999px;border-width:1px;border-color:currentColor;opacity:.6}.modal::backdrop,.modal:not(dialog:not(.modal-open)){background-color:#0006;animation:modal-pop .2s ease-out}.modal-open .modal-box,.modal-toggle:checked+.modal .modal-box,.modal:target .modal-box,.modal[open] .modal-box{--tw-translate-y:0px;--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.modal-action>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.5rem*var(--tw-space-x-reverse));margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)))}.modal-action:where([dir=rtl],[dir=rtl] *)>:not([hidden])~:not([hidden]){--tw-space-x-reverse:1}@keyframes modal-pop{0%{opacity:0}}.progress::-moz-progress-bar{border-radius:var(--rounded-box,1rem);background-color:currentColor}.progress-primary::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)))}.progress:indeterminate{--progress-color:var(--fallback-bc,oklch(var(--bc)/1));background-image:repeating-linear-gradient(90deg,var(--progress-color) -1%,var(--progress-color) 10%,transparent 10%,transparent 90%);background-size:200%;background-position-x:15%;animation:progress-loading 5s ease-in-out infinite}.progress-primary:indeterminate{--progress-color:var(--fallback-p,oklch(var(--p)/1))}.progress::-webkit-progress-bar{border-radius:var(--rounded-box,1rem);background-color:transparent}.progress::-webkit-progress-value{border-radius:var(--rounded-box,1rem);background-color:currentColor}.progress-primary::-webkit-progress-value{--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)))}.progress:indeterminate::-moz-progress-bar{background-color:transparent;background-image:repeating-linear-gradient(90deg,var(--progress-color) -1%,var(--progress-color) 10%,transparent 10%,transparent 90%);background-size:200%;background-position-x:15%;animation:progress-loading 5s ease-in-out infinite}@keyframes progress-loading{50%{background-position-x:-115%}}.radio:focus{box-shadow:none}.radio:focus-visible{outline-style:solid;outline-width:2px;outline-offset:2px;outline-color:var(--fallback-bc,oklch(var(--bc)/1))}.radio:checked,.radio[aria-checked=true]{--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));background-image:none;animation:radiomark var(--animation-input,.2s) ease-out;box-shadow:0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset}.radio:disabled{cursor:not-allowed;opacity:.2}@keyframes radiomark{0%{box-shadow:0 0 0 12px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 12px var(--fallback-b1,oklch(var(--b1)/1)) inset}50%{box-shadow:0 0 0 3px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 3px var(--fallback-b1,oklch(var(--b1)/1)) inset}to{box-shadow:0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset}}.range:focus-visible::-webkit-slider-thumb{--focus-shadow:0 0 0 6px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 2rem var(--range-shdw) inset}.range:focus-visible::-moz-range-thumb{--focus-shadow:0 0 0 6px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 2rem var(--range-shdw) inset}.range::-webkit-slider-runnable-track{height:.5rem;width:100%;border-radius:var(--rounded-box,1rem);background-color:var(--fallback-bc,oklch(var(--bc)/.1))}.range::-moz-range-track{height:.5rem;width:100%;border-radius:var(--rounded-box,1rem);background-color:var(--fallback-bc,oklch(var(--bc)/.1))}.range::-webkit-slider-thumb{position:relative;height:1.5rem;width:1.5rem;border-radius:var(--rounded-box,1rem);border-style:none;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));appearance:none;-webkit-appearance:none;top:50%;color:var(--range-shdw);transform:translateY(-50%);--filler-size:100rem;--filler-offset:0.6rem;box-shadow:0 0 0 3px var(--range-shdw) inset,var(--focus-shadow,0 0),calc(var(--filler-size)*-1 - var(--filler-offset)) 0 0 var(--filler-size)}.range::-moz-range-thumb{position:relative;height:1.5rem;width:1.5rem;border-radius:var(--rounded-box,1rem);border-style:none;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));top:50%;color:var(--range-shdw);--filler-size:100rem;--filler-offset:0.5rem;box-shadow:0 0 0 3px var(--range-shdw) inset,var(--focus-shadow,0 0),calc(var(--filler-size)*-1 - var(--filler-offset)) 0 0 var(--filler-size)}@keyframes rating-pop{0%{transform:translateY(-.125em)}40%{transform:translateY(-.125em)}to{transform:translateY(0)}}.select-bordered,.select:focus{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.select:focus{box-shadow:none;outline-style:solid;outline-width:2px;outline-offset:2px;outline-color:var(--fallback-bc,oklch(var(--bc)/.2))}.select-disabled,.select:disabled,.select[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));color:var(--fallback-bc,oklch(var(--bc)/.4))}.select-disabled::-moz-placeholder,.select:disabled::-moz-placeholder,.select[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.select-disabled::placeholder,.select:disabled::placeholder,.select[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.select-multiple,.select[multiple],.select[size].select:not([size="1"]){background-image:none;padding-right:1rem}[dir=rtl] .select{background-position:12px calc(1px + 50%),16px calc(1px + 50%)}@keyframes skeleton{0%{background-position:150%}to{background-position:-50%}}:where(.stats)>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(0px*(1 - var(--tw-divide-y-reverse))) calc(1px*var(--tw-divide-x-reverse)) calc(0px*var(--tw-divide-y-reverse)) calc(1px*(1 - var(--tw-divide-x-reverse)))}[dir=rtl] .stats>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:1}.steps .step:before{top:0;height:.5rem;width:100%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));content:"";margin-inline-start:-100%}.steps .step:after,.steps .step:before{grid-column-start:1;grid-row-start:1;--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)));--tw-text-opacity:1}.steps .step:after{content:counter(step);counter-increment:step;z-index:1;position:relative;display:grid;height:2rem;width:2rem;place-items:center;place-self:center;border-radius:9999px;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.steps .step:first-child:before{content:none}.steps .step[data-content]:after{content:attr(data-content)}.swap-rotate .swap-indeterminate,.swap-rotate .swap-on,.swap-rotate input:indeterminate~.swap-on{--tw-rotate:45deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.swap-active:where(.swap-rotate) .swap-off,.swap-rotate input:checked~.swap-off,.swap-rotate input:indeterminate~.swap-off{--tw-rotate:-45deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.swap-active:where(.swap-rotate) .swap-on,.swap-rotate input:checked~.swap-on,.swap-rotate input:indeterminate~.swap-indeterminate{--tw-rotate:0deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.swap-flip .swap-indeterminate,.swap-flip .swap-on,.swap-flip input:indeterminate~.swap-on{transform:rotateY(180deg);backface-visibility:hidden;opacity:1}.swap-active:where(.swap-flip) .swap-off,.swap-flip input:checked~.swap-off,.swap-flip input:indeterminate~.swap-off{transform:rotateY(-180deg);backface-visibility:hidden;opacity:1}.swap-active:where(.swap-flip) .swap-on,.swap-flip input:checked~.swap-on,.swap-flip input:indeterminate~.swap-indeterminate{transform:rotateY(0deg)}.tabs-lifted>.tab:focus-visible{border-end-end-radius:0;border-end-start-radius:0}.tab:is(.tab-active,[aria-selected=true]):not(.tab-disabled):not([disabled]),.tab:is(input:checked){border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:1;--tw-text-opacity:1}.tab:focus{outline:2px solid transparent;outline-offset:2px}.tab:focus-visible{outline:2px solid currentColor;outline-offset:-5px}.tab-disabled,.tab[disabled]{cursor:not-allowed;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.tabs-bordered>.tab{border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:0.2;border-style:solid;border-bottom-width:calc(var(--tab-border, 1px) + 1px)}.tabs-lifted>.tab{border:var(--tab-border,1px) solid transparent;border-width:0 0 var(--tab-border,1px) 0;border-start-start-radius:var(--tab-radius,.5rem);border-start-end-radius:var(--tab-radius,.5rem);border-bottom-color:var(--tab-border-color);padding-inline-start:var(--tab-padding,1rem);padding-inline-end:var(--tab-padding,1rem);padding-top:var(--tab-border,1px)}.tabs-lifted>.tab:is(.tab-active,[aria-selected=true]):not(.tab-disabled):not([disabled]),.tabs-lifted>.tab:is(input:checked){background-color:var(--tab-bg);border-width:var(--tab-border,1px) var(--tab-border,1px) 0 var(--tab-border,1px);border-inline-start-color:var(--tab-border-color);border-inline-end-color:var(--tab-border-color);border-top-color:var(--tab-border-color);padding-inline-start:calc(var(--tab-padding, 1rem) - var(--tab-border, 1px));padding-inline-end:calc(var(--tab-padding, 1rem) - var(--tab-border, 1px));padding-bottom:var(--tab-border,1px);padding-top:0}.tabs-lifted>.tab:is(.tab-active,[aria-selected=true]):not(.tab-disabled):not([disabled]):before,.tabs-lifted>.tab:is(input:checked):before{z-index:1;content:"";display:block;position:absolute;width:calc(100% + var(--tab-radius, .5rem)*2);height:var(--tab-radius,.5rem);bottom:0;background-size:var(--tab-radius,.5rem);background-position:0 0,100% 0;background-repeat:no-repeat;--tab-grad:calc(69% - var(--tab-border, 1px));--radius-start:radial-gradient(circle at top left,transparent var(--tab-grad),var(--tab-border-color) calc(var(--tab-grad) + 0.25px),var(--tab-border-color) calc(var(--tab-grad) + var(--tab-border, 1px)),var(--tab-bg) calc(var(--tab-grad) + var(--tab-border, 1px) + 0.25px));--radius-end:radial-gradient(circle at top right,transparent var(--tab-grad),var(--tab-border-color) calc(var(--tab-grad) + 0.25px),var(--tab-border-color) calc(var(--tab-grad) + var(--tab-border, 1px)),var(--tab-bg) calc(var(--tab-grad) + var(--tab-border, 1px) + 0.25px));background-image:var(--radius-start),var(--radius-end)}.tabs-lifted>.tab:is(.tab-active,[aria-selected=true]):not(.tab-disabled):not([disabled]):first-child:before,.tabs-lifted>.tab:is(input:checked):first-child:before{background-image:var(--radius-end);background-position:100% 0}[dir=rtl] .tabs-lifted>.tab:is(.tab-active,[aria-selected=true]):not(.tab-disabled):not([disabled]):first-child:before,[dir=rtl] .tabs-lifted>.tab:is(input:checked):first-child:before{background-image:var(--radius-start);background-position:0 0}.tabs-lifted>.tab:is(.tab-active,[aria-selected=true]):not(.tab-disabled):not([disabled]):last-child:before,.tabs-lifted>.tab:is(input:checked):last-child:before{background-image:var(--radius-start);background-position:0 0}[dir=rtl] .tabs-lifted>.tab:is(.tab-active,[aria-selected=true]):not(.tab-disabled):not([disabled]):last-child:before,[dir=rtl] .tabs-lifted>.tab:is(input:checked):last-child:before{background-image:var(--radius-end);background-position:100% 0}.tabs-lifted>.tab:is(input:checked)+.tabs-lifted .tab:is(input:checked):before,.tabs-lifted>:is(.tab-active,[aria-selected=true]):not(.tab-disabled):not([disabled])+.tabs-lifted :is(.tab-active,[aria-selected=true]):not(.tab-disabled):not([disabled]):before{background-image:var(--radius-end);background-position:100% 0}.tabs-boxed .tab{border-radius:var(--rounded-btn,.5rem)}.table:where([dir=rtl],[dir=rtl] *){text-align:right}.table :where(th,td){padding:.75rem 1rem;vertical-align:middle}.table tr.active,.table tr.active:nth-child(2n),.table-zebra tbody tr:nth-child(2n){--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))}.table-zebra tr.active,.table-zebra tr.active:nth-child(2n),.table-zebra-zebra tbody tr:nth-child(2n){--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}.table :where(thead tr,tbody tr:not(:last-child),tbody tr:first-child:last-child){border-bottom-width:1px;--tw-border-opacity:1;border-bottom-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))}.table :where(thead,tfoot){white-space:nowrap;font-size:.75rem;line-height:1rem;font-weight:700;color:var(--fallback-bc,oklch(var(--bc)/.6))}.table :where(tfoot){border-top-width:1px;--tw-border-opacity:1;border-top-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))}.textarea-bordered,.textarea:focus{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.textarea:focus{box-shadow:none;outline-style:solid;outline-width:2px;outline-offset:2px;outline-color:var(--fallback-bc,oklch(var(--bc)/.2))}.textarea-disabled,.textarea:disabled,.textarea[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));color:var(--fallback-bc,oklch(var(--bc)/.4))}.textarea-disabled::-moz-placeholder,.textarea:disabled::-moz-placeholder,.textarea[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.textarea-disabled::placeholder,.textarea:disabled::placeholder,.textarea[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.toast>*{animation:toast-pop .25s ease-out}@keyframes toast-pop{0%{transform:scale(.9);opacity:0}to{transform:scale(1);opacity:1}}[dir=rtl] .toggle{--handleoffsetcalculator:calc(var(--handleoffset)*1)}.toggle:focus-visible{outline-style:solid;outline-width:2px;outline-offset:2px;outline-color:var(--fallback-bc,oklch(var(--bc)/.2))}.toggle:hover{background-color:currentColor}.toggle:checked,.toggle[aria-checked=true]{background-image:none;--handleoffsetcalculator:var(--handleoffset);--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}[dir=rtl] .toggle:checked,[dir=rtl] .toggle[aria-checked=true]{--handleoffsetcalculator:calc(var(--handleoffset)*-1)}.toggle:indeterminate{--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));box-shadow:calc(var(--handleoffset)/2) 0 0 2px var(--tglbg) inset,calc(var(--handleoffset)/-2) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset}[dir=rtl] .toggle:indeterminate{box-shadow:calc(var(--handleoffset)/2) 0 0 2px var(--tglbg) inset,calc(var(--handleoffset)/-2) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset}.toggle:disabled{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));background-color:transparent;opacity:.3;--togglehandleborder:0 0 0 3px var(--fallback-bc,oklch(var(--bc)/1)) inset,var(--handleoffsetcalculator) 0 0 3px var(--fallback-bc,oklch(var(--bc)/1)) inset}.glass,.glass.btn-active{border:none;-webkit-backdrop-filter:blur(var(--glass-blur,40px));backdrop-filter:blur(var(--glass-blur,40px));background-color:transparent;background-image:linear-gradient(135deg,rgb(255 255 255/var(--glass-opacity,30%)) 0,transparent 100%),linear-gradient(var(--glass-reflex-degree,100deg),rgb(255 255 255/var(--glass-reflex-opacity,10%)) 25%,transparent 25%);box-shadow:0 0 0 1px rgb(255 255 255/var(--glass-border-opacity,10%)) inset,0 0 0 2px rgb(0 0 0/5%);text-shadow:0 1px rgb(0 0 0/var(--glass-text-shadow-opacity,5%))}@media (hover:hover){.glass.btn-active{border:none;-webkit-backdrop-filter:blur(var(--glass-blur,40px));backdrop-filter:blur(var(--glass-blur,40px));background-color:transparent;background-image:linear-gradient(135deg,rgb(255 255 255/var(--glass-opacity,30%)) 0,transparent 100%),linear-gradient(var(--glass-reflex-degree,100deg),rgb(255 255 255/var(--glass-reflex-opacity,10%)) 25%,transparent 25%);box-shadow:0 0 0 1px rgb(255 255 255/var(--glass-border-opacity,10%)) inset,0 0 0 2px rgb(0 0 0/5%);text-shadow:0 1px rgb(0 0 0/var(--glass-text-shadow-opacity,5%))}}.badge-xs{height:.75rem;font-size:.75rem;line-height:.75rem;padding-left:.313rem;padding-right:.313rem}.badge-sm{height:1rem;font-size:.75rem;line-height:1rem;padding-left:.438rem;padding-right:.438rem}.btm-nav-xs>:where(.active){border-top-width:1px}.btm-nav-sm>:where(.active){border-top-width:2px}.btm-nav-md>:where(.active){border-top-width:2px}.btm-nav-lg>:where(.active){border-top-width:4px}.btn-xs{height:1.5rem;min-height:1.5rem;padding-left:.5rem;padding-right:.5rem;font-size:.75rem}.btn-sm{height:2rem;min-height:2rem;padding-left:.75rem;padding-right:.75rem;font-size:.875rem}.btn-lg{height:4rem;min-height:4rem;padding-left:1.5rem;padding-right:1.5rem;font-size:1.125rem}.btn-square:where(.btn-xs){height:1.5rem;width:1.5rem;padding:0}.btn-square:where(.btn-sm){height:2rem;width:2rem;padding:0}.btn-square:where(.btn-lg){height:4rem;width:4rem;padding:0}.btn-circle:where(.btn-xs){height:1.5rem;width:1.5rem;border-radius:9999px;padding:0}.btn-circle:where(.btn-sm){height:2rem;width:2rem;border-radius:9999px;padding:0}.btn-circle:where(.btn-md){height:3rem;width:3rem;border-radius:9999px;padding:0}.btn-circle:where(.btn-lg){height:4rem;width:4rem;border-radius:9999px;padding:0}[type=checkbox].checkbox-sm{height:1.25rem;width:1.25rem}[type=checkbox].checkbox-lg{height:2rem;width:2rem}.indicator :where(.indicator-item){bottom:auto;inset-inline-end:0;inset-inline-start:auto;top:0;--tw-translate-y:-50%;--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item):where([dir=rtl],[dir=rtl] *){--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-start){inset-inline-end:auto;inset-inline-start:0;--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-start):where([dir=rtl],[dir=rtl] *){--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-center){inset-inline-end:50%;inset-inline-start:50%;--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-center):where([dir=rtl],[dir=rtl] *){--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-end){inset-inline-end:0;inset-inline-start:auto;--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-end):where([dir=rtl],[dir=rtl] *){--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-bottom){bottom:0;top:auto;--tw-translate-y:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-middle){bottom:50%;top:50%;--tw-translate-y:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-top){bottom:auto;top:0;--tw-translate-y:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.input-sm{height:2rem;padding-left:.75rem;padding-right:.75rem;font-size:.875rem;line-height:2rem}.join.join-vertical{flex-direction:column}.join.join-vertical .join-item:first-child:not(:last-child),.join.join-vertical :first-child:not(:last-child) .join-item{border-end-start-radius:0;border-end-end-radius:0;border-start-start-radius:inherit;border-start-end-radius:inherit}.join.join-vertical .join-item:last-child:not(:first-child),.join.join-vertical :last-child:not(:first-child) .join-item{border-start-start-radius:0;border-start-end-radius:0;border-end-start-radius:inherit;border-end-end-radius:inherit}.join.join-horizontal{flex-direction:row}.join.join-horizontal .join-item:first-child:not(:last-child),.join.join-horizontal :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-start-end-radius:0;border-end-start-radius:inherit;border-start-start-radius:inherit}.join.join-horizontal .join-item:last-child:not(:first-child),.join.join-horizontal :last-child:not(:first-child) .join-item{border-end-start-radius:0;border-start-start-radius:0;border-end-end-radius:inherit;border-start-end-radius:inherit}.kbd-sm{padding-left:.25rem;padding-right:.25rem;font-size:.875rem;line-height:1.25rem;min-height:1.6em;min-width:1.6em}.menu-horizontal{display:inline-flex;flex-direction:row}.menu-horizontal>li:not(.menu-title)>details>ul{position:absolute}.select-sm{height:2rem;min-height:2rem;padding-left:.75rem;padding-right:2rem;font-size:.875rem;line-height:2rem}[dir=rtl] .select-sm{padding-left:2rem;padding-right:.75rem}.steps-horizontal .step{display:grid;grid-template-columns:repeat(1,minmax(0,1fr));grid-template-rows:repeat(2,minmax(0,1fr));place-items:center;text-align:center}.steps-vertical .step{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));grid-template-rows:repeat(1,minmax(0,1fr))}.tabs-md :where(.tab){height:2rem;font-size:.875rem;line-height:1.25rem;line-height:2;--tab-padding:1rem}.tabs-lg :where(.tab){height:3rem;font-size:1.125rem;line-height:1.75rem;line-height:2;--tab-padding:1.25rem}.tabs-sm :where(.tab){height:1.5rem;font-size:.875rem;line-height:.75rem;--tab-padding:0.75rem}.tabs-xs :where(.tab){height:1.25rem;font-size:.75rem;line-height:.75rem;--tab-padding:0.5rem}:where(.toast){bottom:0;inset-inline-end:0;inset-inline-start:auto;top:auto;--tw-translate-x:0px;--tw-translate-y:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.toast:where(.toast-start){inset-inline-end:auto;inset-inline-start:0;--tw-translate-x:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.toast:where(.toast-center){inset-inline-end:50%;inset-inline-start:50%;--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.toast:where(.toast-center):where([dir=rtl],[dir=rtl] *){--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.toast:where(.toast-end){inset-inline-end:0;inset-inline-start:auto;--tw-translate-x:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.toast:where(.toast-bottom){bottom:0;top:auto;--tw-translate-y:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.toast:where(.toast-middle){bottom:auto;top:50%;--tw-translate-y:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.toast:where(.toast-top){bottom:auto;top:0;--tw-translate-y:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.tooltip{--tooltip-offset:calc(100% + 1px + var(--tooltip-tail, 0px))}.tooltip:before{position:absolute;pointer-events:none;z-index:1;content:var(--tw-content);--tw-content:attr(data-tip)}.tooltip-top:before,.tooltip:before{transform:translateX(-50%);top:auto;left:50%;right:auto;bottom:var(--tooltip-offset)}.tooltip-bottom:before{transform:translateX(-50%);top:var(--tooltip-offset);left:50%;right:auto;bottom:auto}.tooltip-left:before{transform:translateY(-50%);top:50%;left:auto;right:var(--tooltip-offset);bottom:auto}.avatar.online:before{background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity)))}.avatar.offline:before,.avatar.online:before{content:"";position:absolute;z-index:10;display:block;border-radius:9999px;--tw-bg-opacity:1;outline-style:solid;outline-width:2px;outline-color:var(--fallback-b1,oklch(var(--b1)/1));width:15%;height:15%;top:7%;right:7%}.avatar.offline:before{background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}.card-compact .card-body{padding:1rem;font-size:.875rem;line-height:1.25rem}.card-compact .card-title{margin-bottom:.25rem}.card-normal .card-body{padding:var(--padding-card,2rem);font-size:1rem;line-height:1.5rem}.card-normal .card-title{margin-bottom:.75rem}.join.join-vertical>:where(:not(:first-child)){margin-left:0;margin-right:0;margin-top:-1px}.join.join-vertical>:where(:not(:first-child)):is(.btn){margin-top:calc(var(--border-btn)*-1)}.join.join-horizontal>:where(:not(:first-child)){margin-top:0;margin-bottom:0;margin-inline-start:-1px}.join.join-horizontal>:where(:not(:first-child)):is(.btn){margin-inline-start:calc(var(--border-btn)*-1);margin-top:0}.menu-horizontal>li:not(.menu-title)>details>ul{margin-inline-start:0;margin-top:1rem;padding-top:.5rem;padding-bottom:.5rem;padding-inline-end:.5rem}.menu-horizontal>li>details>ul:before{content:none}:where(.menu-horizontal>li:not(.menu-title)>details>ul){border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.menu-xs .menu-title{padding:.25rem .5rem}.menu-sm :where(li:not(.menu-title)>:not(ul,details,.menu-title)),.menu-sm :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--rounded-btn,.5rem);padding:.25rem .75rem;font-size:.875rem;line-height:1.25rem}.menu-sm .menu-title{padding:.5rem .75rem}.menu-md .menu-title{padding:.5rem 1rem}.menu-lg .menu-title{padding:.75rem 1.5rem}.modal-top :where(.modal-box){width:100%;max-width:none;--tw-translate-y:-2.5rem;--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));border-bottom-right-radius:var(--rounded-box,1rem);border-bottom-left-radius:var(--rounded-box,1rem);border-top-left-radius:0;border-top-right-radius:0}.modal-middle :where(.modal-box){width:91.666667%;max-width:32rem;--tw-translate-y:0px;--tw-scale-x:.9;--tw-scale-y:.9;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);border-bottom-right-radius:var(--rounded-box,1rem);border-bottom-left-radius:var(--rounded-box,1rem)}.modal-bottom :where(.modal-box){width:100%;max-width:none;--tw-translate-y:2.5rem;--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);border-bottom-right-radius:0;border-bottom-left-radius:0}.steps-horizontal .step{grid-template-rows:40px 1fr;grid-template-columns:auto;min-width:4rem}.steps-horizontal .step:before{height:.5rem;width:100%;--tw-translate-x:0px;--tw-translate-y:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));content:"";margin-inline-start:-100%}.steps-horizontal .step:where([dir=rtl],[dir=rtl] *):before{--tw-translate-x:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.steps-vertical .step{gap:.5rem;grid-template-columns:40px 1fr;grid-template-rows:auto;min-height:4rem;justify-items:start}.steps-vertical .step:before{height:100%;width:.5rem;--tw-translate-x:-50%;--tw-translate-y:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));margin-inline-start:50%}.steps-vertical .step:where([dir=rtl],[dir=rtl] *):before{--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.table-sm :not(thead):not(tfoot) tr{font-size:.875rem;line-height:1.25rem}.table-sm :where(th,td){padding:.5rem .75rem}.tooltip{position:relative;display:inline-block;text-align:center;--tooltip-tail:0.1875rem;--tooltip-color:var(--fallback-n,oklch(var(--n)/1));--tooltip-text-color:var(--fallback-nc,oklch(var(--nc)/1));--tooltip-tail-offset:calc(100% + 0.0625rem - var(--tooltip-tail))}.tooltip:after,.tooltip:before{opacity:0;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-delay:.1s;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1)}.tooltip:after{position:absolute;content:"";border-style:solid;border-width:var(--tooltip-tail,0);width:0;height:0;display:block}.tooltip:before{max-width:20rem;white-space:normal;border-radius:.25rem;padding:.25rem .5rem;font-size:.875rem;line-height:1.25rem;background-color:var(--tooltip-color);color:var(--tooltip-text-color);width:-moz-max-content;width:max-content}.tooltip.tooltip-open:after,.tooltip.tooltip-open:before,.tooltip:hover:after,.tooltip:hover:before{opacity:1;transition-delay:75ms}.tooltip:has(:focus-visible):after,.tooltip:has(:focus-visible):before{opacity:1;transition-delay:75ms}.tooltip:not([data-tip]):hover:after,.tooltip:not([data-tip]):hover:before{visibility:hidden;opacity:0}.tooltip-top:after,.tooltip:after{transform:translateX(-50%);border-color:var(--tooltip-color) transparent transparent transparent;top:auto;left:50%;right:auto;bottom:var(--tooltip-tail-offset)}.tooltip-bottom:after{transform:translateX(-50%);border-color:transparent transparent var(--tooltip-color) transparent;top:var(--tooltip-tail-offset);left:50%;right:auto;bottom:auto}.tooltip-left:after{transform:translateY(-50%);border-color:transparent transparent transparent var(--tooltip-color);top:50%;left:auto;right:calc(var(--tooltip-tail-offset) + .0625rem);bottom:auto}.visible{visibility:visible}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.bottom-4{bottom:1rem}.right-2{right:.5rem}.right-4{right:1rem}.top-0{top:0}.top-2{top:.5rem}.top-20{top:5rem}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.z-\[1\]{z-index:1}.mx-auto{margin-left:auto;margin-right:auto}.mb-2{margin-bottom:.5rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-4{margin-left:1rem}.mr-1{margin-right:.25rem}.mr-2{margin-right:.5rem}.mr-3{margin-right:.75rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.table{display:table}.grid{display:grid}.contents{display:contents}.hidden{display:none}.h-2{height:.5rem}.h-32{height:8rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-8{height:2rem}.h-full{height:100%}.max-h-96{max-height:24rem}.max-h-\[90vh\]{max-height:90vh}.min-h-96{min-height:24rem}.min-h-\[200px\]{min-height:200px}.min-h-screen{min-height:100vh}.w-12{width:3rem}.w-16{width:4rem}.w-20{width:5rem}.w-24{width:6rem}.w-32{width:8rem}.w-4{width:1rem}.w-48{width:12rem}.w-5{width:1.25rem}.w-52{width:13rem}.w-6{width:1.5rem}.w-8{width:2rem}.w-full{width:100%}.min-w-12{min-width:3rem}.min-w-32{min-width:8rem}.min-w-36{min-width:9rem}.min-w-48{min-width:12rem}.max-w-2xl{max-width:42rem}.max-w-48{max-width:12rem}.max-w-6xl{max-width:72rem}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.resize{resize:both}.list-inside{list-style-position:inside}.list-disc{list-style-type:disc}.grid-flow-col{grid-auto-flow:column}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-start{justify-content:flex-start}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.space-x-8>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(2rem*var(--tw-space-x-reverse));margin-left:calc(2rem*(1 - var(--tw-space-x-reverse)))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem*var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem*var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem*var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem*var(--tw-space-y-reverse))}.overflow-x-auto{overflow-x:auto}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.text-nowrap{text-wrap:nowrap}.rounded-box{border-radius:var(--rounded-box,1rem)}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-base-200{--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity,1)))}.border-base-300{--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity,1)))}.border-info{--tw-border-opacity:1;border-color:var(--fallback-in,oklch(var(--in)/var(--tw-border-opacity,1)))}.border-primary{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity,1)))}.border-transparent{border-color:transparent}.bg-base-100{--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity,1)))}.bg-base-200{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity,1)))}.bg-base-300{--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity,1)))}.bg-black\/50{background-color:rgba(0,0,0,.5)}.bg-primary{--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity,1)))}.bg-primary\/10{background-color:var(--fallback-p,oklch(var(--p)/.1))}.bg-primary\/5{background-color:var(--fallback-p,oklch(var(--p)/.05))}.bg-gradient-to-r{background-image:linear-gradient(to right,var(--tw-gradient-stops))}.from-primary{--tw-gradient-from:var(--fallback-p,oklch(var(--p)/1)) var(--tw-gradient-from-position);--tw-gradient-to:var(--fallback-p,oklch(var(--p)/0)) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.to-secondary{--tw-gradient-to:var(--fallback-s,oklch(var(--s)/1)) var(--tw-gradient-to-position)}.bg-clip-text{-webkit-background-clip:text;background-clip:text}.p-0{padding:0}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-6{padding:1.5rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-4{padding-left:1rem;padding-right:1rem}.py-16{padding-top:4rem;padding-bottom:4rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.py-8{padding-top:2rem;padding-bottom:2rem}.text-center{text-align:center}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-6xl{font-size:3.75rem;line-height:1}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.text-accent{--tw-text-opacity:1;color:var(--fallback-a,oklch(var(--a)/var(--tw-text-opacity,1)))}.text-base-content{--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity,1)))}.text-base-content\/30{color:var(--fallback-bc,oklch(var(--bc)/.3))}.text-base-content\/50{color:var(--fallback-bc,oklch(var(--bc)/.5))}.text-base-content\/60{color:var(--fallback-bc,oklch(var(--bc)/.6))}.text-base-content\/70{color:var(--fallback-bc,oklch(var(--bc)/.7))}.text-current{color:currentColor}.text-error{--tw-text-opacity:1;color:var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity,1)))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.text-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity,1)))}.text-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity,1)))}.text-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity,1)))}.text-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity,1)))}.text-transparent{color:transparent}.text-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity,1)))}.opacity-50{opacity:.5}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-2xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-lg,.shadow-sm{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.blur{--tw-blur:blur(8px)}.blur,.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur-sm{--tw-backdrop-blur:blur(4px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-300{transition-duration:.3s}.ease-in{transition-timing-function:cubic-bezier(.4,0,1,1)}*{transition:transform .2s ease-in-out,opacity .2s ease-in-out,box-shadow .2s ease-in-out,border-color .2s ease-in-out,background-color .2s ease-in-out}:root{--warning-color:#f59e0b}[data-theme=dark]{--warning-color:#fbbf24}.context-menu{position:absolute;z-index:1000;-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);border:1px solid hsl(var(--bc)/.2);box-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 10px 10px -5px rgba(0,0,0,.04)}.context-menu.hidden{opacity:0;pointer-events:none;transform:scale(.95) translateY(-5px)}.context-menu:not(.hidden){opacity:1;pointer-events:all;transform:scale(1) translateY(0);animation:contextMenuAppear .15s ease-out}@keyframes contextMenuAppear{0%{opacity:0;transform:scale(.95) translateY(-5px)}to{opacity:1;transform:scale(1) translateY(0)}}.progress{transition:all .3s ease-in-out}.progress::-webkit-progress-value{-webkit-transition:width .5s ease-in-out;transition:width .5s ease-in-out}.progress::-moz-progress-bar{-moz-transition:width .5s ease-in-out;transition:width .5s ease-in-out}.btn{transition:all .2s ease-in-out;transform:translateY(0)}.btn:hover:not(:disabled){transform:translateY(-1px);box-shadow:0 4px 12px rgba(0,0,0,.15)}.btn:active:not(:disabled){transform:translateY(0);transition:all .1s ease-in-out}.card{transition:all .3s ease-in-out}.card:hover{transform:translateY(-2px);box-shadow:0 8px 25px rgba(0,0,0,.15)}.table tbody tr{transition:all .2s ease-in-out}.table tbody tr:hover{background-color:hsl(var(--b2));transform:scale(1.005)}.item-row.selected{background-color:hsl(var(--p)/.1)!important;border-left:4px solid hsl(var(--p))}.item-row{cursor:pointer;transition:all .2s ease-in-out}.stepper-nav .nav-link{transition:all .2s ease-in-out;position:relative;overflow:hidden}.stepper-nav .nav-link:before{content:"";position:absolute;top:0;left:-100%;width:100%;height:100%;background:linear-gradient(90deg,transparent,hsla(0,0%,100%,.2),transparent);transition:left .5s ease-in-out}.stepper-nav .nav-link:hover:before{left:100%}.modal{transition:all .3s ease-in-out}.modal-box{animation:modalSlideIn .3s ease-out}@keyframes modalSlideIn{0%{opacity:0;transform:scale(.9) translateY(-20px)}to{opacity:1;transform:scale(1) translateY(0)}}.toast-container .alert{animation:toastSlideIn .3s ease-out}@keyframes toastSlideIn{0%{opacity:0;transform:translateX(100%)}to{opacity:1;transform:translateX(0)}}.badge,.loading{transition:all .2s ease-in-out}.badge:hover{transform:scale(1.05)}.form-control input:focus,.form-control select:focus,.form-control textarea:focus{transform:scale(1.02);box-shadow:0 0 0 3px hsl(var(--p)/.2)}.join .btn{transition:all .2s ease-in-out}.join .btn:not(.btn-active):hover{background-color:hsl(var(--b3));transform:translateY(-1px)}.password-toggle-container{position:relative;display:flex;align-items:center}.password-toggle-btn{position:absolute;right:0;top:0;bottom:0;width:40px;background:none;border:none;cursor:pointer;z-index:10;color:hsl(var(--bc)/.6);transition:all .2s ease-in-out;display:flex;align-items:center;justify-content:center;border-top-right-radius:var(--rounded-btn,.5rem);border-bottom-right-radius:var(--rounded-btn,.5rem)}.password-toggle-btn:hover{background-color:hsl(var(--bc)/.1);color:hsl(var(--bc)/.8)}.input.input-has-toggle{padding-right:40px!important;width:100%}.textarea.has-toggle{padding-right:40px!important;font-family:JetBrains Mono,Consolas,Monaco,Courier New,monospace!important;line-height:1.4;resize:vertical}.password-toggle-btn.textarea-toggle{top:0;right:0;bottom:auto;height:40px;border-radius:0;border-top-right-radius:var(--rounded-btn,.5rem)}.textarea.has-toggle:not([data-password-visible=true]),.textarea.has-toggle[data-password-visible=false]{-webkit-text-security:disc;text-security:disc;font-family:monospace!important;letter-spacing:2px}.textarea.has-toggle[data-password-visible=true]{-webkit-text-security:none;text-security:none;font-family:JetBrains Mono,Consolas,Monaco,Courier New,monospace!important;letter-spacing:normal}.input[type=password]{font-family:Courier New,monospace;letter-spacing:1px}.input[type=text].was-password{font-family:JetBrains Mono,Consolas,Monaco,Courier New,monospace;letter-spacing:normal}[data-theme=dark] .password-toggle-btn{color:hsl(var(--bc)/.6);background-color:transparent}[data-theme=dark] .password-toggle-btn:hover{background-color:hsl(var(--bc)/.15);color:hsl(var(--bc)/.9)}[data-theme=light] .password-toggle-btn{color:hsl(var(--bc)/.7);background-color:transparent}[data-theme=light] .password-toggle-btn:hover{background-color:hsl(var(--bc)/.1);color:hsl(var(--bc)/.9)}.password-toggle-btn i{font-size:14px;line-height:1}.password-toggle-container,.password-toggle-container .input,.password-toggle-container .textarea{width:100%}.drawer-side{transition:transform .3s ease-in-out}.dropdown-content{animation:dropdownSlideIn .2s ease-out}@keyframes dropdownSlideIn{0%{opacity:0;transform:scale(.95) translateY(-10px)}to{opacity:1;transform:scale(1) translateY(0)}}.filters-container .filter-item{animation:filterItemAppear .2s ease-out;transition:all .2s ease-in-out}@keyframes filterItemAppear{0%{opacity:0;transform:translateX(-10px)}to{opacity:1;transform:translateX(0)}}.filter-item:hover{background-color:hsl(var(--b2));border-radius:4px;padding:4px;margin:-4px}[data-theme=dark] .glass{background:hsla(0,0%,100%,.05);-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);border:1px solid hsla(0,0%,100%,.1)}[data-theme=dark] .input,[data-theme=dark] .select,[data-theme=dark] .textarea{color:#fff!important;background-color:hsl(var(--b1))}[data-theme=dark] .input::-moz-placeholder,[data-theme=dark] .textarea::-moz-placeholder{color:#6b7280!important;opacity:1!important;font-style:italic}[data-theme=dark] .input::placeholder,[data-theme=dark] .textarea::placeholder{color:#6b7280!important;opacity:1!important;font-style:italic}[data-theme=dark] .input:focus,[data-theme=dark] .select:focus,[data-theme=dark] .textarea:focus{color:#fff!important;background-color:hsl(var(--b1));border-color:hsl(var(--p));outline:none;box-shadow:0 0 0 2px hsl(var(--p)/.2)}[data-theme=dark] .select option{background-color:hsl(var(--b1));color:#fff}[data-theme=dark] .input:disabled,[data-theme=dark] .select:disabled,[data-theme=dark] .textarea:disabled{color:#9ca3af!important;background-color:hsl(var(--b2));border-color:hsl(var(--bc)/.2)}[data-theme=dark] .input:disabled::-moz-placeholder,[data-theme=dark] .textarea:disabled::-moz-placeholder{color:#6b7280!important}[data-theme=dark] .input:disabled::placeholder,[data-theme=dark] .textarea:disabled::placeholder{color:#6b7280!important}[data-theme=dark] .input[readonly],[data-theme=dark] .select[readonly],[data-theme=dark] .textarea[readonly]{color:#d1d5db!important;background-color:hsl(var(--b2));border-color:hsl(var(--bc)/.15)}[data-theme=dark] .password-toggle-container .input{color:#fff!important}[data-theme=dark] .password-toggle-container .textarea{color:#fff!important;font-family:JetBrains Mono,Consolas,Monaco,Courier New,monospace}[data-theme=dark] .label-text{color:#f3f4f6}[data-theme=dark] .label-text-alt{color:#9ca3af}.text-warning,[data-theme=dark] .label-text-alt.text-warning,[data-theme=dark] .text-warning{color:var(--warning-color)!important}[data-theme=dark] .file-input{color:#fff!important}[data-theme=dark] .file-input::file-selector-button{background-color:hsl(var(--b3));color:#fff;border:none;border-right:1px solid hsl(var(--bc)/.2)}[data-theme=dark] input[type=search]{color:#fff!important}[data-theme=dark] input[type=search]::-moz-placeholder{color:#6b7280!important;font-style:italic}[data-theme=dark] input[type=search]::placeholder{color:#6b7280!important;font-style:italic}[data-theme=dark] input[type=date],[data-theme=dark] input[type=datetime-local],[data-theme=dark] input[type=email],[data-theme=dark] input[type=number],[data-theme=dark] input[type=time],[data-theme=dark] input[type=url]{color:#fff!important}[data-theme=dark] .input:-webkit-autofill,[data-theme=dark] .input:-webkit-autofill:focus,[data-theme=dark] .input:-webkit-autofill:hover{-webkit-box-shadow:0 0 0 1000px hsl(var(--b1)) inset!important;-webkit-text-fill-color:#fff!important;-webkit-transition:background-color 5000s ease-in-out 0s;transition:background-color 5000s ease-in-out 0s}[data-theme=dark] .input-error{color:#fff!important;border-color:hsl(var(--er));background-color:hsl(var(--er)/.1)}[data-theme=dark] .input-error::-moz-placeholder{color:#ef4444!important}[data-theme=dark] .input-error::placeholder{color:#ef4444!important}@media (max-width:768px){.card:hover{box-shadow:0 4px 15px rgba(0,0,0,.1)}.btn:hover:not(:disabled),.card:hover,.table tbody tr:hover{transform:none}}::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-track{background:hsl(var(--b1))}::-webkit-scrollbar-thumb{background:hsl(var(--bc)/.3);border-radius:4px}::-webkit-scrollbar-thumb:hover{background:hsl(var(--bc)/.5)}.tab-button{transition:all .2s ease-in-out;white-space:nowrap;cursor:pointer;background:none;border:none;outline:none}.tab-button.active{color:hsl(var(--p))!important;border-color:hsl(var(--p))!important}.tab-button:not(.active){color:hsl(var(--bc)/.7);border-color:transparent}.tab-button:not(.active):hover{color:hsl(var(--bc));border-color:hsl(var(--bc)/.3)}.tab-content{min-height:400px}.tab-content.hidden{display:none!important}.tab-content:not(.hidden){display:block;animation:fadeIn .3s ease-in-out}@keyframes fadeIn{0%{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}@media (max-width:640px){.tab-button{padding:12px 8px;font-size:12px}.tab-button i{font-size:16px}nav[aria-label="Configuration Tabs"]{overflow-x:auto;white-space:nowrap;padding-bottom:8px}nav[aria-label="Configuration Tabs"]::-webkit-scrollbar{height:4px}nav[aria-label="Configuration Tabs"]::-webkit-scrollbar-track{background:hsl(var(--b2))}nav[aria-label="Configuration Tabs"]::-webkit-scrollbar-thumb{background:hsl(var(--bc)/.3);border-radius:2px}}[data-theme=dark] .tab-button:not(.active){color:hsl(var(--bc)/.6)}[data-theme=dark] .tab-button:not(.active):hover{color:hsl(var(--bc)/.9);border-color:hsl(var(--bc)/.3)}[data-theme=light] .tab-button:not(.active){color:hsl(var(--bc)/.7)}[data-theme=light] .tab-button:not(.active):hover{color:hsl(var(--bc)/.9);border-color:hsl(var(--bc)/.4)}.tab-button{border-bottom-width:2px;border-bottom-style:solid}nav[aria-label="Configuration Tabs"]{display:flex;gap:2rem;min-height:60px;align-items:end}.tab-button .flex{align-items:center;justify-content:center;gap:.5rem}.tab-button:focus{outline:2px solid hsl(var(--p));outline-offset:2px;border-radius:4px}.tab-button:focus:not(:focus-visible){outline:none}.hover\:border-base-300:hover{--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity,1)))}.hover\:bg-base-200:hover{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity,1)))}.hover\:bg-base-300:hover{--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity,1)))}.hover\:text-base-content:hover{--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity,1)))}.hover\:text-primary:hover{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity,1)))}@media (min-width:640px){.sm\:inline{display:inline}.sm\:w-auto{width:auto}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}}@media (min-width:768px){.md\:inline{display:inline}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:1024px){.lg\:flex{display:flex}.lg\:hidden{display:none}.lg\:w-auto{width:auto}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:flex-row{flex-direction:row}.lg\:items-center{align-items:center}}@media (min-width:1280px){.xl\:inline{display:inline}} \ No newline at end of file diff --git a/pkg/web/assets/build/js/common.js b/pkg/web/assets/build/js/common.js index 7465a1a..0f77955 100644 --- a/pkg/web/assets/build/js/common.js +++ b/pkg/web/assets/build/js/common.js @@ -1 +1 @@ -class DecypharrUtils{constructor(){this.urlBase=window.urlBase||"",this.toastContainer=null,this.init()}init(){this.setupToastSystem(),this.setupThemeToggle(),this.setupPasswordToggles(),this.setupVersionInfo(),this.setupGlobalEventListeners(),this.createToastContainer()}createToastContainer(){let e=document.querySelector(".toast-container");e||(e=document.createElement("div"),e.className="toast-container fixed bottom-4 right-4 z-50 space-y-2",document.body.appendChild(e)),this.toastContainer=e}setupToastSystem(){this.addToastStyles(),window.addEventListener("error",e=>{console.error("Global error:",e.error),this.createToast(`Unexpected error: ${e.error?.message||"Unknown error"}`,"error")}),window.addEventListener("unhandledrejection",e=>{console.error("Unhandled promise rejection:",e.reason),this.createToast(`Promise rejected: ${e.reason?.message||"Unknown error"}`,"error")})}addToastStyles(){if(document.getElementById("toast-styles"))return;const e=document.createElement("style");e.id="toast-styles",e.textContent="\n @keyframes toastSlideIn {\n from {\n opacity: 0;\n transform: translateX(100%);\n }\n to {\n opacity: 1;\n transform: translateX(0);\n }\n }\n\n @keyframes toastSlideOut {\n from {\n opacity: 1;\n transform: translateX(0);\n }\n to {\n opacity: 0;\n transform: translateX(100%);\n }\n }\n\n .toast-container .alert {\n animation: toastSlideIn 0.3s ease-out;\n max-width: 400px;\n word-wrap: break-word;\n }\n\n .toast-container .alert.toast-closing {\n animation: toastSlideOut 0.3s ease-in forwards;\n }\n\n @media (max-width: 640px) {\n .toast-container {\n left: 1rem;\n right: 1rem;\n bottom: 1rem;\n }\n \n .toast-container .alert {\n max-width: none;\n }\n }\n ",document.head.appendChild(e)}joinURL(e,t){return e.endsWith("/")||(e+="/"),t.startsWith("/")&&(t=t.substring(1)),e+t}async fetcher(e,t={}){const n=this.joinURL(this.urlBase,e),o={headers:{},...t};t.body instanceof FormData||(o.headers["Content-Type"]="application/json"),o.headers={...o.headers,...t.headers};try{const e=await fetch(n,o);return t.loadingButton&&this.setButtonLoading(t.loadingButton,!1),e}catch(e){throw t.loadingButton&&this.setButtonLoading(t.loadingButton,!1),e}}createToast(e,t="success",n=null){t=["success","warning","error","info"].includes(t)?t:"success",n=n||{success:5e3,warning:1e4,error:15e3,info:7e3}[t],this.toastContainer||this.createToastContainer();const o=`toast-${Date.now()}-${Math.random().toString(36).substr(2,9)}`,s=`\n
\n
\n \n ${{success:'',error:'',warning:'',info:''}[t]}\n \n
\n ${e.replace(/\n/g,"
")}
\n
\n \n
\n
\n `;this.toastContainer.insertAdjacentHTML("beforeend",s);const a=setTimeout(()=>this.closeToast(o),n),r=document.getElementById(o);return r&&(r.dataset.timeoutId=a),o}closeToast(e){const t=document.getElementById(e);t&&(t.dataset.timeoutId&&clearTimeout(parseInt(t.dataset.timeoutId)),t.classList.add("toast-closing"),setTimeout(()=>{t.parentNode&&t.remove()},300))}closeAllToasts(){const e=this.toastContainer?.querySelectorAll(".alert");e&&e.forEach(e=>{e.id&&this.closeToast(e.id)})}setButtonLoading(e,t=!0,n=null){"string"==typeof e&&(e=document.getElementById(e)||document.querySelector(e)),e&&(t?(e.disabled=!0,e.dataset.originalText||(e.dataset.originalText=n||e.innerHTML),e.innerHTML='Processing...',e.classList.add("loading-state")):(e.disabled=!1,e.innerHTML=e.dataset.originalText||"Submit",e.classList.remove("loading-state"),delete e.dataset.originalText))}setupPasswordToggles(){document.addEventListener("click",e=>{const t=e.target.closest(".password-toggle-btn");if(t){e.preventDefault(),e.stopPropagation();const n=t.closest(".password-toggle-container");if(n){const e=n.querySelector("input, textarea"),o=t.querySelector("i");e&&o&&this.togglePasswordField(e,o)}}})}togglePasswordField(e,t){t&&("textarea"===e.tagName.toLowerCase()?this.togglePasswordTextarea(e,t):this.togglePasswordInput(e,t))}togglePasswordInput(e,t){"password"===e.type?(e.type="text",t.className="bi bi-eye-slash"):(e.type="password",t.className="bi bi-eye")}togglePasswordTextarea(e,t){"disc"===e.style.webkitTextSecurity||""===e.style.webkitTextSecurity||"true"!==e.getAttribute("data-password-visible")?(e.style.webkitTextSecurity="none",e.style.textSecurity="none",e.setAttribute("data-password-visible","true"),t.className="bi bi-eye-slash"):(e.style.webkitTextSecurity="disc",e.style.textSecurity="disc",e.setAttribute("data-password-visible","false"),t.className="bi bi-eye")}togglePassword(e){const t=document.getElementById(e),n=t?.closest(".password-toggle-container")?.querySelector(".password-toggle-btn");let o=n.querySelector("i");t&&o&&this.togglePasswordField(t,o)}setupThemeToggle(){const e=document.getElementById("themeToggle"),t=document.documentElement;if(!e)return;const n=n=>{t.setAttribute("data-theme",n),localStorage.setItem("theme",n),e.checked="dark"===n,document.body.style.transition="background-color 0.3s ease, color 0.3s ease",setTimeout(()=>{document.body.style.transition=""},300),window.dispatchEvent(new CustomEvent("themechange",{detail:{theme:n}}))},o=localStorage.getItem("theme");o?n(o):window.matchMedia?.("(prefers-color-scheme: dark)").matches?n("dark"):n("light"),e.addEventListener("change",()=>{const e=t.getAttribute("data-theme");n("dark"===e?"light":"dark")}),window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",e=>{localStorage.getItem("theme")||n(e.matches?"dark":"light")})}async setupVersionInfo(){try{const e=await this.fetcher("/version");if(!e.ok)throw new Error("Failed to fetch version");const t=await e.json(),n=document.getElementById("version-badge");n&&(n.innerHTML=`\n \n ${t.channel}-${t.version}\n \n `,n.classList.remove("badge-warning","badge-error","badge-ghost"),"beta"===t.channel?n.classList.add("badge-warning"):"nightly"===t.channel&&n.classList.add("badge-error"))}catch(e){console.error("Error fetching version:",e);const t=document.getElementById("version-badge");t&&(t.textContent="Unknown",t.classList.add("badge-ghost"))}}setupGlobalEventListeners(){document.addEventListener("click",e=>{const t=e.target.closest('a[href^="#"]');if(t&&"#"!==t.getAttribute("href")){e.preventDefault();const n=document.querySelector(t.getAttribute("href"));n&&n.scrollIntoView({behavior:"smooth",block:"start"})}}),document.addEventListener("invalid",e=>{e.target.classList.add("input-error"),setTimeout(()=>e.target.classList.remove("input-error"),3e3)},!0),document.addEventListener("keydown",e=>{"Escape"===e.key&&(document.querySelectorAll(".modal[open]").forEach(e=>e.close()),document.querySelectorAll(".dropdown-open").forEach(e=>{e.classList.remove("dropdown-open")}),document.querySelectorAll(".context-menu:not(.hidden)").forEach(e=>{e.classList.add("hidden")})),(e.ctrlKey||e.metaKey)&&"/"===e.key&&(e.preventDefault(),this.showKeyboardShortcuts())}),document.addEventListener("visibilitychange",()=>{document.hidden?window.dispatchEvent(new CustomEvent("pageHidden")):window.dispatchEvent(new CustomEvent("pageVisible"))}),window.addEventListener("online",()=>{this.createToast("Connection restored","success")}),window.addEventListener("offline",()=>{this.createToast("Connection lost - working offline","warning")})}showKeyboardShortcuts(){const e=document.createElement("dialog");e.className="modal",e.innerHTML=`\n \n `,document.body.appendChild(e),e.showModal(),e.addEventListener("close",()=>{document.body.removeChild(e)})}formatBytes(e){if(!e||0===e)return"0 B";const t=Math.floor(Math.log(e)/Math.log(1024));return`${parseFloat((e/Math.pow(1024,t)).toFixed(2))} ${["B","KB","MB","GB","TB","PB"][t]}`}formatSpeed(e){return`${this.formatBytes(e)}/s`}formatDuration(e){if(!e||0===e)return"0s";const t=[{label:"d",seconds:86400},{label:"h",seconds:3600},{label:"m",seconds:60},{label:"s",seconds:1}],n=[];let o=e;for(const e of t){const t=Math.floor(o/e.seconds);t>0&&(n.push(`${t}${e.label}`),o%=e.seconds)}return n.slice(0,2).join(" ")||"0s"}debounce(e,t,n=!1){let o;return function(...s){const a=n&&!o;clearTimeout(o),o=setTimeout(()=>{o=null,n||e(...s)},t),a&&e(...s)}}throttle(e,t){let n;return function(...o){n||(e.apply(this,o),n=!0,setTimeout(()=>n=!1,t))}}async copyToClipboard(e){try{return await navigator.clipboard.writeText(e),this.createToast("Copied to clipboard","success"),!0}catch(e){return console.error("Failed to copy to clipboard:",e),this.createToast("Failed to copy to clipboard","error"),!1}}isValidUrl(e){try{return new URL(e),!0}catch(e){return!1}}escapeHtml(e){const t={"&":"&","<":"<",">":">",'"':""","'":"'"};return e?e.replace(/[&<>"']/g,e=>t[e]):""}getCurrentTheme(){return document.documentElement.getAttribute("data-theme")||"light"}isOnline(){return navigator.onLine}}window.decypharrUtils=new DecypharrUtils,window.fetcher=(e,t={})=>window.decypharrUtils.fetcher(e,t),window.createToast=(e,t,n)=>window.decypharrUtils.createToast(e,t,n),"undefined"!=typeof module&&module.exports&&(module.exports=DecypharrUtils); \ No newline at end of file +class DecypharrUtils{constructor(){this.urlBase=window.urlBase||"",this.toastContainer=null,this.init()}init(){this.setupToastSystem(),this.setupThemeToggle(),this.setupPasswordToggles(),this.setupVersionInfo(),this.setupGlobalEventListeners(),this.createToastContainer()}createToastContainer(){let e=document.querySelector(".toast-container");e||(e=document.createElement("div"),e.className="toast-container fixed bottom-4 right-4 z-50 space-y-2",document.body.appendChild(e)),this.toastContainer=e}setupToastSystem(){this.addToastStyles(),window.addEventListener("error",e=>{console.error("Global error:",e.error),this.createToast(`Unexpected error: ${e.error?.message||"Unknown error"}`,"error")}),window.addEventListener("unhandledrejection",e=>{console.error("Unhandled promise rejection:",e.reason),this.createToast(`Promise rejected: ${e.reason?.message||"Unknown error"}`,"error")})}addToastStyles(){if(document.getElementById("toast-styles"))return;const e=document.createElement("style");e.id="toast-styles",e.textContent="\n @keyframes toastSlideIn {\n from {\n opacity: 0;\n transform: translateX(100%);\n }\n to {\n opacity: 1;\n transform: translateX(0);\n }\n }\n\n @keyframes toastSlideOut {\n from {\n opacity: 1;\n transform: translateX(0);\n }\n to {\n opacity: 0;\n transform: translateX(100%);\n }\n }\n\n .toast-container .alert {\n animation: toastSlideIn 0.3s ease-out;\n max-width: 400px;\n word-wrap: break-word;\n }\n\n .toast-container .alert.toast-closing {\n animation: toastSlideOut 0.3s ease-in forwards;\n }\n\n @media (max-width: 640px) {\n .toast-container {\n left: 1rem;\n right: 1rem;\n bottom: 1rem;\n }\n \n .toast-container .alert {\n max-width: none;\n }\n }\n ",document.head.appendChild(e)}joinURL(e,t){return e.endsWith("/")||(e+="/"),t.startsWith("/")&&(t=t.substring(1)),e+t}async fetcher(e,t={}){const n=this.joinURL(this.urlBase,e),o={headers:{},...t};t.body instanceof FormData||(o.headers["Content-Type"]="application/json"),o.headers={...o.headers,...t.headers};try{const e=await fetch(n,o);return t.loadingButton&&this.setButtonLoading(t.loadingButton,!1),e}catch(e){throw t.loadingButton&&this.setButtonLoading(t.loadingButton,!1),e}}createToast(e,t="success",n=null){t=["success","warning","error","info"].includes(t)?t:"success",n=n||{success:5e3,warning:1e4,error:15e3,info:7e3}[t],this.toastContainer||this.createToastContainer();const o=`toast-${Date.now()}-${Math.random().toString(36).substr(2,9)}`,s=`\n
\n
\n \n ${{success:'',error:'',warning:'',info:''}[t]}\n \n
\n ${e.replace(/\n/g,"
")}
\n
\n \n
\n
\n `;this.toastContainer.insertAdjacentHTML("beforeend",s);const a=setTimeout(()=>this.closeToast(o),n),r=document.getElementById(o);return r&&(r.dataset.timeoutId=a),o}closeToast(e){const t=document.getElementById(e);t&&(t.dataset.timeoutId&&clearTimeout(parseInt(t.dataset.timeoutId)),t.classList.add("toast-closing"),setTimeout(()=>{t.parentNode&&t.remove()},300))}closeAllToasts(){const e=this.toastContainer?.querySelectorAll(".alert");e&&e.forEach(e=>{e.id&&this.closeToast(e.id)})}setButtonLoading(e,t=!0,n=null){"string"==typeof e&&(e=document.getElementById(e)||document.querySelector(e)),e&&(t?(e.disabled=!0,e.dataset.originalText||(e.dataset.originalText=n||e.innerHTML),e.innerHTML='Processing...',e.classList.add("loading-state")):(e.disabled=!1,e.innerHTML=e.dataset.originalText||"Submit",e.classList.remove("loading-state"),delete e.dataset.originalText))}setupPasswordToggles(){document.addEventListener("click",e=>{const t=e.target.closest(".password-toggle-btn");if(t){e.preventDefault(),e.stopPropagation();const n=t.closest(".password-toggle-container");if(n){const e=n.querySelector("input, textarea"),o=t.querySelector("i");e&&o&&this.togglePasswordField(e,o)}}})}togglePasswordField(e,t){t&&("textarea"===e.tagName.toLowerCase()?this.togglePasswordTextarea(e,t):this.togglePasswordInput(e,t))}togglePasswordInput(e,t){"password"===e.type?(e.type="text",t.className="bi bi-eye-slash"):(e.type="password",t.className="bi bi-eye")}togglePasswordTextarea(e,t){"disc"===e.style.webkitTextSecurity||""===e.style.webkitTextSecurity||"true"!==e.getAttribute("data-password-visible")?(e.style.webkitTextSecurity="none",e.style.textSecurity="none",e.setAttribute("data-password-visible","true"),t.className="bi bi-eye-slash"):(e.style.webkitTextSecurity="disc",e.style.textSecurity="disc",e.setAttribute("data-password-visible","false"),t.className="bi bi-eye")}togglePassword(e){const t=document.getElementById(e),n=t?.closest(".password-toggle-container")?.querySelector(".password-toggle-btn");let o=n.querySelector("i");t&&o&&this.togglePasswordField(t,o)}setupThemeToggle(){const e=document.getElementById("themeToggle"),t=document.documentElement;if(!e)return;const n=n=>{t.setAttribute("data-theme",n),localStorage.setItem("theme",n),e.checked="dark"===n,document.body.style.transition="background-color 0.3s ease, color 0.3s ease",setTimeout(()=>{document.body.style.transition=""},300),window.dispatchEvent(new CustomEvent("themechange",{detail:{theme:n}}))},o=localStorage.getItem("theme");o?n(o):window.matchMedia?.("(prefers-color-scheme: dark)").matches?n("dark"):n("light"),e.addEventListener("change",()=>{const e=t.getAttribute("data-theme");n("dark"===e?"light":"dark")}),window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",e=>{localStorage.getItem("theme")||n(e.matches?"dark":"light")})}async setupVersionInfo(){try{const e=await this.fetcher("/version");if(!e.ok)throw new Error("Failed to fetch version");const t=await e.json(),n=document.getElementById("version-badge");n&&(n.innerHTML=`\n \n ${t.channel}-${t.version}\n \n `,n.classList.remove("badge-warning","badge-error","badge-ghost"),"beta"===t.channel?n.classList.add("badge-warning"):"experimental"===t.channel&&n.classList.add("badge-error"))}catch(e){console.error("Error fetching version:",e);const t=document.getElementById("version-badge");t&&(t.textContent="Unknown",t.classList.add("badge-ghost"))}}setupGlobalEventListeners(){document.addEventListener("click",e=>{const t=e.target.closest('a[href^="#"]');if(t&&"#"!==t.getAttribute("href")){e.preventDefault();const n=document.querySelector(t.getAttribute("href"));n&&n.scrollIntoView({behavior:"smooth",block:"start"})}}),document.addEventListener("invalid",e=>{e.target.classList.add("input-error"),setTimeout(()=>e.target.classList.remove("input-error"),3e3)},!0),document.addEventListener("keydown",e=>{"Escape"===e.key&&(document.querySelectorAll(".modal[open]").forEach(e=>e.close()),document.querySelectorAll(".dropdown-open").forEach(e=>{e.classList.remove("dropdown-open")}),document.querySelectorAll(".context-menu:not(.hidden)").forEach(e=>{e.classList.add("hidden")})),(e.ctrlKey||e.metaKey)&&"/"===e.key&&(e.preventDefault(),this.showKeyboardShortcuts())}),document.addEventListener("visibilitychange",()=>{document.hidden?window.dispatchEvent(new CustomEvent("pageHidden")):window.dispatchEvent(new CustomEvent("pageVisible"))}),window.addEventListener("online",()=>{this.createToast("Connection restored","success")}),window.addEventListener("offline",()=>{this.createToast("Connection lost - working offline","warning")})}showKeyboardShortcuts(){const e=document.createElement("dialog");e.className="modal",e.innerHTML=`\n \n `,document.body.appendChild(e),e.showModal(),e.addEventListener("close",()=>{document.body.removeChild(e)})}formatBytes(e){if(!e||0===e)return"0 B";const t=Math.floor(Math.log(e)/Math.log(1024));return`${parseFloat((e/Math.pow(1024,t)).toFixed(2))} ${["B","KB","MB","GB","TB","PB"][t]}`}formatSpeed(e){return`${this.formatBytes(e)}/s`}formatDuration(e){if(!e||0===e)return"0s";const t=[{label:"d",seconds:86400},{label:"h",seconds:3600},{label:"m",seconds:60},{label:"s",seconds:1}],n=[];let o=e;for(const e of t){const t=Math.floor(o/e.seconds);t>0&&(n.push(`${t}${e.label}`),o%=e.seconds)}return n.slice(0,2).join(" ")||"0s"}debounce(e,t,n=!1){let o;return function(...s){const a=n&&!o;clearTimeout(o),o=setTimeout(()=>{o=null,n||e(...s)},t),a&&e(...s)}}throttle(e,t){let n;return function(...o){n||(e.apply(this,o),n=!0,setTimeout(()=>n=!1,t))}}async copyToClipboard(e){try{return await navigator.clipboard.writeText(e),this.createToast("Copied to clipboard","success"),!0}catch(e){return console.error("Failed to copy to clipboard:",e),this.createToast("Failed to copy to clipboard","error"),!1}}isValidUrl(e){try{return new URL(e),!0}catch(e){return!1}}escapeHtml(e){const t={"&":"&","<":"<",">":">",'"':""","'":"'"};return e?e.replace(/[&<>"']/g,e=>t[e]):""}getCurrentTheme(){return document.documentElement.getAttribute("data-theme")||"light"}isOnline(){return navigator.onLine}}window.decypharrUtils=new DecypharrUtils,window.fetcher=(e,t={})=>window.decypharrUtils.fetcher(e,t),window.createToast=(e,t,n)=>window.decypharrUtils.createToast(e,t,n),"undefined"!=typeof module&&module.exports&&(module.exports=DecypharrUtils); \ No newline at end of file diff --git a/pkg/web/assets/build/js/config.js b/pkg/web/assets/build/js/config.js index 1556747..64474d1 100644 --- a/pkg/web/assets/build/js/config.js +++ b/pkg/web/assets/build/js/config.js @@ -1 +1 @@ -class ConfigManager{constructor(){this.debridCount=0,this.arrCount=0,this.debridDirectoryCounts={},this.directoryFilterCounts={},this.refs={configForm:document.getElementById("configForm"),loadingOverlay:document.getElementById("loadingOverlay"),debridConfigs:document.getElementById("debridConfigs"),arrConfigs:document.getElementById("arrConfigs"),addDebridBtn:document.getElementById("addDebridBtn"),addArrBtn:document.getElementById("addArrBtn")},this.init()}init(){this.bindEvents(),this.loadConfiguration(),this.setupMagnetHandler(),this.checkIncompleteConfig()}checkIncompleteConfig(){const e=new URLSearchParams(window.location.search);if(e.has("inco")){const n=e.get("inco");window.decypharrUtils.createToast(`Incomplete configuration: ${n}`,"warning")}}bindEvents(){this.refs.configForm.addEventListener("submit",e=>this.saveConfiguration(e)),this.refs.addDebridBtn.addEventListener("click",()=>this.addDebridConfig()),this.refs.addArrBtn.addEventListener("click",()=>this.addArrConfig()),document.addEventListener("change",e=>{e.target.classList.contains("useWebdav")&&this.toggleWebDAVSection(e.target)})}async loadConfiguration(){try{const e=await window.decypharrUtils.fetcher("/api/config");if(!e.ok)throw new Error("Failed to load configuration");const n=await e.json();this.populateForm(n)}catch(e){console.error("Error loading configuration:",e),window.decypharrUtils.createToast("Error loading configuration","error")}}populateForm(e){this.populateGeneralSettings(e),e.debrids&&Array.isArray(e.debrids)&&e.debrids.forEach(e=>this.addDebridConfig(e)),this.populateQBittorrentSettings(e.qbittorrent),e.arrs&&Array.isArray(e.arrs)&&e.arrs.forEach(e=>this.addArrConfig(e)),this.populateRepairSettings(e.repair)}populateGeneralSettings(e){["log_level","url_base","bind_address","port","discord_webhook_url","min_file_size","max_file_size","remove_stalled_after"].forEach(n=>{const t=document.querySelector(`[name="${n}"]`);t&&void 0!==e[n]&&(t.value=e[n])}),e.allowed_file_types&&Array.isArray(e.allowed_file_types)&&(document.querySelector('[name="allowed_file_types"]').value=e.allowed_file_types.join(", "))}populateQBittorrentSettings(e){if(!e)return;["download_folder","refresh_interval","max_downloads","skip_pre_cache"].forEach(n=>{const t=document.querySelector(`[name="qbit.${n}"]`);t&&void 0!==e[n]&&("checkbox"===t.type?t.checked=e[n]:t.value=e[n])})}populateRepairSettings(e){if(!e)return;["enabled","interval","workers","zurg_url","strategy","use_webdav","auto_process"].forEach(n=>{const t=document.querySelector(`[name="repair.${n}"]`);t&&void 0!==e[n]&&("checkbox"===t.type?t.checked=e[n]:t.value=e[n])})}addDebridConfig(e={}){const n=this.getDebridTemplate(this.debridCount,e);this.refs.debridConfigs.insertAdjacentHTML("beforeend",n);const t=this.refs.debridConfigs.lastElementChild.querySelector(".useWebdav");e.use_webdav&&this.toggleWebDAVSection(t,!0),Object.keys(e).length>0&&this.populateDebridData(this.debridCount,e),this.debridDirectoryCounts[this.debridCount]=0,e.directories&&Object.entries(e.directories).forEach(([e,n])=>{const t=this.addDirectory(this.debridCount,{name:e,...n});n.filters&&Object.entries(n.filters).forEach(([e,n])=>{this.addFilter(this.debridCount,t,e,n)})}),this.debridCount++}populateDebridData(e,n){Object.entries(n).forEach(([n,t])=>{const a=document.querySelector(`[name="debrid[${e}].${n}"]`);a&&("checkbox"===a.type?a.checked=t:"download_api_keys"===n&&Array.isArray(t)?(a.value=t.join("\n"),"textarea"===a.tagName.toLowerCase()&&(a.style.webkitTextSecurity="disc",a.style.textSecurity="disc",a.setAttribute("data-password-visible","false"))):a.value=t)})}getDebridTemplate(e,n={}){return`\n
\n
\n
\n

\n \n Debrid Service #${e+1}\n

\n \n
\n
\n
\n \n \n
\n\n
\n \n
\n \n \n
\n
\n API key for the debrid service\n
\n
\n
\n\n
\n
\n
\n \n
\n \n \n
\n
\n Multiple API keys for downloads - leave empty to use main API key\n
\n
\n
\n
\n
\n \n \n
\n Path where debrid files are mounted\n
\n
\n\n
\n \n \n
\n API rate limit for this service\n
\n
\n
\n
\n\n \x3c!-- Options Grid - Full Width Below --\x3e\n
\n
\n \n
\n Create internal WebDAV server\n
\n
\n\n
\n \n
\n Download uncached files\n
\n
\n\n
\n \n
\n Include sample files\n
\n
\n\n
\n \n
\n Preprocess RAR files\n
\n
\n
\n\n \x3c!-- WebDAV Configuration (Initially Hidden) --\x3e\n \n
\n
\n `}toggleWebDAVSection(e,n=!1){const t=e.closest(".debrid-config"),a=t.dataset.index,r=t.querySelector(`#webdav-section-${a}`),i=r.querySelectorAll(".webdav-field");e.checked||n?(r.classList.remove("hidden"),r.querySelectorAll('input[name$=".torrents_refresh_interval"]').forEach(e=>e.required=!0),r.querySelectorAll('input[name$=".download_links_refresh_interval"]').forEach(e=>e.required=!0),r.querySelectorAll('input[name$=".auto_expire_links_after"]').forEach(e=>e.required=!0),r.querySelectorAll('input[name$=".workers"]').forEach(e=>e.required=!0)):(r.classList.add("hidden"),i.forEach(e=>e.required=!1))}addDirectory(e,n={}){this.debridDirectoryCounts[e]||(this.debridDirectoryCounts[e]=0);const t=this.debridDirectoryCounts[e],a=document.getElementById(`debrid[${e}].directories`),r=this.getDirectoryTemplate(e,t);a.insertAdjacentHTML("beforeend",r);const i=`${e}-${t}`;if(this.directoryFilterCounts[i]=0,n.name){const a=document.querySelector(`[name="debrid[${e}].directory[${t}].name"]`);a&&(a.value=n.name)}return this.debridDirectoryCounts[e]++,t}getDirectoryTemplate(e,n){return`\n
\n
\n
\n
Virtual Directory
\n \n
\n\n
\n \n \n
\n\n
\n
\n
\n Filters\n \n
\n
\n\n
\n \x3c!-- Filters will be added here --\x3e\n
\n\n
\n \n\n \n\n \n\n \n
\n
\n
\n
\n `}addFilter(e,n,t,a=""){const r=`${e}-${n}`;this.directoryFilterCounts[r]||(this.directoryFilterCounts[r]=0);const i=this.directoryFilterCounts[r],l=document.getElementById(`debrid[${e}].directory[${n}].filters`);if(l){const s=this.getFilterTemplate(e,n,i,t);if(l.insertAdjacentHTML("beforeend",s),a){const t=l.querySelector(`[name="debrid[${e}].directory[${n}].filter[${i}].value"]`);t&&(t.value=a)}this.directoryFilterCounts[r]++}}getFilterTemplate(e,n,t,a){const r=this.getFilterConfig(a);return`\n
\n
\n ${r.label}\n
\n \n
\n \n
\n \n
\n `}getFilterConfig(e){return{include:{label:"Include",placeholder:"Text that should be included in filename",badgeClass:"badge-primary"},exclude:{label:"Exclude",placeholder:"Text that should not be in filename",badgeClass:"badge-error"},regex:{label:"Regex Match",placeholder:"Regular expression pattern",badgeClass:"badge-warning"},not_regex:{label:"Regex Not Match",placeholder:"Regular expression pattern that should not match",badgeClass:"badge-error"},exact_match:{label:"Exact Match",placeholder:"Exact text to match",badgeClass:"badge-primary"},not_exact_match:{label:"Not Exact Match",placeholder:"Exact text that should not match",badgeClass:"badge-error"},starts_with:{label:"Starts With",placeholder:"Text that filename starts with",badgeClass:"badge-primary"},not_starts_with:{label:"Not Starts With",placeholder:"Text that filename should not start with",badgeClass:"badge-error"},ends_with:{label:"Ends With",placeholder:"Text that filename ends with",badgeClass:"badge-primary"},not_ends_with:{label:"Not Ends With",placeholder:"Text that filename should not end with",badgeClass:"badge-error"},size_gt:{label:"Size Greater Than",placeholder:"Size in bytes, KB, MB, GB (e.g. 700MB)",badgeClass:"badge-success"},size_lt:{label:"Size Less Than",placeholder:"Size in bytes, KB, MB, GB (e.g. 700MB)",badgeClass:"badge-warning"},last_added:{label:"Added in the last",placeholder:"Time duration (e.g. 24h, 7d, 30d)",badgeClass:"badge-info"}}[e]||{label:e.replace(/_/g," ").replace(/\b\w/g,e=>e.toUpperCase()),placeholder:"Filter value",badgeClass:"badge-ghost"}}showFilterHelp(){const e=document.createElement("dialog");e.className="modal",e.innerHTML='\n \n ',document.body.appendChild(e),e.showModal(),e.addEventListener("close",()=>{document.body.removeChild(e)})}addArrConfig(e={}){const n=this.getArrTemplate(this.arrCount,e);this.refs.arrConfigs.insertAdjacentHTML("beforeend",n),Object.keys(e).length>0&&this.populateArrData(this.arrCount,e),this.arrCount++}populateArrData(e,n){Object.entries(n).forEach(([n,t])=>{const a=document.querySelector(`[name="arr[${e}].${n}"]`);a&&("checkbox"===a.type?a.checked=t:a.value=t)})}getArrTemplate(e,n={}){const t="auto"===n.source;return`\n
\n
\n
\n

\n \n Arr Service #${e+1}\n ${t?'
Auto-detected
':""}\n

\n ${t?"":'\n \n '}\n
\n\n \n\n
\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n
\n \n \n
\n
\n
\n\n
\n
\n \n \n
\n Which debrid service this Arr should prefer\n
\n
\n\n
\n
\n
\n \n
\n\n
\n \n
\n\n
\n \n
\n
\n
\n
\n
\n
\n `}async saveConfiguration(e){e.preventDefault(),this.refs.loadingOverlay.classList.remove("hidden");try{const e=this.collectFormData(),n=this.validateConfiguration(e);if(!n.valid)throw new Error(n.errors.join("\n"));const t=await window.decypharrUtils.fetcher("/api/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)});if(!t.ok){const e=await t.text();throw new Error(e||"Failed to save configuration")}window.decypharrUtils.createToast("Configuration saved successfully! Services are restarting...","success"),setTimeout(()=>{window.location.reload()},2e3)}catch(e){console.error("Error saving configuration:",e),window.decypharrUtils.createToast(`Error saving configuration: ${e.message}`,"error"),this.refs.loadingOverlay.classList.add("hidden")}}validateConfiguration(e){const n=[];return e.debrids.forEach((e,t)=>{e.name&&e.api_key&&e.folder||n.push(`Debrid service #${t+1}: Name, API key, and folder are required`)}),e.arrs.forEach((e,t)=>{e.name&&e.host||n.push(`Arr service #${t+1}: Name and host are required`),e.host&&!this.isValidUrl(e.host)&&n.push(`Arr service #${t+1}: Invalid host URL format`)}),e.repair.enabled&&(e.repair.interval||n.push("Repair interval is required when repair is enabled"),e.repair.workers&&(e.repair.workers<1||e.repair.workers>50)&&n.push("Repair workers must be between 1 and 50")),{valid:0===n.length,errors:n}}isValidUrl(e){try{return new URL(e),!0}catch(e){return!1}}collectFormData(){return{log_level:document.getElementById("log-level").value,url_base:document.getElementById("urlBase").value,bind_address:document.getElementById("bindAddress").value,port:document.getElementById("port").value?document.getElementById("port").value:null,discord_webhook_url:document.getElementById("discordWebhookUrl").value,allowed_file_types:document.getElementById("allowedExtensions").value.split(",").map(e=>e.trim()).filter(Boolean),min_file_size:document.getElementById("minFileSize").value,max_file_size:document.getElementById("maxFileSize").value,remove_stalled_after:document.getElementById("removeStalledAfter").value,debrids:this.collectDebridConfigs(),qbittorrent:this.collectQBittorrentConfig(),arrs:this.collectArrConfigs(),repair:this.collectRepairConfig()}}collectDebridConfigs(){const e=[];for(let n=0;ne.trim()).filter(e=>e.length>0)),a.use_webdav){a.torrents_refresh_interval=document.querySelector(`[name="debrid[${n}].torrents_refresh_interval"]`).value,a.download_links_refresh_interval=document.querySelector(`[name="debrid[${n}].download_links_refresh_interval"]`).value,a.auto_expire_links_after=document.querySelector(`[name="debrid[${n}].auto_expire_links_after"]`).value,a.folder_naming=document.querySelector(`[name="debrid[${n}].folder_naming"]`).value,a.workers=parseInt(document.querySelector(`[name="debrid[${n}].workers"]`).value),a.rc_url=document.querySelector(`[name="debrid[${n}].rc_url"]`).value,a.rc_user=document.querySelector(`[name="debrid[${n}].rc_user"]`).value,a.rc_pass=document.querySelector(`[name="debrid[${n}].rc_pass"]`).value,a.rc_refresh_dirs=document.querySelector(`[name="debrid[${n}].rc_refresh_dirs"]`).value,a.serve_from_rclone=document.querySelector(`[name="debrid[${n}].serve_from_rclone"]`).checked,a.directories={};const e=this.debridDirectoryCounts[n]||0;for(let t=0;t{if("registerProtocolHandler"in navigator)try{navigator.registerProtocolHandler("magnet",`${window.location.origin}${window.urlBase}download?magnet=%s`,"Decypharr"),localStorage.setItem("magnetHandler","true");const e=document.getElementById("registerMagnetLink");e.innerHTML='Magnet Handler Registered',e.classList.remove("btn-primary"),e.classList.add("btn-success"),e.disabled=!0,window.decypharrUtils.createToast("Magnet link handler registered successfully")}catch(e){console.error("Failed to register magnet link handler:",e),window.decypharrUtils.createToast("Failed to register magnet link handler","error")}else window.decypharrUtils.createToast("Magnet link registration not supported in this browser","warning")},"true"===localStorage.getItem("magnetHandler")){const e=document.getElementById("registerMagnetLink");e&&(e.innerHTML='Magnet Handler Registered',e.classList.remove("btn-primary"),e.classList.add("btn-success"),e.disabled=!0)}}} \ No newline at end of file +class ConfigManager{constructor(){this.debridCount=0,this.arrCount=0,this.usenetProviderCount=0,this.debridDirectoryCounts={},this.directoryFilterCounts={},this.refs={configForm:document.getElementById("configForm"),loadingOverlay:document.getElementById("loadingOverlay"),debridConfigs:document.getElementById("debridConfigs"),arrConfigs:document.getElementById("arrConfigs"),usenetConfigs:document.getElementById("usenetConfigs"),addDebridBtn:document.getElementById("addDebridBtn"),addArrBtn:document.getElementById("addArrBtn"),addUsenetBtn:document.getElementById("addUsenetBtn")},this.init()}init(){this.bindEvents(),this.loadConfiguration(),this.setupMagnetHandler(),this.checkIncompleteConfig()}checkIncompleteConfig(){const e=new URLSearchParams(window.location.search);if(e.has("inco")){const n=e.get("inco");window.decypharrUtils.createToast(`Incomplete configuration: ${n}`,"warning")}}bindEvents(){this.refs.configForm.addEventListener("submit",e=>this.saveConfiguration(e)),this.refs.addDebridBtn.addEventListener("click",()=>this.addDebridConfig()),this.refs.addArrBtn.addEventListener("click",()=>this.addArrConfig()),this.refs.addUsenetBtn.addEventListener("click",()=>this.addUsenetConfig()),document.addEventListener("change",e=>{e.target.classList.contains("useWebdav")&&this.toggleWebDAVSection(e.target)})}async loadConfiguration(){try{const e=await window.decypharrUtils.fetcher("/api/config");if(!e.ok)throw new Error("Failed to load configuration");const n=await e.json();this.populateForm(n)}catch(e){console.error("Error loading configuration:",e),window.decypharrUtils.createToast("Error loading configuration","error")}}populateForm(e){this.populateGeneralSettings(e),e.debrids&&Array.isArray(e.debrids)&&e.debrids.forEach(e=>this.addDebridConfig(e)),this.populateQBittorrentSettings(e.qbittorrent),e.arrs&&Array.isArray(e.arrs)&&e.arrs.forEach(e=>this.addArrConfig(e)),this.populateUsenetSettings(e.usenet),this.populateSABnzbdSettings(e.sabnzbd),this.populateRepairSettings(e.repair)}populateGeneralSettings(e){["log_level","url_base","bind_address","port","discord_webhook_url","min_file_size","max_file_size","remove_stalled_after"].forEach(n=>{const t=document.querySelector(`[name="${n}"]`);t&&void 0!==e[n]&&(t.value=e[n])}),e.allowed_file_types&&Array.isArray(e.allowed_file_types)&&(document.querySelector('[name="allowed_file_types"]').value=e.allowed_file_types.join(", "))}populateQBittorrentSettings(e){if(!e)return;["download_folder","refresh_interval","max_downloads","skip_pre_cache"].forEach(n=>{const t=document.querySelector(`[name="qbit.${n}"]`);t&&void 0!==e[n]&&("checkbox"===t.type?t.checked=e[n]:t.value=e[n])})}populateRepairSettings(e){if(!e)return;["enabled","interval","workers","zurg_url","strategy","use_webdav","auto_process"].forEach(n=>{const t=document.querySelector(`[name="repair.${n}"]`);t&&void 0!==e[n]&&("checkbox"===t.type?t.checked=e[n]:t.value=e[n])})}populateUsenetSettings(e){if(!e)return;["mount_folder","chunks","skip_pre_cache","rc_url","rc_user","rc_pass"].forEach(n=>{const t=document.querySelector(`[name="usenet.${n}"]`);t&&void 0!==e[n]&&("checkbox"===t.type?t.checked=e[n]:t.value=e[n])}),e.providers&&Array.isArray(e.providers)&&e.providers.forEach(e=>this.addUsenetConfig(e))}addDebridConfig(e={}){const n=this.getDebridTemplate(this.debridCount,e);this.refs.debridConfigs.insertAdjacentHTML("beforeend",n);const t=this.refs.debridConfigs.lastElementChild.querySelector(".useWebdav");e.use_webdav&&this.toggleWebDAVSection(t,!0),Object.keys(e).length>0&&this.populateDebridData(this.debridCount,e),this.debridDirectoryCounts[this.debridCount]=0,e.directories&&Object.entries(e.directories).forEach(([e,n])=>{const t=this.addDirectory(this.debridCount,{name:e,...n});n.filters&&Object.entries(n.filters).forEach(([e,n])=>{this.addFilter(this.debridCount,t,e,n)})}),this.debridCount++}populateDebridData(e,n){Object.entries(n).forEach(([n,t])=>{const a=document.querySelector(`[name="debrid[${e}].${n}"]`);a&&("checkbox"===a.type?a.checked=t:"download_api_keys"===n&&Array.isArray(t)?(a.value=t.join("\n"),"textarea"===a.tagName.toLowerCase()&&(a.style.webkitTextSecurity="disc",a.style.textSecurity="disc",a.setAttribute("data-password-visible","false"))):a.value=t)})}getDebridTemplate(e,n={}){return`\n
\n
\n
\n

\n \n Debrid Service #${e+1}\n

\n \n
\n
\n
\n \n \n
\n\n
\n \n
\n \n \n
\n
\n API key for the debrid service\n
\n
\n
\n\n
\n
\n
\n \n
\n \n \n
\n
\n Multiple API keys for downloads - leave empty to use main API key\n
\n
\n
\n
\n
\n \n \n
\n Path where debrid files are mounted\n
\n
\n\n
\n \n \n
\n API rate limit for this service\n
\n
\n
\n
\n\n \x3c!-- Options Grid - Full Width Below --\x3e\n
\n
\n \n
\n Create internal WebDAV server\n
\n
\n\n
\n \n
\n Download uncached files\n
\n
\n\n
\n \n
\n Include sample files\n
\n
\n\n
\n \n
\n Preprocess RAR files\n
\n
\n
\n\n \x3c!-- WebDAV Configuration (Initially Hidden) --\x3e\n \n
\n
\n `}toggleWebDAVSection(e,n=!1){const t=e.closest(".debrid-config"),a=t.dataset.index,s=t.querySelector(`#webdav-section-${a}`),r=s.querySelectorAll(".webdav-field");e.checked||n?(s.classList.remove("hidden"),s.querySelectorAll('input[name$=".torrents_refresh_interval"]').forEach(e=>e.required=!0),s.querySelectorAll('input[name$=".download_links_refresh_interval"]').forEach(e=>e.required=!0),s.querySelectorAll('input[name$=".auto_expire_links_after"]').forEach(e=>e.required=!0),s.querySelectorAll('input[name$=".workers"]').forEach(e=>e.required=!0)):(s.classList.add("hidden"),r.forEach(e=>e.required=!1))}addDirectory(e,n={}){this.debridDirectoryCounts[e]||(this.debridDirectoryCounts[e]=0);const t=this.debridDirectoryCounts[e],a=document.getElementById(`debrid[${e}].directories`),s=this.getDirectoryTemplate(e,t);a.insertAdjacentHTML("beforeend",s);const r=`${e}-${t}`;if(this.directoryFilterCounts[r]=0,n.name){const a=document.querySelector(`[name="debrid[${e}].directory[${t}].name"]`);a&&(a.value=n.name)}return this.debridDirectoryCounts[e]++,t}getDirectoryTemplate(e,n){return`\n
\n
\n
\n
Virtual Directory
\n \n
\n\n
\n \n \n
\n\n
\n
\n
\n Filters\n \n
\n
\n\n
\n \x3c!-- Filters will be added here --\x3e\n
\n\n
\n \n\n \n\n \n\n \n
\n
\n
\n
\n `}addFilter(e,n,t,a=""){const s=`${e}-${n}`;this.directoryFilterCounts[s]||(this.directoryFilterCounts[s]=0);const r=this.directoryFilterCounts[s],l=document.getElementById(`debrid[${e}].directory[${n}].filters`);if(l){const i=this.getFilterTemplate(e,n,r,t);if(l.insertAdjacentHTML("beforeend",i),a){const t=l.querySelector(`[name="debrid[${e}].directory[${n}].filter[${r}].value"]`);t&&(t.value=a)}this.directoryFilterCounts[s]++}}getFilterTemplate(e,n,t,a){const s=this.getFilterConfig(a);return`\n
\n
\n ${s.label}\n
\n \n
\n \n
\n \n
\n `}getFilterConfig(e){return{include:{label:"Include",placeholder:"Text that should be included in filename",badgeClass:"badge-primary"},exclude:{label:"Exclude",placeholder:"Text that should not be in filename",badgeClass:"badge-error"},regex:{label:"Regex Match",placeholder:"Regular expression pattern",badgeClass:"badge-warning"},not_regex:{label:"Regex Not Match",placeholder:"Regular expression pattern that should not match",badgeClass:"badge-error"},exact_match:{label:"Exact Match",placeholder:"Exact text to match",badgeClass:"badge-primary"},not_exact_match:{label:"Not Exact Match",placeholder:"Exact text that should not match",badgeClass:"badge-error"},starts_with:{label:"Starts With",placeholder:"Text that filename starts with",badgeClass:"badge-primary"},not_starts_with:{label:"Not Starts With",placeholder:"Text that filename should not start with",badgeClass:"badge-error"},ends_with:{label:"Ends With",placeholder:"Text that filename ends with",badgeClass:"badge-primary"},not_ends_with:{label:"Not Ends With",placeholder:"Text that filename should not end with",badgeClass:"badge-error"},size_gt:{label:"Size Greater Than",placeholder:"Size in bytes, KB, MB, GB (e.g. 700MB)",badgeClass:"badge-success"},size_lt:{label:"Size Less Than",placeholder:"Size in bytes, KB, MB, GB (e.g. 700MB)",badgeClass:"badge-warning"},last_added:{label:"Added in the last",placeholder:"Time duration (e.g. 24h, 7d, 30d)",badgeClass:"badge-info"}}[e]||{label:e.replace(/_/g," ").replace(/\b\w/g,e=>e.toUpperCase()),placeholder:"Filter value",badgeClass:"badge-ghost"}}showFilterHelp(){const e=document.createElement("dialog");e.className="modal",e.innerHTML='\n \n ',document.body.appendChild(e),e.showModal(),e.addEventListener("close",()=>{document.body.removeChild(e)})}addArrConfig(e={}){const n=this.getArrTemplate(this.arrCount,e);this.refs.arrConfigs.insertAdjacentHTML("beforeend",n),Object.keys(e).length>0&&this.populateArrData(this.arrCount,e),this.arrCount++}populateArrData(e,n){Object.entries(n).forEach(([n,t])=>{const a=document.querySelector(`[name="arr[${e}].${n}"]`);a&&("checkbox"===a.type?a.checked=t:a.value=t)})}getArrTemplate(e,n={}){const t="auto"===n.source;return`\n
\n
\n
\n

\n \n Arr Service #${e+1}\n ${t?'
Auto-detected
':""}\n

\n ${t?"":'\n \n '}\n
\n\n \n\n
\n
\n \n \n
\n\n
\n \n \n
\n\n
\n \n
\n \n \n
\n
\n
\n\n
\n
\n \n \n
\n Which debrid service this Arr should prefer\n
\n
\n\n
\n
\n
\n \n
\n\n
\n \n
\n\n
\n \n
\n
\n
\n
\n
\n
\n `}async saveConfiguration(e){e.preventDefault(),this.refs.loadingOverlay.classList.remove("hidden");try{const e=this.collectFormData(),n=this.validateConfiguration(e);if(!n.valid)throw new Error(n.errors.join("\n"));const t=await window.decypharrUtils.fetcher("/api/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)});if(!t.ok){const e=await t.text();throw new Error(e||"Failed to save configuration")}window.decypharrUtils.createToast("Configuration saved successfully! Services are restarting...","success"),setTimeout(()=>{window.location.reload()},2e3)}catch(e){console.error("Error saving configuration:",e),window.decypharrUtils.createToast(`Error saving configuration: ${e.message}`,"error"),this.refs.loadingOverlay.classList.add("hidden")}}validateConfiguration(e){const n=[];return e.debrids.forEach((e,t)=>{e.name&&e.api_key&&e.folder||n.push(`Debrid service #${t+1}: Name, API key, and folder are required`)}),e.arrs.forEach((e,t)=>{e.name&&e.host||n.push(`Arr service #${t+1}: Name and host are required`),e.host&&!this.isValidUrl(e.host)&&n.push(`Arr service #${t+1}: Invalid host URL format`)}),e.usenet&&e.usenet.providers.forEach((e,t)=>{e.host||n.push(`Usenet server #${t+1}: Host is required`),e.port&&(e.port<1||e.port>65535)&&n.push(`Usenet server #${t+1}: Port must be between 1 and 65535`),e.connections&&e.connections<1&&n.push(`Usenet server #${t+1}: Connections must be more than 0`)}),e.repair.enabled&&(e.repair.interval||n.push("Repair interval is required when repair is enabled"),e.repair.workers&&(e.repair.workers<1||e.repair.workers>50)&&n.push("Repair workers must be between 1 and 50")),{valid:0===n.length,errors:n}}isValidUrl(e){try{return new URL(e),!0}catch(e){return!1}}collectFormData(){return{log_level:document.getElementById("log-level").value,url_base:document.getElementById("urlBase").value,bind_address:document.getElementById("bindAddress").value,port:document.getElementById("port").value?document.getElementById("port").value:null,discord_webhook_url:document.getElementById("discordWebhookUrl").value,allowed_file_types:document.getElementById("allowedExtensions").value.split(",").map(e=>e.trim()).filter(Boolean),min_file_size:document.getElementById("minFileSize").value,max_file_size:document.getElementById("maxFileSize").value,remove_stalled_after:document.getElementById("removeStalledAfter").value,debrids:this.collectDebridConfigs(),qbittorrent:this.collectQBittorrentConfig(),arrs:this.collectArrConfigs(),usenet:this.collectUsenetConfig(),sabnzbd:this.collectSABnzbdConfig(),repair:this.collectRepairConfig()}}collectDebridConfigs(){const e=[];for(let n=0;ne.trim()).filter(e=>e.length>0)),a.use_webdav){a.torrents_refresh_interval=document.querySelector(`[name="debrid[${n}].torrents_refresh_interval"]`).value,a.download_links_refresh_interval=document.querySelector(`[name="debrid[${n}].download_links_refresh_interval"]`).value,a.auto_expire_links_after=document.querySelector(`[name="debrid[${n}].auto_expire_links_after"]`).value,a.folder_naming=document.querySelector(`[name="debrid[${n}].folder_naming"]`).value,a.workers=parseInt(document.querySelector(`[name="debrid[${n}].workers"]`).value),a.rc_url=document.querySelector(`[name="debrid[${n}].rc_url"]`).value,a.rc_user=document.querySelector(`[name="debrid[${n}].rc_user"]`).value,a.rc_pass=document.querySelector(`[name="debrid[${n}].rc_pass"]`).value,a.rc_refresh_dirs=document.querySelector(`[name="debrid[${n}].rc_refresh_dirs"]`).value,a.serve_from_rclone=document.querySelector(`[name="debrid[${n}].serve_from_rclone"]`).checked,a.directories={};const e=this.debridDirectoryCounts[n]||0;for(let t=0;t0&&this.populateUsenetData(this.usenetProviderCount,e),this.usenetProviderCount++}populateUsenetData(e,n){Object.entries(n).forEach(([n,t])=>{const a=document.querySelector(`[name="usenet[${e}].${n}"]`);a&&("checkbox"===a.type?a.checked=t:a.value=t)})}getUsenetTemplate(e,n={}){return`\n
\n
\n
\n

\n \n Usenet Server #${e+1}\n

\n \n
\n \n
\n
\n \n \n
\n Usenet Name\n
\n
\n
\n \n \n
\n Usenet server hostname\n
\n
\n\n
\n \n \n
\n Server port (119 for standard, 563 for SSL)\n
\n
\n
\n \n \n
\n Maximum simultaneous connections\n
\n
\n
\n \n \n
\n Username for authentication\n
\n
\n\n
\n \n
\n \n \n
\n
\n Password for authentication\n
\n
\n
\n\n
\n
\n \n
\n Use SSL encryption\n
\n
\n\n
\n \n
\n Use TLS encryption\n
\n
\n
\n
\n
\n `}populateSABnzbdSettings(e){if(!e)return;["download_folder","refresh_interval"].forEach(n=>{const t=document.querySelector(`[name="sabnzbd.${n}"]`);t&&void 0!==e[n]&&("checkbox"===t.type?t.checked=e[n]:t.value=e[n])});const n=document.querySelector('[name="sabnzbd.categories"]');n&&e.categories&&(n.value=e.categories.join(", "))}collectUsenetConfig(){const e=[];for(let n=0;ne.trim()).filter(Boolean)}}collectRepairConfig(){return{enabled:document.querySelector('[name="repair.enabled"]').checked,interval:document.querySelector('[name="repair.interval"]').value,zurg_url:document.querySelector('[name="repair.zurg_url"]').value,strategy:document.querySelector('[name="repair.strategy"]').value,workers:parseInt(document.querySelector('[name="repair.workers"]').value)||1,use_webdav:document.querySelector('[name="repair.use_webdav"]').checked,auto_process:document.querySelector('[name="repair.auto_process"]').checked}}setupMagnetHandler(){if(window.registerMagnetLinkHandler=()=>{if("registerProtocolHandler"in navigator)try{navigator.registerProtocolHandler("magnet",`${window.location.origin}${window.urlBase}download?magnet=%s`,"Decypharr"),localStorage.setItem("magnetHandler","true");const e=document.getElementById("registerMagnetLink");e.innerHTML='Magnet Handler Registered',e.classList.remove("btn-primary"),e.classList.add("btn-success"),e.disabled=!0,window.decypharrUtils.createToast("Magnet link handler registered successfully")}catch(e){console.error("Failed to register magnet link handler:",e),window.decypharrUtils.createToast("Failed to register magnet link handler","error")}else window.decypharrUtils.createToast("Magnet link registration not supported in this browser","warning")},"true"===localStorage.getItem("magnetHandler")){const e=document.getElementById("registerMagnetLink");e&&(e.innerHTML='Magnet Handler Registered',e.classList.remove("btn-primary"),e.classList.add("btn-success"),e.disabled=!0)}}} \ No newline at end of file diff --git a/pkg/web/assets/build/js/dashboard.js b/pkg/web/assets/build/js/dashboard.js index 2296c7a..addde6c 100644 --- a/pkg/web/assets/build/js/dashboard.js +++ b/pkg/web/assets/build/js/dashboard.js @@ -1 +1 @@ -class TorrentDashboard{constructor(){this.state={torrents:[],selectedTorrents:new Set,categories:new Set,filteredTorrents:[],selectedCategory:"",selectedState:"",sortBy:"added_on",itemsPerPage:20,currentPage:1,selectedTorrentContextMenu:null},this.refs={torrentsList:document.getElementById("torrentsList"),categoryFilter:document.getElementById("categoryFilter"),stateFilter:document.getElementById("stateFilter"),sortSelector:document.getElementById("sortSelector"),selectAll:document.getElementById("selectAll"),batchDeleteBtn:document.getElementById("batchDeleteBtn"),batchDeleteDebridBtn:document.getElementById("batchDeleteDebridBtn"),refreshBtn:document.getElementById("refreshBtn"),torrentContextMenu:document.getElementById("torrentContextMenu"),paginationControls:document.getElementById("paginationControls"),paginationInfo:document.getElementById("paginationInfo"),emptyState:document.getElementById("emptyState")},this.init()}init(){this.bindEvents(),this.loadTorrents(),this.startAutoRefresh()}bindEvents(){this.refs.refreshBtn.addEventListener("click",()=>this.loadTorrents()),this.refs.batchDeleteBtn.addEventListener("click",()=>this.deleteSelectedTorrents()),this.refs.batchDeleteDebridBtn.addEventListener("click",()=>this.deleteSelectedTorrents(!0)),this.refs.selectAll.addEventListener("change",t=>this.toggleSelectAll(t.target.checked)),this.refs.categoryFilter.addEventListener("change",t=>this.setFilter("category",t.target.value)),this.refs.stateFilter.addEventListener("change",t=>this.setFilter("state",t.target.value)),this.refs.sortSelector.addEventListener("change",t=>this.setSort(t.target.value)),this.bindContextMenu(),this.refs.torrentsList.addEventListener("change",t=>{t.target.classList.contains("torrent-select")&&this.toggleTorrentSelection(t.target.dataset.hash,t.target.checked)})}bindContextMenu(){this.refs.torrentsList.addEventListener("contextmenu",t=>{const e=t.target.closest("tr[data-hash]");e&&(t.preventDefault(),this.showContextMenu(t,e))}),document.addEventListener("click",t=>{this.refs.torrentContextMenu.contains(t.target)||this.hideContextMenu()}),this.refs.torrentContextMenu.addEventListener("click",t=>{const e=t.target.closest("[data-action]")?.dataset.action;e&&(this.handleContextAction(e),this.hideContextMenu())})}showContextMenu(t,e){this.state.selectedTorrentContextMenu={hash:e.dataset.hash,name:e.dataset.name,category:e.dataset.category||""},this.refs.torrentContextMenu.querySelector(".torrent-name").textContent=this.state.selectedTorrentContextMenu.name;const{pageX:s,pageY:r}=t,{clientWidth:n,clientHeight:a}=document.documentElement,o=this.refs.torrentContextMenu;o.style.left=`${Math.min(s,n-200)}px`,o.style.top=`${Math.min(r,a-150)}px`,o.classList.remove("hidden")}hideContextMenu(){this.refs.torrentContextMenu.classList.add("hidden"),this.state.selectedTorrentContextMenu=null}async handleContextAction(t){const e=this.state.selectedTorrentContextMenu;if(!e)return;const s={"copy-magnet":async()=>{try{await navigator.clipboard.writeText(`magnet:?xt=urn:btih:${e.hash}`),window.decypharrUtils.createToast("Magnet link copied to clipboard")}catch(t){window.decypharrUtils.createToast("Failed to copy magnet link","error")}},"copy-name":async()=>{try{await navigator.clipboard.writeText(e.name),window.decypharrUtils.createToast("Torrent name copied to clipboard")}catch(t){window.decypharrUtils.createToast("Failed to copy torrent name","error")}},delete:async()=>{await this.deleteTorrent(e.hash,e.category,!1)}};s[t]&&await s[t]()}async loadTorrents(){try{this.refs.refreshBtn.disabled=!0,this.refs.paginationInfo.textContent="Loading torrents...";const t=await window.decypharrUtils.fetcher("/api/torrents");if(!t.ok)throw new Error("Failed to fetch torrents");const e=await t.json();this.state.torrents=e,this.state.categories=new Set(e.map(t=>t.category).filter(Boolean)),this.updateUI()}catch(t){console.error("Error loading torrents:",t),window.decypharrUtils.createToast(`Error loading torrents: ${t.message}`,"error")}finally{this.refs.refreshBtn.disabled=!1}}updateUI(){this.filterTorrents(),this.updateCategoryFilter(),this.renderTorrents(),this.updatePagination(),this.updateSelectionUI(),this.toggleEmptyState()}filterTorrents(){let t=[...this.state.torrents];this.state.selectedCategory&&(t=t.filter(t=>t.category===this.state.selectedCategory)),this.state.selectedState&&(t=t.filter(t=>t.state?.toLowerCase()===this.state.selectedState.toLowerCase())),t=this.sortTorrents(t),this.state.filteredTorrents=t}sortTorrents(t){const[e,s]=this.state.sortBy.includes("_asc")||this.state.sortBy.includes("_desc")?[this.state.sortBy.split("_").slice(0,-1).join("_"),this.state.sortBy.endsWith("_asc")?"asc":"desc"]:[this.state.sortBy,"desc"];return t.sort((t,r)=>{let n,a;switch(e){case"name":n=t.name?.toLowerCase()||"",a=r.name?.toLowerCase()||"";break;case"size":n=t.size||0,a=r.size||0;break;case"progress":n=t.progress||0,a=r.progress||0;break;case"added_on":n=t.added_on||0,a=r.added_on||0;break;default:n=t[e]||0,a=r[e]||0}return"string"==typeof n?"asc"===s?n.localeCompare(a):a.localeCompare(n):"asc"===s?n-a:a-n})}renderTorrents(){const t=(this.state.currentPage-1)*this.state.itemsPerPage,e=Math.min(t+this.state.itemsPerPage,this.state.filteredTorrents.length),s=this.state.filteredTorrents.slice(t,e);this.refs.torrentsList.innerHTML=s.map(t=>this.torrentRowTemplate(t)).join("")}torrentRowTemplate(t){const e=(100*t.progress).toFixed(1),s=this.state.selectedTorrents.has(t.hash);new Date(t.added_on).toLocaleString();return`\n \n \n \n \n \n
\n ${this.escapeHtml(t.name)}\n
\n \n \n ${window.decypharrUtils.formatBytes(t.size)}\n \n \n
\n \n ${e}%\n
\n \n \n ${window.decypharrUtils.formatSpeed(t.dlspeed)}\n \n \n ${t.category?`
${this.escapeHtml(t.category)}
`:'None'}\n \n \n ${t.debrid?`
${this.escapeHtml(t.debrid)}
`:'None'}\n \n \n ${t.num_seeds||0}\n \n \n
\n ${this.escapeHtml(t.state)}\n
\n \n \n
\n \n ${t.debrid&&t.id?`\n \n `:""}\n
\n \n \n `}getStateColor(t){return{downloading:"badge-primary",pausedup:"badge-success",error:"badge-error",completed:"badge-success"}[t?.toLowerCase()]||"badge-ghost"}updateCategoryFilter(){const t=Array.from(this.state.categories).sort(),e=[''].concat(t.map(t=>``));this.refs.categoryFilter.innerHTML=e.join("")}updatePagination(){const t=Math.ceil(this.state.filteredTorrents.length/this.state.itemsPerPage),e=(this.state.currentPage-1)*this.state.itemsPerPage,s=Math.min(e+this.state.itemsPerPage,this.state.filteredTorrents.length);if(this.refs.paginationInfo.textContent=`Showing ${this.state.filteredTorrents.length>0?e+1:0}-${s} of ${this.state.filteredTorrents.length} torrents`,this.refs.paginationControls.innerHTML="",t<=1)return;const r=this.createPaginationButton("❮",this.state.currentPage-1,1===this.state.currentPage);this.refs.paginationControls.appendChild(r);let n=Math.max(1,this.state.currentPage-Math.floor(2.5)),a=Math.min(t,n+5-1);a-n+1<5&&(n=Math.max(1,a-5+1));for(let t=n;t<=a;t++){const e=this.createPaginationButton(t,t,!1,t===this.state.currentPage);this.refs.paginationControls.appendChild(e)}const o=this.createPaginationButton("❯",this.state.currentPage+1,this.state.currentPage===t);this.refs.paginationControls.appendChild(o)}createPaginationButton(t,e,s=!1,r=!1){const n=document.createElement("button");return n.className=`join-item btn btn-sm ${r?"btn-active":""} ${s?"btn-disabled":""}`,n.textContent=t,n.disabled=s,s||n.addEventListener("click",()=>{this.state.currentPage=e,this.updateUI()}),n}updateSelectionUI(){const t=new Set(this.state.filteredTorrents.map(t=>t.hash));this.state.selectedTorrents.forEach(e=>{t.has(e)||this.state.selectedTorrents.delete(e)}),this.refs.batchDeleteBtn.classList.toggle("hidden",0===this.state.selectedTorrents.size),this.refs.batchDeleteDebridBtn.classList.toggle("hidden",0===this.state.selectedTorrents.size);const e=this.state.filteredTorrents.slice((this.state.currentPage-1)*this.state.itemsPerPage,this.state.currentPage*this.state.itemsPerPage);this.refs.selectAll.checked=e.length>0&&e.every(t=>this.state.selectedTorrents.has(t.hash)),this.refs.selectAll.indeterminate=e.some(t=>this.state.selectedTorrents.has(t.hash))&&!e.every(t=>this.state.selectedTorrents.has(t.hash))}toggleEmptyState(){const t=0===this.state.torrents.length;this.refs.emptyState.classList.toggle("hidden",!t),document.querySelector(".card:has(#torrentsList)").classList.toggle("hidden",t)}setFilter(t,e){"category"===t?this.state.selectedCategory=e:"state"===t&&(this.state.selectedState=e),this.state.currentPage=1,this.updateUI()}setSort(t){this.state.sortBy=t,this.state.currentPage=1,this.updateUI()}toggleSelectAll(t){this.state.filteredTorrents.slice((this.state.currentPage-1)*this.state.itemsPerPage,this.state.currentPage*this.state.itemsPerPage).forEach(e=>{t?this.state.selectedTorrents.add(e.hash):this.state.selectedTorrents.delete(e.hash)}),this.updateUI()}toggleTorrentSelection(t,e){e?this.state.selectedTorrents.add(t):this.state.selectedTorrents.delete(t),this.updateSelectionUI()}async deleteTorrent(t,e,s=!1){if(confirm(`Are you sure you want to delete this torrent${s?" from "+e:""}?`))try{const r=`/api/torrents/${encodeURIComponent(e)}/${t}?removeFromDebrid=${s}`,n=await window.decypharrUtils.fetcher(r,{method:"DELETE"});if(!n.ok)throw new Error(await n.text());window.decypharrUtils.createToast("Torrent deleted successfully"),await this.loadTorrents()}catch(t){console.error("Error deleting torrent:",t),window.decypharrUtils.createToast(`Failed to delete torrent: ${t.message}`,"error")}}async deleteSelectedTorrents(t=!1){const e=this.state.selectedTorrents.size;if(0!==e){if(confirm(`Are you sure you want to delete ${e} torrent${e>1?"s":""}${t?" from debrid":""}?`))try{const s=Array.from(this.state.selectedTorrents).join(","),r=await window.decypharrUtils.fetcher(`/api/torrents/?hashes=${encodeURIComponent(s)}&removeFromDebrid=${t}`,{method:"DELETE"});if(!r.ok)throw new Error(await r.text());window.decypharrUtils.createToast(`${e} torrent${e>1?"s":""} deleted successfully`),this.state.selectedTorrents.clear(),await this.loadTorrents()}catch(t){console.error("Error deleting torrents:",t),window.decypharrUtils.createToast(`Failed to delete some torrents: ${t.message}`,"error")}}else window.decypharrUtils.createToast("No torrents selected for deletion","warning")}startAutoRefresh(){this.refreshInterval=setInterval(()=>{this.loadTorrents()},5e3),window.addEventListener("beforeunload",()=>{this.refreshInterval&&clearInterval(this.refreshInterval)})}escapeHtml(t){const e={"&":"&","<":"<",">":">",'"':""","'":"'"};return t?t.replace(/[&<>"']/g,t=>e[t]):""}} \ No newline at end of file +class Dashboard{constructor(){this.state={mode:"torrents",torrents:[],nzbs:[],selectedItems:new Set,categories:new Set,filteredItems:[],selectedCategory:"",selectedState:"",sortBy:"added_on",itemsPerPage:20,currentPage:1,selectedItemContextMenu:null},this.refs={torrentsMode:document.getElementById("torrentsMode"),nzbsMode:document.getElementById("nzbsMode"),dataList:document.getElementById("dataList"),torrentsHeaders:document.getElementById("torrentsHeaders"),nzbsHeaders:document.getElementById("nzbsHeaders"),categoryFilter:document.getElementById("categoryFilter"),stateFilter:document.getElementById("stateFilter"),sortSelector:document.getElementById("sortSelector"),selectAll:document.getElementById("selectAll"),selectAllNzb:document.getElementById("selectAllNzb"),batchDeleteBtn:document.getElementById("batchDeleteBtn"),batchDeleteDebridBtn:document.getElementById("batchDeleteDebridBtn"),refreshBtn:document.getElementById("refreshBtn"),torrentContextMenu:document.getElementById("torrentContextMenu"),nzbContextMenu:document.getElementById("nzbContextMenu"),paginationControls:document.getElementById("paginationControls"),paginationInfo:document.getElementById("paginationInfo"),emptyState:document.getElementById("emptyState"),emptyStateTitle:document.getElementById("emptyStateTitle"),emptyStateMessage:document.getElementById("emptyStateMessage")},this.init()}init(){this.bindEvents(),this.loadModeFromURL(),this.loadData(),this.startAutoRefresh()}bindEvents(){this.refs.torrentsMode.addEventListener("click",()=>this.switchMode("torrents")),this.refs.nzbsMode.addEventListener("click",()=>this.switchMode("nzbs")),this.refs.refreshBtn.addEventListener("click",()=>this.loadData()),this.refs.batchDeleteBtn.addEventListener("click",()=>this.deleteSelectedItems()),this.refs.batchDeleteDebridBtn.addEventListener("click",()=>this.deleteSelectedItems(!0)),this.refs.selectAll.addEventListener("change",t=>this.toggleSelectAll(t.target.checked)),this.refs.selectAllNzb.addEventListener("change",t=>this.toggleSelectAll(t.target.checked)),this.refs.categoryFilter.addEventListener("change",t=>this.setFilter("category",t.target.value)),this.refs.stateFilter.addEventListener("change",t=>this.setFilter("state",t.target.value)),this.refs.sortSelector.addEventListener("change",t=>this.setSort(t.target.value)),this.bindContextMenu(),this.refs.dataList.addEventListener("change",t=>{t.target.classList.contains("item-select")&&this.toggleItemSelection(t.target.dataset.id,t.target.checked)})}switchMode(t){this.state.mode!==t&&(this.state.mode=t,this.state.selectedItems.clear(),this.updateURL(t),"torrents"===t?(this.refs.torrentsMode.classList.remove("btn-outline"),this.refs.torrentsMode.classList.add("btn-primary"),this.refs.nzbsMode.classList.remove("btn-primary"),this.refs.nzbsMode.classList.add("btn-outline"),this.refs.torrentsHeaders.classList.remove("hidden"),this.refs.nzbsHeaders.classList.add("hidden"),this.refs.emptyStateTitle.textContent="No Torrents Found",this.refs.emptyStateMessage.textContent="You haven't added any torrents yet. Start by adding your first download!",this.refs.batchDeleteDebridBtn.classList.remove("hidden")):(this.refs.nzbsMode.classList.remove("btn-outline"),this.refs.nzbsMode.classList.add("btn-primary"),this.refs.torrentsMode.classList.remove("btn-primary"),this.refs.torrentsMode.classList.add("btn-outline"),this.refs.nzbsHeaders.classList.remove("hidden"),this.refs.torrentsHeaders.classList.add("hidden"),this.refs.emptyStateTitle.textContent="No NZBs Found",this.refs.emptyStateMessage.textContent="You haven't added any NZB downloads yet. Start by adding your first NZB!",this.refs.batchDeleteDebridBtn.classList.add("hidden")),this.state.selectedCategory="",this.state.selectedState="",this.state.currentPage=1,this.refs.categoryFilter.value="",this.refs.stateFilter.value="",this.loadData(),this.updateBatchActions())}updateBatchActions(){const t=this.state.selectedItems.size>0;if(this.refs.batchDeleteBtn&&this.refs.batchDeleteBtn.classList.toggle("hidden",!t),this.refs.batchDeleteDebridBtn){const e=t&&"torrents"===this.state.mode;this.refs.batchDeleteDebridBtn.classList.toggle("hidden",!e)}if(t){const t=this.state.selectedItems.size,e="torrents"===this.state.mode?"Torrent":"NZB",s="torrents"===this.state.mode?"Torrents":"NZBs";if(this.refs.batchDeleteBtn){const a=1===t?`Delete ${e}`:`Delete ${t} ${s}`,r=this.refs.batchDeleteBtn.querySelector("span");r&&(r.textContent=a)}if(this.refs.batchDeleteDebridBtn&&"torrents"===this.state.mode){const e=1===t?"Remove From Debrid":`Remove ${t} From Debrid`,s=this.refs.batchDeleteDebridBtn.querySelector("span");s&&(s.textContent=e)}}else{if(this.refs.batchDeleteBtn){const t=this.refs.batchDeleteBtn.querySelector("span");t&&(t.textContent="Delete Selected")}if(this.refs.batchDeleteDebridBtn){const t=this.refs.batchDeleteDebridBtn.querySelector("span");t&&(t.textContent="Remove From Debrid")}}}loadData(){"torrents"===this.state.mode?this.loadTorrents():this.loadNZBs()}async loadNZBs(){try{const t=await window.decypharrUtils.fetcher("/api/nzbs");if(!t.ok)throw new Error("Failed to fetch NZBs");const e=await t.json();this.state.nzbs=e.nzbs||[],this.updateCategories(),this.applyFilters(),this.renderData()}catch(t){console.error("Error loading NZBs:",t),window.decypharrUtils.createToast("Error loading NZBs","error")}}updateCategories(){const t="torrents"===this.state.mode?this.state.torrents:this.state.nzbs;this.state.categories=new Set(t.map(t=>t.category).filter(Boolean))}applyFilters(){"torrents"===this.state.mode?this.filterTorrents():this.filterNZBs()}filterNZBs(){let t=[...this.state.nzbs];this.state.selectedCategory&&(t=t.filter(t=>t.category===this.state.selectedCategory)),this.state.selectedState&&(t=t.filter(t=>t.status===this.state.selectedState)),t.sort((t,e)=>{switch(this.state.sortBy){case"added_on":return new Date(e.added_on)-new Date(t.added_on);case"added_on_asc":return new Date(t.added_on)-new Date(e.added_on);case"name_asc":return t.name.localeCompare(e.name);case"name_desc":return e.name.localeCompare(t.name);case"size_desc":return(e.total_size||0)-(t.total_size||0);case"size_asc":return(t.total_size||0)-(e.total_size||0);case"progress_desc":return(e.progress||0)-(t.progress||0);case"progress_asc":return(t.progress||0)-(e.progress||0);default:return 0}}),this.state.filteredItems=t}renderData(){"torrents"===this.state.mode?this.renderTorrents():this.renderNZBs()}renderNZBs(){const t=(this.state.currentPage-1)*this.state.itemsPerPage,e=t+this.state.itemsPerPage,s=this.state.filteredItems.slice(t,e),a=this.refs.dataList;a.innerHTML="",0===s.length?this.refs.emptyState.classList.remove("hidden"):(this.refs.emptyState.classList.add("hidden"),s.forEach(t=>{const e=document.createElement("tr");e.className="hover cursor-pointer",e.setAttribute("data-id",t.id),e.setAttribute("data-name",t.name),e.setAttribute("data-category",t.category||"");const s=Math.round(t.progress||0),r=this.formatBytes(t.total_size||0),n=this.formatETA(t.eta||0),i=this.formatAge(t.date_posted),o=this.getStatusBadge(t.status);e.innerHTML=`\n \n \n \n \n
${t.name}
\n \n ${r}\n \n
\n
\n
\n
\n ${s}%\n
\n \n ${n}\n \n ${t.category||"N/A"}\n \n ${o}\n ${i}\n \n
\n \n
\n \n `,a.appendChild(e)})),this.updatePagination(),this.updateSelectionUI()}getStatusBadge(t){return{downloading:'Downloading',completed:'Completed',paused:'Paused',failed:'Failed',queued:'Queued',processing:'Processing',verifying:'Verifying',repairing:'Repairing',extracting:'Extracting'}[t]||'Unknown'}formatETA(t){if(!t||t<=0)return"N/A";const e=Math.floor(t/3600),s=Math.floor(t%3600/60);return e>0?`${e}h ${s}m`:`${s}m`}formatAge(t){if(!t)return"N/A";const e=new Date-new Date(t),s=Math.floor(e/864e5);return 0===s?"Today":1===s?"1 day":`${s} days`}formatBytes(t){if(!t||0===t)return"0 B";const e=Math.floor(Math.log(t)/Math.log(1024));return parseFloat((t/Math.pow(1024,e)).toFixed(2))+" "+["B","KB","MB","GB","TB"][e]}bindContextMenu(){this.refs.dataList.addEventListener("contextmenu",t=>{const e=t.target.closest("tr[data-id]");e&&(t.preventDefault(),this.showContextMenu(t,e))}),document.addEventListener("click",t=>{const e=this.refs.torrentContextMenu,s=this.refs.nzbContextMenu;e.contains(t.target)||s.contains(t.target)||this.hideContextMenu()}),this.refs.torrentContextMenu.addEventListener("click",t=>{const e=t.target.closest("[data-action]")?.dataset.action;e&&(this.handleContextAction(e),this.hideContextMenu())}),this.refs.nzbContextMenu.addEventListener("click",t=>{const e=t.target.closest("[data-action]")?.dataset.action;e&&(this.handleContextAction(e),this.hideContextMenu())})}showContextMenu(t,e){const{pageX:s,pageY:a}=t,{clientWidth:r,clientHeight:n}=document.documentElement;if("torrents"===this.state.mode){this.state.selectedItemContextMenu={id:e.dataset.hash,name:e.dataset.name,category:e.dataset.category||"",type:"torrent"};const t=this.refs.torrentContextMenu;t.querySelector(".torrent-name").textContent=this.state.selectedItemContextMenu.name,t.style.left=`${Math.min(s,r-200)}px`,t.style.top=`${Math.min(a,n-150)}px`,t.classList.remove("hidden")}else{this.state.selectedItemContextMenu={id:e.dataset.id,name:e.dataset.name,category:e.dataset.category||"",type:"nzb"};const t=this.refs.nzbContextMenu;t.querySelector(".nzb-name").textContent=this.state.selectedItemContextMenu.name,t.style.left=`${Math.min(s,r-200)}px`,t.style.top=`${Math.min(a,n-150)}px`,t.classList.remove("hidden")}}hideContextMenu(){this.refs.torrentContextMenu.classList.add("hidden"),this.refs.nzbContextMenu.classList.add("hidden"),this.state.selectedItemContextMenu=null}async handleContextAction(t){const e=this.state.selectedItemContextMenu;e&&("torrent"===e.type?await this.handleTorrentAction(t,e):await this.handleNZBAction(t,e))}async handleTorrentAction(t,e){const s={"copy-magnet":async()=>{try{await navigator.clipboard.writeText(`magnet:?xt=urn:btih:${e.hash}`),window.decypharrUtils.createToast("Magnet link copied to clipboard")}catch(t){window.decypharrUtils.createToast("Failed to copy magnet link","error")}},"copy-name":async()=>{try{await navigator.clipboard.writeText(e.name),window.decypharrUtils.createToast("Torrent name copied to clipboard")}catch(t){window.decypharrUtils.createToast("Failed to copy torrent name","error")}},delete:async()=>{await this.deleteTorrent(e.hash,e.category,!1)}};s[t]&&await s[t]()}async handleNZBAction(t,e){const s={pause:async()=>{try{if(!(await window.decypharrUtils.fetcher(`/api/nzbs/${e.id}/pause`,{method:"POST"})).ok)throw new Error("Failed to pause NZB");window.decypharrUtils.createToast("NZB paused successfully"),this.loadData()}catch(t){window.decypharrUtils.createToast("Failed to pause NZB","error")}},resume:async()=>{try{if(!(await window.decypharrUtils.fetcher(`/api/nzbs/${e.id}/resume`,{method:"POST"})).ok)throw new Error("Failed to resume NZB");window.decypharrUtils.createToast("NZB resumed successfully"),this.loadData()}catch(t){window.decypharrUtils.createToast("Failed to resume NZB","error")}},retry:async()=>{try{if(!(await window.decypharrUtils.fetcher(`/api/nzbs/${e.id}/retry`,{method:"POST"})).ok)throw new Error("Failed to retry NZB");window.decypharrUtils.createToast("NZB retry started successfully"),this.loadData()}catch(t){window.decypharrUtils.createToast("Failed to retry NZB","error")}},"copy-name":async()=>{try{await navigator.clipboard.writeText(e.name),window.decypharrUtils.createToast("NZB name copied to clipboard")}catch(t){window.decypharrUtils.createToast("Failed to copy NZB name","error")}},delete:async()=>{await this.deleteNZB(e.id)}};s[t]&&await s[t]()}async deleteNZB(t){try{if(!(await window.decypharrUtils.fetcher(`/api/nzbs/${t}`,{method:"DELETE"})).ok)throw new Error("Failed to delete NZB");window.decypharrUtils.createToast("NZB deleted successfully"),this.loadData()}catch(t){window.decypharrUtils.createToast("Failed to delete NZB","error")}}async loadTorrents(){try{this.refs.refreshBtn.disabled=!0,this.refs.paginationInfo.textContent="Loading torrents...";const t=await window.decypharrUtils.fetcher("/api/torrents");if(!t.ok)throw new Error("Failed to fetch torrents");const e=await t.json();this.state.torrents=e,this.state.categories=new Set(e.map(t=>t.category).filter(Boolean)),this.updateUI()}catch(t){console.error("Error loading torrents:",t),window.decypharrUtils.createToast(`Error loading torrents: ${t.message}`,"error")}finally{this.refs.refreshBtn.disabled=!1}}updateUI(){this.applyFilters(),this.updateCategoryFilter(),this.renderData(),this.updatePagination(),this.updateSelectionUI(),this.toggleEmptyState()}filterTorrents(){let t=[...this.state.torrents];this.state.selectedCategory&&(t=t.filter(t=>t.category===this.state.selectedCategory)),this.state.selectedState&&(t=t.filter(t=>t.state?.toLowerCase()===this.state.selectedState.toLowerCase())),t=this.sortTorrents(t),this.state.filteredItems=t}sortTorrents(t){const[e,s]=this.state.sortBy.includes("_asc")||this.state.sortBy.includes("_desc")?[this.state.sortBy.split("_").slice(0,-1).join("_"),this.state.sortBy.endsWith("_asc")?"asc":"desc"]:[this.state.sortBy,"desc"];return t.sort((t,a)=>{let r,n;switch(e){case"name":r=t.name?.toLowerCase()||"",n=a.name?.toLowerCase()||"";break;case"size":r=t.size||0,n=a.size||0;break;case"progress":r=t.progress||0,n=a.progress||0;break;case"added_on":r=t.added_on||0,n=a.added_on||0;break;default:r=t[e]||0,n=a[e]||0}return"string"==typeof r?"asc"===s?r.localeCompare(n):n.localeCompare(r):"asc"===s?r-n:n-r})}renderTorrents(){const t=(this.state.currentPage-1)*this.state.itemsPerPage,e=Math.min(t+this.state.itemsPerPage,this.state.filteredItems.length),s=this.state.filteredItems.slice(t,e);this.refs.dataList.innerHTML=s.map(t=>this.torrentRowTemplate(t)).join("")}torrentRowTemplate(t){const e=(100*t.progress).toFixed(1),s=this.state.selectedItems.has(t.hash);new Date(t.added_on).toLocaleString();return`\n \n \n \n \n \n
\n ${this.escapeHtml(t.name)}\n
\n \n \n ${window.decypharrUtils.formatBytes(t.size)}\n \n \n
\n \n ${e}%\n
\n \n \n ${window.decypharrUtils.formatSpeed(t.dlspeed)}\n \n \n ${t.category?`
${this.escapeHtml(t.category)}
`:'None'}\n \n \n ${t.debrid?`
${this.escapeHtml(t.debrid)}
`:'None'}\n \n \n ${t.num_seeds||0}\n \n \n
\n ${this.escapeHtml(t.state)}\n
\n \n \n
\n \n ${t.debrid&&t.id?`\n \n `:""}\n
\n \n \n `}getStateColor(t){return{downloading:"badge-primary",pausedup:"badge-success",error:"badge-error",completed:"badge-success"}[t?.toLowerCase()]||"badge-ghost"}updateCategoryFilter(){const t=Array.from(this.state.categories).sort(),e=[''].concat(t.map(t=>``));this.refs.categoryFilter.innerHTML=e.join("")}updatePagination(){const t=Math.ceil(this.state.filteredItems.length/this.state.itemsPerPage),e=(this.state.currentPage-1)*this.state.itemsPerPage,s=Math.min(e+this.state.itemsPerPage,this.state.filteredItems.length);if(this.refs.paginationInfo.textContent=`Showing ${this.state.filteredItems.length>0?e+1:0}-${s} of ${this.state.filteredItems.length} torrents`,this.refs.paginationControls.innerHTML="",t<=1)return;const a=this.createPaginationButton("❮",this.state.currentPage-1,1===this.state.currentPage);this.refs.paginationControls.appendChild(a);let r=Math.max(1,this.state.currentPage-Math.floor(2.5)),n=Math.min(t,r+5-1);n-r+1<5&&(r=Math.max(1,n-5+1));for(let t=r;t<=n;t++){const e=this.createPaginationButton(t,t,!1,t===this.state.currentPage);this.refs.paginationControls.appendChild(e)}const i=this.createPaginationButton("❯",this.state.currentPage+1,this.state.currentPage===t);this.refs.paginationControls.appendChild(i)}createPaginationButton(t,e,s=!1,a=!1){const r=document.createElement("button");return r.className=`join-item btn btn-sm ${a?"btn-active":""} ${s?"btn-disabled":""}`,r.textContent=t,r.disabled=s,s||r.addEventListener("click",()=>{this.state.currentPage=e,this.updateUI()}),r}updateSelectionUI(){const t=new Set(this.state.filteredItems.map(t=>t.hash));this.state.selectedItems.forEach(e=>{t.has(e)||this.state.selectedItems.delete(e)}),this.refs.batchDeleteBtn.classList.toggle("hidden",0===this.state.selectedItems.size),this.refs.batchDeleteDebridBtn.classList.toggle("hidden",0===this.state.selectedItems.size);const e=this.state.filteredItems.slice((this.state.currentPage-1)*this.state.itemsPerPage,this.state.currentPage*this.state.itemsPerPage);this.refs.selectAll.checked=e.length>0&&e.every(t=>this.state.selectedItems.has(t.hash)),this.refs.selectAll.indeterminate=e.some(t=>this.state.selectedItems.has(t.hash))&&!e.every(t=>this.state.selectedItems.has(t.hash))}toggleEmptyState(){const t=0===("torrents"===this.state.mode?this.state.torrents:this.state.nzbs).length;this.refs.emptyState&&this.refs.emptyState.classList.toggle("hidden",!t);const e=document.querySelector(".card:has(#dataList)");e&&e.classList.toggle("hidden",t)}setFilter(t,e){"category"===t?this.state.selectedCategory=e:"state"===t&&(this.state.selectedState=e),this.state.currentPage=1,this.updateUI()}setSort(t){this.state.sortBy=t,this.state.currentPage=1,this.updateUI()}toggleSelectAll(t){this.state.filteredItems.slice((this.state.currentPage-1)*this.state.itemsPerPage,this.state.currentPage*this.state.itemsPerPage).forEach(e=>{t?this.state.selectedItems.add(e.hash):this.state.selectedItems.delete(e.hash)}),this.updateUI()}toggleItemSelection(t,e){e?this.state.selectedItems.add(t):this.state.selectedItems.delete(t),this.updateSelectionUI(),this.updateBatchActions()}async deleteTorrent(t,e,s=!1){if(confirm(`Are you sure you want to delete this torrent${s?" from "+e:""}?`))try{const a=`/api/torrents/${encodeURIComponent(e)}/${t}?removeFromDebrid=${s}`,r=await window.decypharrUtils.fetcher(a,{method:"DELETE"});if(!r.ok)throw new Error(await r.text());window.decypharrUtils.createToast("Torrent deleted successfully"),await this.loadTorrents()}catch(t){console.error("Error deleting torrent:",t),window.decypharrUtils.createToast(`Failed to delete torrent: ${t.message}`,"error")}}async deleteSelectedItems(t=!1){const e=this.state.selectedItems.size;if(0===e){const t="torrents"===this.state.mode?"torrents":"NZBs";return void window.decypharrUtils.createToast(`No ${t} selected for deletion`,"warning")}const s="torrents"===this.state.mode?"torrent":"NZB",a="torrents"===this.state.mode?"torrents":"NZBs";if(confirm(`Are you sure you want to delete ${e} ${e>1?a:s}${t?" from debrid":""}?`))try{if("torrents"===this.state.mode){const e=Array.from(this.state.selectedItems).join(","),s=await window.decypharrUtils.fetcher(`/api/torrents/?hashes=${encodeURIComponent(e)}&removeFromDebrid=${t}`,{method:"DELETE"});if(!s.ok)throw new Error(await s.text())}else{const t=Array.from(this.state.selectedItems).map(t=>window.decypharrUtils.fetcher(`/api/nzbs/${t}`,{method:"DELETE"})),e=await Promise.all(t);for(const t of e)if(!t.ok)throw new Error(await t.text())}window.decypharrUtils.createToast(`${e} ${e>1?a:s} deleted successfully`),this.state.selectedItems.clear(),await this.loadData()}catch(t){console.error(`Error deleting ${a}:`,t),window.decypharrUtils.createToast(`Failed to delete some ${a}: ${t.message}`,"error")}}startAutoRefresh(){this.refreshInterval=setInterval(()=>{this.loadData()},5e3),window.addEventListener("beforeunload",()=>{this.refreshInterval&&clearInterval(this.refreshInterval)})}escapeHtml(t){const e={"&":"&","<":"<",">":">",'"':""","'":"'"};return t?t.replace(/[&<>"']/g,t=>e[t]):""}loadModeFromURL(){const t=new URLSearchParams(window.location.search).get("mode");this.state.mode="nzbs"===t||"torrents"===t?t:"torrents",this.setModeUI(this.state.mode)}setModeUI(t){"torrents"===t?(this.refs.torrentsMode.classList.remove("btn-outline"),this.refs.torrentsMode.classList.add("btn-primary"),this.refs.nzbsMode.classList.remove("btn-primary"),this.refs.nzbsMode.classList.add("btn-outline"),this.refs.torrentsHeaders.classList.remove("hidden"),this.refs.nzbsHeaders.classList.add("hidden"),this.refs.emptyStateTitle.textContent="No Torrents Found",this.refs.emptyStateMessage.textContent="You haven't added any torrents yet. Start by adding your first download!",this.refs.batchDeleteDebridBtn.classList.remove("hidden")):(this.refs.nzbsMode.classList.remove("btn-outline"),this.refs.nzbsMode.classList.add("btn-primary"),this.refs.torrentsMode.classList.remove("btn-primary"),this.refs.torrentsMode.classList.add("btn-outline"),this.refs.nzbsHeaders.classList.remove("hidden"),this.refs.torrentsHeaders.classList.add("hidden"),this.refs.emptyStateTitle.textContent="No NZBs Found",this.refs.emptyStateMessage.textContent="You haven't added any NZB downloads yet. Start by adding your first NZB!",this.refs.batchDeleteDebridBtn.classList.add("hidden"))}updateURL(t){const e=new URL(window.location);e.searchParams.set("mode",t),window.history.replaceState({},"",e)}} \ No newline at end of file diff --git a/pkg/web/assets/build/js/download.js b/pkg/web/assets/build/js/download.js index 8b43f79..692f7e7 100644 --- a/pkg/web/assets/build/js/download.js +++ b/pkg/web/assets/build/js/download.js @@ -1 +1 @@ -class DownloadManager{constructor(e){this.downloadFolder=e,this.refs={downloadForm:document.getElementById("downloadForm"),magnetURI:document.getElementById("magnetURI"),torrentFiles:document.getElementById("torrentFiles"),arr:document.getElementById("arr"),downloadAction:document.getElementById("downloadAction"),downloadUncached:document.getElementById("downloadUncached"),downloadFolder:document.getElementById("downloadFolder"),debrid:document.getElementById("debrid"),submitBtn:document.getElementById("submitDownload"),activeCount:document.getElementById("activeCount"),completedCount:document.getElementById("completedCount"),totalSize:document.getElementById("totalSize")},this.init()}init(){this.loadSavedOptions(),this.bindEvents(),this.handleMagnetFromURL()}bindEvents(){this.refs.downloadForm.addEventListener("submit",e=>this.handleSubmit(e)),this.refs.arr.addEventListener("change",()=>this.saveOptions()),this.refs.downloadAction.addEventListener("change",()=>this.saveOptions()),this.refs.downloadUncached.addEventListener("change",()=>this.saveOptions()),this.refs.downloadFolder.addEventListener("change",()=>this.saveOptions()),this.refs.torrentFiles.addEventListener("change",e=>this.handleFileSelection(e)),this.setupDragAndDrop()}loadSavedOptions(){const e={category:localStorage.getItem("downloadCategory")||"",action:localStorage.getItem("downloadAction")||"symlink",uncached:"true"===localStorage.getItem("downloadUncached"),folder:localStorage.getItem("downloadFolder")||this.downloadFolder};this.refs.arr.value=e.category,this.refs.downloadAction.value=e.action,this.refs.downloadUncached.checked=e.uncached,this.refs.downloadFolder.value=e.folder}saveOptions(){localStorage.setItem("downloadCategory",this.refs.arr.value),localStorage.setItem("downloadAction",this.refs.downloadAction.value),localStorage.setItem("downloadUncached",this.refs.downloadUncached.checked.toString()),localStorage.setItem("downloadFolder",this.refs.downloadFolder.value)}handleMagnetFromURL(){const e=new URLSearchParams(window.location.search).get("magnet");e&&(this.refs.magnetURI.value=e,history.replaceState({},document.title,window.location.pathname),window.decypharrUtils.createToast("Magnet link loaded from URL","info"))}async handleSubmit(e){e.preventDefault();const t=new FormData,r=this.refs.magnetURI.value.split("\n").map(e=>e.trim()).filter(e=>e.length>0);r.length>0&&t.append("urls",r.join("\n"));for(let e=0;e100)window.decypharrUtils.createToast("Please submit up to 100 torrents at a time","warning");else{t.append("arr",this.refs.arr.value),t.append("downloadFolder",this.refs.downloadFolder.value),t.append("action",this.refs.downloadAction.value),t.append("downloadUncached",this.refs.downloadUncached.checked),this.refs.debrid&&t.append("debrid",this.refs.debrid.value);try{window.decypharrUtils.setButtonLoading(this.refs.submitBtn,!0);const e=await window.decypharrUtils.fetcher("/api/add",{method:"POST",body:t,headers:{}}),r=await e.json();if(!e.ok)throw new Error(r.error||"Unknown error");r.errors&&r.errors.length>0?r.results.length>0?(window.decypharrUtils.createToast(`Added ${r.results.length} torrents with ${r.errors.length} errors`,"warning"),this.showErrorDetails(r.errors)):(window.decypharrUtils.createToast("Failed to add torrents","error"),this.showErrorDetails(r.errors)):(window.decypharrUtils.createToast(`Successfully added ${r.results.length} torrent${r.results.length>1?"s":""}!`),this.clearForm())}catch(e){console.error("Error adding downloads:",e),window.decypharrUtils.createToast(`Error adding downloads: ${e.message}`,"error")}finally{window.decypharrUtils.setButtonLoading(this.refs.submitBtn,!1)}}else window.decypharrUtils.createToast("Please provide at least one torrent","warning")}showErrorDetails(e){const t=e.map(e=>`• ${e}`).join("\n");console.error("Download errors:",t),setTimeout(()=>{confirm("Some torrents failed to add. Would you like to see the details?")&&alert(t)},1e3)}clearForm(){this.refs.magnetURI.value="",this.refs.torrentFiles.value=""}handleFileSelection(e){const t=e.target.files;if(t.length>0){const e=Array.from(t).map(e=>e.name).join(", ");window.decypharrUtils.createToast(`Selected ${t.length} file${t.length>1?"s":""}: ${e}`,"info")}}setupDragAndDrop(){const e=this.refs.downloadForm;["dragenter","dragover","dragleave","drop"].forEach(t=>{e.addEventListener(t,this.preventDefaults,!1)}),["dragenter","dragover"].forEach(t=>{e.addEventListener(t,()=>this.highlight(e),!1)}),["dragleave","drop"].forEach(t=>{e.addEventListener(t,()=>this.unhighlight(e),!1)}),e.addEventListener("drop",e=>this.handleDrop(e),!1)}preventDefaults(e){e.preventDefault(),e.stopPropagation()}highlight(e){e.classList.add("border-primary","border-2","border-dashed","bg-primary/5")}unhighlight(e){e.classList.remove("border-primary","border-2","border-dashed","bg-primary/5")}handleDrop(e){const t=e.dataTransfer.files,r=Array.from(t).filter(e=>e.name.toLowerCase().endsWith(".torrent"));if(r.length>0){const e=new DataTransfer;r.forEach(t=>e.items.add(t)),this.refs.torrentFiles.files=e.files,this.handleFileSelection({target:{files:r}})}else window.decypharrUtils.createToast("Please drop .torrent files only","warning")}} \ No newline at end of file +class DownloadManager{constructor(e){this.downloadFolder=e,this.currentMode="torrent",this.refs={downloadForm:document.getElementById("downloadForm"),torrentMode:document.getElementById("torrentMode"),nzbMode:document.getElementById("nzbMode"),magnetURI:document.getElementById("magnetURI"),torrentFiles:document.getElementById("torrentFiles"),torrentInputs:document.getElementById("torrentInputs"),nzbURLs:document.getElementById("nzbURLs"),nzbFiles:document.getElementById("nzbFiles"),nzbInputs:document.getElementById("nzbInputs"),arr:document.getElementById("arr"),downloadAction:document.getElementById("downloadAction"),downloadUncached:document.getElementById("downloadUncached"),downloadFolder:document.getElementById("downloadFolder"),downloadFolderHint:document.getElementById("downloadFolderHint"),debrid:document.getElementById("debrid"),submitBtn:document.getElementById("submitDownload"),submitButtonText:document.getElementById("submitButtonText"),activeCount:document.getElementById("activeCount"),completedCount:document.getElementById("completedCount"),totalSize:document.getElementById("totalSize")},this.init()}init(){this.loadSavedOptions(),this.bindEvents(),this.handleMagnetFromURL(),this.loadModeFromURL()}bindEvents(){this.refs.downloadForm.addEventListener("submit",e=>this.handleSubmit(e)),this.refs.torrentMode.addEventListener("click",()=>this.switchMode("torrent")),this.refs.nzbMode.addEventListener("click",()=>this.switchMode("nzb")),this.refs.arr.addEventListener("change",()=>this.saveOptions()),this.refs.downloadAction.addEventListener("change",()=>this.saveOptions()),this.refs.downloadUncached.addEventListener("change",()=>this.saveOptions()),this.refs.downloadFolder.addEventListener("change",()=>this.saveOptions()),this.refs.torrentFiles.addEventListener("change",e=>this.handleFileSelection(e)),this.refs.nzbFiles.addEventListener("change",e=>this.handleFileSelection(e)),this.setupDragAndDrop()}loadSavedOptions(){const e={category:localStorage.getItem("downloadCategory")||"",action:localStorage.getItem("downloadAction")||"symlink",uncached:"true"===localStorage.getItem("downloadUncached"),folder:localStorage.getItem("downloadFolder")||this.downloadFolder,mode:localStorage.getItem("downloadMode")||"torrent"};this.refs.arr.value=e.category,this.refs.downloadAction.value=e.action,this.refs.downloadUncached.checked=e.uncached,this.refs.downloadFolder.value=e.folder,this.currentMode=e.mode}saveOptions(){localStorage.setItem("downloadCategory",this.refs.arr.value),localStorage.setItem("downloadAction",this.refs.downloadAction.value),localStorage.setItem("downloadUncached",this.refs.downloadUncached.checked.toString()),localStorage.setItem("downloadFolder",this.refs.downloadFolder.value),localStorage.setItem("downloadMode",this.currentMode)}handleMagnetFromURL(){const e=new URLSearchParams(window.location.search).get("magnet");e&&(this.refs.magnetURI.value=e,history.replaceState({},document.title,window.location.pathname),window.decypharrUtils.createToast("Magnet link loaded from URL","info"))}async handleSubmit(e){e.preventDefault();const t=new FormData;let n=[],s=[],r="/api/add",o="torrent";if("torrent"===this.currentMode){n=this.refs.magnetURI.value.split("\n").map(e=>e.trim()).filter(e=>e.length>0),n.length>0&&t.append("urls",n.join("\n"));for(let e=0;ee.trim()).filter(e=>e.length>0),n.length>0&&t.append("nzbUrls",n.join("\n"));for(let e=0;e100)window.decypharrUtils.createToast(`Please submit up to 100 ${o}s at a time`,"warning");else{t.append("arr",this.refs.arr.value),t.append("downloadFolder",this.refs.downloadFolder.value),t.append("action",this.refs.downloadAction.value),t.append("downloadUncached",this.refs.downloadUncached.checked),this.refs.debrid&&t.append("debrid",this.refs.debrid.value);try{window.decypharrUtils.setButtonLoading(this.refs.submitBtn,!0);const e=await window.decypharrUtils.fetcher(r,{method:"POST",body:t,headers:{}}),n=await e.json();if(!e.ok)throw new Error(n.error||"Unknown error");if(n.errors&&n.errors.length>0){console.log(n.errors);let e=` ${n.errors.join("\n")}`;n.results.length>0?window.decypharrUtils.createToast(`Added ${n.results.length} ${o}s with ${n.errors.length} errors \n${e}`,"warning"):window.decypharrUtils.createToast(`Failed to add ${o}s \n${e}`,"error")}else window.decypharrUtils.createToast(`Successfully added ${n.results.length} ${o}${n.results.length>1?"s":""}!`),this.clearForm()}catch(e){console.error("Error adding downloads:",e),window.decypharrUtils.createToast(`Error adding downloads: ${e.message}`,"error")}finally{window.decypharrUtils.setButtonLoading(this.refs.submitBtn,!1)}}else window.decypharrUtils.createToast(`Please provide at least one ${o}`,"warning")}switchMode(e){this.currentMode=e,this.saveOptions(),this.updateURL(e),"torrent"===e?(this.refs.torrentMode.classList.remove("btn-outline"),this.refs.torrentMode.classList.add("btn-primary"),this.refs.nzbMode.classList.remove("btn-primary"),this.refs.nzbMode.classList.add("btn-outline"),this.refs.torrentInputs.classList.remove("hidden"),this.refs.nzbInputs.classList.add("hidden"),this.refs.submitButtonText.textContent="Add to Download Queue",this.refs.downloadFolderHint.textContent="Leave empty to use default qBittorrent folder"):(this.refs.nzbMode.classList.remove("btn-outline"),this.refs.nzbMode.classList.add("btn-primary"),this.refs.torrentMode.classList.remove("btn-primary"),this.refs.torrentMode.classList.add("btn-outline"),this.refs.nzbInputs.classList.remove("hidden"),this.refs.torrentInputs.classList.add("hidden"),this.refs.submitButtonText.textContent="Add to NZB Queue",this.refs.downloadFolderHint.textContent="Leave empty to use default SABnzbd folder")}clearForm(){"torrent"===this.currentMode?(this.refs.magnetURI.value="",this.refs.torrentFiles.value=""):(this.refs.nzbURLs.value="",this.refs.nzbFiles.value="")}handleFileSelection(e){const t=e.target.files;if(t.length>0){const e=Array.from(t).map(e=>e.name).join(", ");window.decypharrUtils.createToast(`Selected ${t.length} file${t.length>1?"s":""}: ${e}`,"info")}}setupDragAndDrop(){const e=this.refs.downloadForm;["dragenter","dragover","dragleave","drop"].forEach(t=>{e.addEventListener(t,this.preventDefaults,!1)}),["dragenter","dragover"].forEach(t=>{e.addEventListener(t,()=>this.highlight(e),!1)}),["dragleave","drop"].forEach(t=>{e.addEventListener(t,()=>this.unhighlight(e),!1)}),e.addEventListener("drop",e=>this.handleDrop(e),!1)}preventDefaults(e){e.preventDefault(),e.stopPropagation()}highlight(e){e.classList.add("border-primary","border-2","border-dashed","bg-primary/5")}unhighlight(e){e.classList.remove("border-primary","border-2","border-dashed","bg-primary/5")}handleDrop(e){const t=e.dataTransfer.files;if("torrent"===this.currentMode){const e=Array.from(t).filter(e=>e.name.toLowerCase().endsWith(".torrent"));if(e.length>0){const t=new DataTransfer;e.forEach(e=>t.items.add(e)),this.refs.torrentFiles.files=t.files,this.handleFileSelection({target:{files:e}})}else window.decypharrUtils.createToast("Please drop .torrent files only","warning")}else{const e=Array.from(t).filter(e=>e.name.toLowerCase().endsWith(".nzb"));if(e.length>0){const t=new DataTransfer;e.forEach(e=>t.items.add(e)),this.refs.nzbFiles.files=t.files,this.handleFileSelection({target:{files:e}})}else window.decypharrUtils.createToast("Please drop .nzb files only","warning")}}loadModeFromURL(){const e=new URLSearchParams(window.location.search).get("mode");this.currentMode="nzb"===e||"torrent"===e?e:this.currentMode||"torrent",this.setModeUI(this.currentMode)}setModeUI(e){"torrent"===e?(this.refs.torrentMode.classList.remove("btn-outline"),this.refs.torrentMode.classList.add("btn-primary"),this.refs.nzbMode.classList.remove("btn-primary"),this.refs.nzbMode.classList.add("btn-outline"),this.refs.torrentInputs.classList.remove("hidden"),this.refs.nzbInputs.classList.add("hidden"),this.refs.submitButtonText.textContent="Add to Download Queue",this.refs.downloadFolderHint.textContent="Leave empty to use default qBittorrent folder"):(this.refs.nzbMode.classList.remove("btn-outline"),this.refs.nzbMode.classList.add("btn-primary"),this.refs.torrentMode.classList.remove("btn-primary"),this.refs.torrentMode.classList.add("btn-outline"),this.refs.nzbInputs.classList.remove("hidden"),this.refs.torrentInputs.classList.add("hidden"),this.refs.submitButtonText.textContent="Add to NZB Queue",this.refs.downloadFolderHint.textContent="Leave empty to use default SABnzbd folder")}updateURL(e){const t=new URL(window.location);t.searchParams.set("mode",e),window.history.replaceState({},"",t)}} \ No newline at end of file diff --git a/pkg/web/assets/js/common.js b/pkg/web/assets/js/common.js index 369967e..991162d 100644 --- a/pkg/web/assets/js/common.js +++ b/pkg/web/assets/js/common.js @@ -400,7 +400,7 @@ class DecypharrUtils { if (data.channel === 'beta') { versionBadge.classList.add('badge-warning'); - } else if (data.channel === 'nightly') { + } else if (data.channel === 'experimental') { versionBadge.classList.add('badge-error'); } } diff --git a/pkg/web/assets/js/config.js b/pkg/web/assets/js/config.js index 2c4bd2a..3928b1b 100644 --- a/pkg/web/assets/js/config.js +++ b/pkg/web/assets/js/config.js @@ -3,6 +3,7 @@ class ConfigManager { constructor() { this.debridCount = 0; this.arrCount = 0; + this.usenetProviderCount = 0; this.debridDirectoryCounts = {}; this.directoryFilterCounts = {}; @@ -11,8 +12,10 @@ class ConfigManager { loadingOverlay: document.getElementById('loadingOverlay'), debridConfigs: document.getElementById('debridConfigs'), arrConfigs: document.getElementById('arrConfigs'), + usenetConfigs: document.getElementById('usenetConfigs'), addDebridBtn: document.getElementById('addDebridBtn'), - addArrBtn: document.getElementById('addArrBtn') + addArrBtn: document.getElementById('addArrBtn'), + addUsenetBtn: document.getElementById('addUsenetBtn') }; this.init(); @@ -40,6 +43,7 @@ class ConfigManager { // Add buttons this.refs.addDebridBtn.addEventListener('click', () => this.addDebridConfig()); this.refs.addArrBtn.addEventListener('click', () => this.addArrConfig()); + this.refs.addUsenetBtn.addEventListener('click', () => this.addUsenetConfig()); // WebDAV toggle handlers document.addEventListener('change', (e) => { @@ -82,6 +86,12 @@ class ConfigManager { config.arrs.forEach(arr => this.addArrConfig(arr)); } + // Load usenet config + this.populateUsenetSettings(config.usenet); + + // Load SABnzbd config + this.populateSABnzbdSettings(config.sabnzbd); + // Load repair config this.populateRepairSettings(config.repair); } @@ -139,6 +149,26 @@ class ConfigManager { }); } + populateUsenetSettings(usenetConfig) { + if (!usenetConfig) return; + // Populate general Usenet settings + let fields = ["mount_folder", "chunks", "skip_pre_cache", "rc_url", "rc_user", "rc_pass"]; + fields.forEach(field => { + const element = document.querySelector(`[name="usenet.${field}"]`); + if (element && usenetConfig[field] !== undefined) { + if (element.type === 'checkbox') { + element.checked = usenetConfig[field]; + } else { + element.value = usenetConfig[field]; + } + } + }); + + if (usenetConfig.providers && Array.isArray(usenetConfig.providers)) { + usenetConfig.providers.forEach(usenet => this.addUsenetConfig(usenet)); + } + } + addDebridConfig(data = {}) { const debridHtml = this.getDebridTemplate(this.debridCount, data); this.refs.debridConfigs.insertAdjacentHTML('beforeend', debridHtml); @@ -228,7 +258,7 @@ class ConfigManager { API Key
- - +

Directory Filter Types

@@ -779,7 +809,7 @@ class ConfigManager {
  • Examples: 24h, 7d, 30d
  • -
    +
    Negative filters (Not...) will exclude matches instead of including them.
    @@ -868,7 +898,7 @@ class ConfigManager { API Token
    - +
    + +
    +
    + + +
    + Usenet Name +
    +
    +
    + + +
    + Usenet server hostname +
    +
    + +
    + + +
    + Server port (119 for standard, 563 for SSL) +
    +
    +
    + + +
    + Maximum simultaneous connections +
    +
    +
    + + +
    + Username for authentication +
    +
    + +
    + +
    + + +
    +
    + Password for authentication +
    +
    +
    + +
    +
    + +
    + Use SSL encryption +
    +
    + +
    + +
    + Use TLS encryption +
    +
    +
    +
    +
    + `; + } + + populateSABnzbdSettings(sabnzbdConfig) { + if (!sabnzbdConfig) return; + + const fields = ['download_folder', 'refresh_interval']; + + fields.forEach(field => { + const element = document.querySelector(`[name="sabnzbd.${field}"]`); + if (element && sabnzbdConfig[field] !== undefined) { + if (element.type === 'checkbox') { + element.checked = sabnzbdConfig[field]; + } else { + element.value = sabnzbdConfig[field]; + } + } + }); + const categoriesEl = document.querySelector('[name="sabnzbd.categories"]'); + if (categoriesEl && sabnzbdConfig.categories) { + categoriesEl.value = sabnzbdConfig.categories.join(', '); + } + } + + collectUsenetConfig() { + const providers = []; + + for (let i = 0; i < this.usenetProviderCount; i++) { + const hostEl = document.querySelector(`[name="usenet[${i}].host"]`); + if (!hostEl || !hostEl.closest('.usenet-config')) continue; + + const usenet = { + host: hostEl.value, + port: parseInt(document.querySelector(`[name="usenet[${i}].port"]`).value) || 119, + username: document.querySelector(`[name="usenet[${i}].username"]`).value, + password: document.querySelector(`[name="usenet[${i}].password"]`).value, + connections: parseInt(document.querySelector(`[name="usenet[${i}].connections"]`).value) || 30, + name: document.querySelector(`[name="usenet[${i}].name"]`).value, + ssl: document.querySelector(`[name="usenet[${i}].ssl"]`).checked, + use_tls: document.querySelector(`[name="usenet[${i}].use_tls"]`).checked, + }; + + if (usenet.host) { + providers.push(usenet); + } + } + + return { + "providers": providers, + "chunks": parseInt(document.querySelector('[name="usenet.chunks"]').value) || 15, + "mount_folder": document.querySelector('[name="usenet.mount_folder"]').value, + "skip_pre_cache": document.querySelector('[name="usenet.skip_pre_cache"]').checked, + "rc_url": document.querySelector('[name="usenet.rc_url"]').value, + "rc_user": document.querySelector('[name="usenet.rc_user"]').value, + "rc_pass": document.querySelector('[name="usenet.rc_pass"]').value, + }; + } + + collectSABnzbdConfig() { + return { + download_folder: document.querySelector('[name="sabnzbd.download_folder"]').value, + refresh_interval: parseInt(document.querySelector('[name="sabnzbd.refresh_interval"]').value) || 15, + categories: document.querySelector('[name="sabnzbd.categories"]').value + .split(',').map(ext => ext.trim()).filter(Boolean) + }; + } + collectRepairConfig() { return { enabled: document.querySelector('[name="repair.enabled"]').checked, diff --git a/pkg/web/assets/js/dashboard.js b/pkg/web/assets/js/dashboard.js index 3c7191e..68f645d 100644 --- a/pkg/web/assets/js/dashboard.js +++ b/pkg/web/assets/js/dashboard.js @@ -1,32 +1,51 @@ -// Dashboard functionality for torrent management -class TorrentDashboard { +// Dashboard functionality for torrent and NZB management +class Dashboard { constructor() { this.state = { + mode: 'torrents', // 'torrents' or 'nzbs' torrents: [], - selectedTorrents: new Set(), + nzbs: [], + selectedItems: new Set(), categories: new Set(), - filteredTorrents: [], + filteredItems: [], selectedCategory: '', selectedState: '', sortBy: 'added_on', itemsPerPage: 20, currentPage: 1, - selectedTorrentContextMenu: null + selectedItemContextMenu: null }; this.refs = { - torrentsList: document.getElementById('torrentsList'), + // Mode switching + torrentsMode: document.getElementById('torrentsMode'), + nzbsMode: document.getElementById('nzbsMode'), + + // Table elements + dataList: document.getElementById('dataList'), + torrentsHeaders: document.getElementById('torrentsHeaders'), + nzbsHeaders: document.getElementById('nzbsHeaders'), + + // Controls categoryFilter: document.getElementById('categoryFilter'), stateFilter: document.getElementById('stateFilter'), sortSelector: document.getElementById('sortSelector'), selectAll: document.getElementById('selectAll'), + selectAllNzb: document.getElementById('selectAllNzb'), batchDeleteBtn: document.getElementById('batchDeleteBtn'), batchDeleteDebridBtn: document.getElementById('batchDeleteDebridBtn'), refreshBtn: document.getElementById('refreshBtn'), + + // Context menus torrentContextMenu: document.getElementById('torrentContextMenu'), + nzbContextMenu: document.getElementById('nzbContextMenu'), + + // Pagination and empty state paginationControls: document.getElementById('paginationControls'), paginationInfo: document.getElementById('paginationInfo'), - emptyState: document.getElementById('emptyState') + emptyState: document.getElementById('emptyState'), + emptyStateTitle: document.getElementById('emptyStateTitle'), + emptyStateMessage: document.getElementById('emptyStateMessage') }; this.init(); @@ -34,20 +53,26 @@ class TorrentDashboard { init() { this.bindEvents(); - this.loadTorrents(); + this.loadModeFromURL(); + this.loadData(); this.startAutoRefresh(); } bindEvents() { + // Mode switching + this.refs.torrentsMode.addEventListener('click', () => this.switchMode('torrents')); + this.refs.nzbsMode.addEventListener('click', () => this.switchMode('nzbs')); + // Refresh button - this.refs.refreshBtn.addEventListener('click', () => this.loadTorrents()); + this.refs.refreshBtn.addEventListener('click', () => this.loadData()); // Batch delete - this.refs.batchDeleteBtn.addEventListener('click', () => this.deleteSelectedTorrents()); - this.refs.batchDeleteDebridBtn.addEventListener('click', () => this.deleteSelectedTorrents(true)); + this.refs.batchDeleteBtn.addEventListener('click', () => this.deleteSelectedItems()); + this.refs.batchDeleteDebridBtn.addEventListener('click', () => this.deleteSelectedItems(true)); - // Select all checkbox + // Select all checkboxes this.refs.selectAll.addEventListener('change', (e) => this.toggleSelectAll(e.target.checked)); + this.refs.selectAllNzb.addEventListener('change', (e) => this.toggleSelectAll(e.target.checked)); // Filters this.refs.categoryFilter.addEventListener('change', (e) => this.setFilter('category', e.target.value)); @@ -57,18 +82,333 @@ class TorrentDashboard { // Context menu this.bindContextMenu(); - // Torrent selection - this.refs.torrentsList.addEventListener('change', (e) => { - if (e.target.classList.contains('torrent-select')) { - this.toggleTorrentSelection(e.target.dataset.hash, e.target.checked); + // Item selection + this.refs.dataList.addEventListener('change', (e) => { + if (e.target.classList.contains('item-select')) { + this.toggleItemSelection(e.target.dataset.id, e.target.checked); } }); } + switchMode(mode) { + if (this.state.mode === mode) return; + + this.state.mode = mode; + this.state.selectedItems.clear(); + + // Update URL parameter + this.updateURL(mode); + + // Update button states + if (mode === 'torrents') { + this.refs.torrentsMode.classList.remove('btn-outline'); + this.refs.torrentsMode.classList.add('btn-primary'); + this.refs.nzbsMode.classList.remove('btn-primary'); + this.refs.nzbsMode.classList.add('btn-outline'); + + // Show torrent headers, hide NZB headers + this.refs.torrentsHeaders.classList.remove('hidden'); + this.refs.nzbsHeaders.classList.add('hidden'); + + // Update empty state + this.refs.emptyStateTitle.textContent = 'No Torrents Found'; + this.refs.emptyStateMessage.textContent = "You haven't added any torrents yet. Start by adding your first download!"; + + // Show debrid batch delete button + this.refs.batchDeleteDebridBtn.classList.remove('hidden'); + } else { + this.refs.nzbsMode.classList.remove('btn-outline'); + this.refs.nzbsMode.classList.add('btn-primary'); + this.refs.torrentsMode.classList.remove('btn-primary'); + this.refs.torrentsMode.classList.add('btn-outline'); + + // Show NZB headers, hide torrent headers + this.refs.nzbsHeaders.classList.remove('hidden'); + this.refs.torrentsHeaders.classList.add('hidden'); + + // Update empty state + this.refs.emptyStateTitle.textContent = 'No NZBs Found'; + this.refs.emptyStateMessage.textContent = "You haven't added any NZB downloads yet. Start by adding your first NZB!"; + + // Hide debrid batch delete button (not relevant for NZBs) + this.refs.batchDeleteDebridBtn.classList.add('hidden'); + } + + // Reset filters and reload data + this.state.selectedCategory = ''; + this.state.selectedState = ''; + this.state.currentPage = 1; + this.refs.categoryFilter.value = ''; + this.refs.stateFilter.value = ''; + + this.loadData(); + this.updateBatchActions(); + } + + updateBatchActions() { + const hasSelection = this.state.selectedItems.size > 0; + + // Show/hide batch delete button + if (this.refs.batchDeleteBtn) { + this.refs.batchDeleteBtn.classList.toggle('hidden', !hasSelection); + } + + // Show/hide debrid batch delete button (only for torrents) + if (this.refs.batchDeleteDebridBtn) { + const showDebridButton = hasSelection && this.state.mode === 'torrents'; + this.refs.batchDeleteDebridBtn.classList.toggle('hidden', !showDebridButton); + } + + // Update button text with count + if (hasSelection) { + const count = this.state.selectedItems.size; + const itemType = this.state.mode === 'torrents' ? 'Torrent' : 'NZB'; + const itemTypePlural = this.state.mode === 'torrents' ? 'Torrents' : 'NZBs'; + + if (this.refs.batchDeleteBtn) { + const deleteText = count === 1 ? `Delete ${itemType}` : `Delete ${count} ${itemTypePlural}`; + const deleteSpan = this.refs.batchDeleteBtn.querySelector('span'); + if (deleteSpan) { + deleteSpan.textContent = deleteText; + } + } + + if (this.refs.batchDeleteDebridBtn && this.state.mode === 'torrents') { + const debridText = count === 1 ? 'Remove From Debrid' : `Remove ${count} From Debrid`; + const debridSpan = this.refs.batchDeleteDebridBtn.querySelector('span'); + if (debridSpan) { + debridSpan.textContent = debridText; + } + } + } else { + // Reset button text when no selection + if (this.refs.batchDeleteBtn) { + const deleteSpan = this.refs.batchDeleteBtn.querySelector('span'); + if (deleteSpan) { + deleteSpan.textContent = 'Delete Selected'; + } + } + + if (this.refs.batchDeleteDebridBtn) { + const debridSpan = this.refs.batchDeleteDebridBtn.querySelector('span'); + if (debridSpan) { + debridSpan.textContent = 'Remove From Debrid'; + } + } + } + } + + loadData() { + if (this.state.mode === 'torrents') { + this.loadTorrents(); + } else { + this.loadNZBs(); + } + } + + async loadNZBs() { + try { + const response = await window.decypharrUtils.fetcher('/api/nzbs'); + if (!response.ok) { + throw new Error('Failed to fetch NZBs'); + } + + const data = await response.json(); + this.state.nzbs = data.nzbs || []; + + this.updateCategories(); + this.applyFilters(); + this.renderData(); + } catch (error) { + console.error('Error loading NZBs:', error); + window.decypharrUtils.createToast('Error loading NZBs', 'error'); + } + } + + updateCategories() { + const items = this.state.mode === 'torrents' ? this.state.torrents : this.state.nzbs; + this.state.categories = new Set(items.map(item => item.category).filter(Boolean)); + } + + applyFilters() { + if (this.state.mode === 'torrents') { + this.filterTorrents(); + } else { + this.filterNZBs(); + } + } + + filterNZBs() { + let filtered = [...this.state.nzbs]; + + if (this.state.selectedCategory) { + filtered = filtered.filter(n => n.category === this.state.selectedCategory); + } + + if (this.state.selectedState) { + filtered = filtered.filter(n => n.status === this.state.selectedState); + } + + // Apply sorting + filtered.sort((a, b) => { + switch (this.state.sortBy) { + case 'added_on': + return new Date(b.added_on) - new Date(a.added_on); + case 'added_on_asc': + return new Date(a.added_on) - new Date(b.added_on); + case 'name_asc': + return a.name.localeCompare(b.name); + case 'name_desc': + return b.name.localeCompare(a.name); + case 'size_desc': + return (b.total_size || 0) - (a.total_size || 0); + case 'size_asc': + return (a.total_size || 0) - (b.total_size || 0); + case 'progress_desc': + return (b.progress || 0) - (a.progress || 0); + case 'progress_asc': + return (a.progress || 0) - (b.progress || 0); + default: + return 0; + } + }); + + this.state.filteredItems = filtered; + } + + renderData() { + if (this.state.mode === 'torrents') { + this.renderTorrents(); + } else { + this.renderNZBs(); + } + } + + renderNZBs() { + const startIndex = (this.state.currentPage - 1) * this.state.itemsPerPage; + const endIndex = startIndex + this.state.itemsPerPage; + const pageItems = this.state.filteredItems.slice(startIndex, endIndex); + + const tbody = this.refs.dataList; + tbody.innerHTML = ''; + + if (pageItems.length === 0) { + this.refs.emptyState.classList.remove('hidden'); + } else { + this.refs.emptyState.classList.add('hidden'); + pageItems.forEach(nzb => { + const row = document.createElement('tr'); + row.className = 'hover cursor-pointer'; + row.setAttribute('data-id', nzb.id); + row.setAttribute('data-name', nzb.name); + row.setAttribute('data-category', nzb.category || ''); + + const progressPercent = Math.round(nzb.progress || 0); + const sizeFormatted = this.formatBytes(nzb.total_size || 0); + const etaFormatted = this.formatETA(nzb.eta || 0); + const ageFormatted = this.formatAge(nzb.date_posted); + const statusBadge = this.getStatusBadge(nzb.status); + + row.innerHTML = ` + + + + +
    ${nzb.name}
    + + ${sizeFormatted} + +
    +
    +
    +
    + ${progressPercent}% +
    + + ${etaFormatted} + + ${nzb.category || 'N/A'} + + ${statusBadge} + ${ageFormatted} + +
    + +
    + + `; + + tbody.appendChild(row); + }); + } + + this.updatePagination(); + this.updateSelectionUI(); + } + + getStatusBadge(status) { + const statusMap = { + 'downloading': 'Downloading', + 'completed': 'Completed', + 'paused': 'Paused', + 'failed': 'Failed', + 'queued': 'Queued', + 'processing': 'Processing', + 'verifying': 'Verifying', + 'repairing': 'Repairing', + 'extracting': 'Extracting' + }; + return statusMap[status] || 'Unknown'; + } + + formatETA(seconds) { + if (!seconds || seconds <= 0) return 'N/A'; + + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + + if (hours > 0) { + return `${hours}h ${minutes}m`; + } else { + return `${minutes}m`; + } + } + + formatAge(datePosted) { + if (!datePosted) return 'N/A'; + + const now = new Date(); + const posted = new Date(datePosted); + const diffMs = now - posted; + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + + if (diffDays === 0) { + return 'Today'; + } else if (diffDays === 1) { + return '1 day'; + } else { + return `${diffDays} days`; + } + } + + formatBytes(bytes) { + if (!bytes || bytes === 0) return '0 B'; + + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + } + bindContextMenu() { // Show context menu - this.refs.torrentsList.addEventListener('contextmenu', (e) => { - const row = e.target.closest('tr[data-hash]'); + this.refs.dataList.addEventListener('contextmenu', (e) => { + const row = e.target.closest('tr[data-id]'); if (!row) return; e.preventDefault(); @@ -77,12 +417,14 @@ class TorrentDashboard { // Hide context menu document.addEventListener('click', (e) => { - if (!this.refs.torrentContextMenu.contains(e.target)) { + const torrentMenu = this.refs.torrentContextMenu; + const nzbMenu = this.refs.nzbContextMenu; + if (!torrentMenu.contains(e.target) && !nzbMenu.contains(e.target)) { this.hideContextMenu(); } }); - // Context menu actions + // Context menu actions for torrents this.refs.torrentContextMenu.addEventListener('click', (e) => { const action = e.target.closest('[data-action]')?.dataset.action; if (action) { @@ -90,37 +432,72 @@ class TorrentDashboard { this.hideContextMenu(); } }); + + // Context menu actions for NZBs + this.refs.nzbContextMenu.addEventListener('click', (e) => { + const action = e.target.closest('[data-action]')?.dataset.action; + if (action) { + this.handleContextAction(action); + this.hideContextMenu(); + } + }); } showContextMenu(event, row) { - this.state.selectedTorrentContextMenu = { - hash: row.dataset.hash, - name: row.dataset.name, - category: row.dataset.category || '' - }; - - this.refs.torrentContextMenu.querySelector('.torrent-name').textContent = - this.state.selectedTorrentContextMenu.name; - const { pageX, pageY } = event; const { clientWidth, clientHeight } = document.documentElement; - const menu = this.refs.torrentContextMenu; + + if (this.state.mode === 'torrents') { + this.state.selectedItemContextMenu = { + id: row.dataset.hash, + name: row.dataset.name, + category: row.dataset.category || '', + type: 'torrent' + }; - // Position the menu - menu.style.left = `${Math.min(pageX, clientWidth - 200)}px`; - menu.style.top = `${Math.min(pageY, clientHeight - 150)}px`; + const menu = this.refs.torrentContextMenu; + menu.querySelector('.torrent-name').textContent = this.state.selectedItemContextMenu.name; + + // Position the menu + menu.style.left = `${Math.min(pageX, clientWidth - 200)}px`; + menu.style.top = `${Math.min(pageY, clientHeight - 150)}px`; + menu.classList.remove('hidden'); + } else { + this.state.selectedItemContextMenu = { + id: row.dataset.id, + name: row.dataset.name, + category: row.dataset.category || '', + type: 'nzb' + }; - menu.classList.remove('hidden'); + const menu = this.refs.nzbContextMenu; + menu.querySelector('.nzb-name').textContent = this.state.selectedItemContextMenu.name; + + // Position the menu + menu.style.left = `${Math.min(pageX, clientWidth - 200)}px`; + menu.style.top = `${Math.min(pageY, clientHeight - 150)}px`; + menu.classList.remove('hidden'); + } } hideContextMenu() { this.refs.torrentContextMenu.classList.add('hidden'); - this.state.selectedTorrentContextMenu = null; + this.refs.nzbContextMenu.classList.add('hidden'); + this.state.selectedItemContextMenu = null; } async handleContextAction(action) { - const torrent = this.state.selectedTorrentContextMenu; - if (!torrent) return; + const item = this.state.selectedItemContextMenu; + if (!item) return; + + if (item.type === 'torrent') { + await this.handleTorrentAction(action, item); + } else { + await this.handleNZBAction(action, item); + } + } + + async handleTorrentAction(action, torrent) { const actions = { 'copy-magnet': async () => { @@ -149,6 +526,87 @@ class TorrentDashboard { } } + async handleNZBAction(action, nzb) { + const actions = { + 'pause': async () => { + try { + const response = await window.decypharrUtils.fetcher(`/api/nzbs/${nzb.id}/pause`, { + method: 'POST' + }); + if (response.ok) { + window.decypharrUtils.createToast('NZB paused successfully'); + this.loadData(); + } else { + throw new Error('Failed to pause NZB'); + } + } catch (error) { + window.decypharrUtils.createToast('Failed to pause NZB', 'error'); + } + }, + 'resume': async () => { + try { + const response = await window.decypharrUtils.fetcher(`/api/nzbs/${nzb.id}/resume`, { + method: 'POST' + }); + if (response.ok) { + window.decypharrUtils.createToast('NZB resumed successfully'); + this.loadData(); + } else { + throw new Error('Failed to resume NZB'); + } + } catch (error) { + window.decypharrUtils.createToast('Failed to resume NZB', 'error'); + } + }, + 'retry': async () => { + try { + const response = await window.decypharrUtils.fetcher(`/api/nzbs/${nzb.id}/retry`, { + method: 'POST' + }); + if (response.ok) { + window.decypharrUtils.createToast('NZB retry started successfully'); + this.loadData(); + } else { + throw new Error('Failed to retry NZB'); + } + } catch (error) { + window.decypharrUtils.createToast('Failed to retry NZB', 'error'); + } + }, + 'copy-name': async () => { + try { + await navigator.clipboard.writeText(nzb.name); + window.decypharrUtils.createToast('NZB name copied to clipboard'); + } catch (error) { + window.decypharrUtils.createToast('Failed to copy NZB name', 'error'); + } + }, + 'delete': async () => { + await this.deleteNZB(nzb.id); + } + }; + + if (actions[action]) { + await actions[action](); + } + } + + async deleteNZB(nzbId) { + try { + const response = await window.decypharrUtils.fetcher(`/api/nzbs/${nzbId}`, { + method: 'DELETE' + }); + if (response.ok) { + window.decypharrUtils.createToast('NZB deleted successfully'); + this.loadData(); + } else { + throw new Error('Failed to delete NZB'); + } + } catch (error) { + window.decypharrUtils.createToast('Failed to delete NZB', 'error'); + } + } + async loadTorrents() { try { // Show loading state @@ -173,14 +631,14 @@ class TorrentDashboard { } updateUI() { - // Filter torrents - this.filterTorrents(); + // Apply filters based on current mode + this.applyFilters(); // Update category dropdown this.updateCategoryFilter(); - // Render torrents table - this.renderTorrents(); + // Render data table + this.renderData(); // Update pagination this.updatePagination(); @@ -206,7 +664,7 @@ class TorrentDashboard { // Sort torrents filtered = this.sortTorrents(filtered); - this.state.filteredTorrents = filtered; + this.state.filteredItems = filtered; } sortTorrents(torrents) { @@ -253,27 +711,27 @@ class TorrentDashboard { renderTorrents() { const startIndex = (this.state.currentPage - 1) * this.state.itemsPerPage; - const endIndex = Math.min(startIndex + this.state.itemsPerPage, this.state.filteredTorrents.length); - const pageItems = this.state.filteredTorrents.slice(startIndex, endIndex); + const endIndex = Math.min(startIndex + this.state.itemsPerPage, this.state.filteredItems.length); + const pageItems = this.state.filteredItems.slice(startIndex, endIndex); - this.refs.torrentsList.innerHTML = pageItems.map(torrent => this.torrentRowTemplate(torrent)).join(''); + this.refs.dataList.innerHTML = pageItems.map(torrent => this.torrentRowTemplate(torrent)).join(''); } torrentRowTemplate(torrent) { const progressPercent = (torrent.progress * 100).toFixed(1); - const isSelected = this.state.selectedTorrents.has(torrent.hash); + const isSelected = this.state.selectedItems.has(torrent.hash); let addedOn = new Date(torrent.added_on).toLocaleString(); return ` - @@ -358,13 +816,13 @@ class TorrentDashboard { } updatePagination() { - const totalPages = Math.ceil(this.state.filteredTorrents.length / this.state.itemsPerPage); + const totalPages = Math.ceil(this.state.filteredItems.length / this.state.itemsPerPage); const startIndex = (this.state.currentPage - 1) * this.state.itemsPerPage; - const endIndex = Math.min(startIndex + this.state.itemsPerPage, this.state.filteredTorrents.length); + const endIndex = Math.min(startIndex + this.state.itemsPerPage, this.state.filteredItems.length); // Update pagination info this.refs.paginationInfo.textContent = - `Showing ${this.state.filteredTorrents.length > 0 ? startIndex + 1 : 0}-${endIndex} of ${this.state.filteredTorrents.length} torrents`; + `Showing ${this.state.filteredItems.length > 0 ? startIndex + 1 : 0}-${endIndex} of ${this.state.filteredItems.length} torrents`; // Clear pagination controls this.refs.paginationControls.innerHTML = ''; @@ -412,33 +870,42 @@ class TorrentDashboard { updateSelectionUI() { // Clean up selected torrents that no longer exist - const currentHashes = new Set(this.state.filteredTorrents.map(t => t.hash)); - this.state.selectedTorrents.forEach(hash => { + const currentHashes = new Set(this.state.filteredItems.map(t => t.hash)); + this.state.selectedItems.forEach(hash => { if (!currentHashes.has(hash)) { - this.state.selectedTorrents.delete(hash); + this.state.selectedItems.delete(hash); } }); // Update batch delete button - this.refs.batchDeleteBtn.classList.toggle('hidden', this.state.selectedTorrents.size === 0); - this.refs.batchDeleteDebridBtn.classList.toggle('hidden', this.state.selectedTorrents.size === 0); + this.refs.batchDeleteBtn.classList.toggle('hidden', this.state.selectedItems.size === 0); + this.refs.batchDeleteDebridBtn.classList.toggle('hidden', this.state.selectedItems.size === 0); // Update select all checkbox - const visibleTorrents = this.state.filteredTorrents.slice( + const visibleTorrents = this.state.filteredItems.slice( (this.state.currentPage - 1) * this.state.itemsPerPage, this.state.currentPage * this.state.itemsPerPage ); this.refs.selectAll.checked = visibleTorrents.length > 0 && - visibleTorrents.every(torrent => this.state.selectedTorrents.has(torrent.hash)); - this.refs.selectAll.indeterminate = visibleTorrents.some(torrent => this.state.selectedTorrents.has(torrent.hash)) && - !visibleTorrents.every(torrent => this.state.selectedTorrents.has(torrent.hash)); + visibleTorrents.every(torrent => this.state.selectedItems.has(torrent.hash)); + this.refs.selectAll.indeterminate = visibleTorrents.some(torrent => this.state.selectedItems.has(torrent.hash)) && + !visibleTorrents.every(torrent => this.state.selectedItems.has(torrent.hash)); } toggleEmptyState() { - const isEmpty = this.state.torrents.length === 0; - this.refs.emptyState.classList.toggle('hidden', !isEmpty); - document.querySelector('.card:has(#torrentsList)').classList.toggle('hidden', isEmpty); + const items = this.state.mode === 'torrents' ? this.state.torrents : this.state.nzbs; + const isEmpty = items.length === 0; + + if (this.refs.emptyState) { + this.refs.emptyState.classList.toggle('hidden', !isEmpty); + } + + // Find the main data table card and toggle its visibility + const dataTableCard = document.querySelector('.card:has(#dataList)'); + if (dataTableCard) { + dataTableCard.classList.toggle('hidden', isEmpty); + } } // Event handlers @@ -459,29 +926,30 @@ class TorrentDashboard { } toggleSelectAll(checked) { - const visibleTorrents = this.state.filteredTorrents.slice( + const visibleTorrents = this.state.filteredItems.slice( (this.state.currentPage - 1) * this.state.itemsPerPage, this.state.currentPage * this.state.itemsPerPage ); visibleTorrents.forEach(torrent => { if (checked) { - this.state.selectedTorrents.add(torrent.hash); + this.state.selectedItems.add(torrent.hash); } else { - this.state.selectedTorrents.delete(torrent.hash); + this.state.selectedItems.delete(torrent.hash); } }); this.updateUI(); } - toggleTorrentSelection(hash, checked) { + toggleItemSelection(id, checked) { if (checked) { - this.state.selectedTorrents.add(hash); + this.state.selectedItems.add(id); } else { - this.state.selectedTorrents.delete(hash); + this.state.selectedItems.delete(id); } this.updateSelectionUI(); + this.updateBatchActions(); } async deleteTorrent(hash, category, removeFromDebrid = false) { @@ -504,38 +972,55 @@ class TorrentDashboard { } } - async deleteSelectedTorrents(removeFromDebrid = false) { - const count = this.state.selectedTorrents.size; + async deleteSelectedItems(removeFromDebrid = false) { + const count = this.state.selectedItems.size; if (count === 0) { - window.decypharrUtils.createToast('No torrents selected for deletion', 'warning'); + const itemType = this.state.mode === 'torrents' ? 'torrents' : 'NZBs'; + window.decypharrUtils.createToast(`No ${itemType} selected for deletion`, 'warning'); return; } - if (!confirm(`Are you sure you want to delete ${count} torrent${count > 1 ? 's' : ''}${removeFromDebrid ? ' from debrid' : ''}?`)) { + + const itemType = this.state.mode === 'torrents' ? 'torrent' : 'NZB'; + const itemTypePlural = this.state.mode === 'torrents' ? 'torrents' : 'NZBs'; + + if (!confirm(`Are you sure you want to delete ${count} ${count > 1 ? itemTypePlural : itemType}${removeFromDebrid ? ' from debrid' : ''}?`)) { return; } try { - const hashes = Array.from(this.state.selectedTorrents).join(','); - const response = await window.decypharrUtils.fetcher( - `/api/torrents/?hashes=${encodeURIComponent(hashes)}&removeFromDebrid=${removeFromDebrid}`, - { method: 'DELETE' } - ); + if (this.state.mode === 'torrents') { + const hashes = Array.from(this.state.selectedItems).join(','); + const response = await window.decypharrUtils.fetcher( + `/api/torrents/?hashes=${encodeURIComponent(hashes)}&removeFromDebrid=${removeFromDebrid}`, + { method: 'DELETE' } + ); - if (!response.ok) throw new Error(await response.text()); + if (!response.ok) throw new Error(await response.text()); + } else { + // Delete NZBs one by one + const promises = Array.from(this.state.selectedItems).map(id => + window.decypharrUtils.fetcher(`/api/nzbs/${id}`, { method: 'DELETE' }) + ); + const responses = await Promise.all(promises); + + for (const response of responses) { + if (!response.ok) throw new Error(await response.text()); + } + } - window.decypharrUtils.createToast(`${count} torrent${count > 1 ? 's' : ''} deleted successfully`); - this.state.selectedTorrents.clear(); - await this.loadTorrents(); + window.decypharrUtils.createToast(`${count} ${count > 1 ? itemTypePlural : itemType} deleted successfully`); + this.state.selectedItems.clear(); + await this.loadData(); } catch (error) { - console.error('Error deleting torrents:', error); - window.decypharrUtils.createToast(`Failed to delete some torrents: ${error.message}`, 'error'); + console.error(`Error deleting ${itemTypePlural}:`, error); + window.decypharrUtils.createToast(`Failed to delete some ${itemTypePlural}: ${error.message}`, 'error'); } } startAutoRefresh() { this.refreshInterval = setInterval(() => { - this.loadTorrents(); + this.loadData(); }, 5000); // Clean up on page unload @@ -556,4 +1041,54 @@ class TorrentDashboard { }; return text ? text.replace(/[&<>"']/g, (m) => map[m]) : ''; } + + loadModeFromURL() { + const urlParams = new URLSearchParams(window.location.search); + const mode = urlParams.get('mode'); + + if (mode === 'nzbs' || mode === 'torrents') { + this.state.mode = mode; + } else { + this.state.mode = 'torrents'; // Default mode + } + + // Set the initial UI state without triggering reload + this.setModeUI(this.state.mode); + } + + setModeUI(mode) { + if (mode === 'torrents') { + this.refs.torrentsMode.classList.remove('btn-outline'); + this.refs.torrentsMode.classList.add('btn-primary'); + this.refs.nzbsMode.classList.remove('btn-primary'); + this.refs.nzbsMode.classList.add('btn-outline'); + + this.refs.torrentsHeaders.classList.remove('hidden'); + this.refs.nzbsHeaders.classList.add('hidden'); + + this.refs.emptyStateTitle.textContent = 'No Torrents Found'; + this.refs.emptyStateMessage.textContent = "You haven't added any torrents yet. Start by adding your first download!"; + + this.refs.batchDeleteDebridBtn.classList.remove('hidden'); + } else { + this.refs.nzbsMode.classList.remove('btn-outline'); + this.refs.nzbsMode.classList.add('btn-primary'); + this.refs.torrentsMode.classList.remove('btn-primary'); + this.refs.torrentsMode.classList.add('btn-outline'); + + this.refs.nzbsHeaders.classList.remove('hidden'); + this.refs.torrentsHeaders.classList.add('hidden'); + + this.refs.emptyStateTitle.textContent = 'No NZBs Found'; + this.refs.emptyStateMessage.textContent = "You haven't added any NZB downloads yet. Start by adding your first NZB!"; + + this.refs.batchDeleteDebridBtn.classList.add('hidden'); + } + } + + updateURL(mode) { + const url = new URL(window.location); + url.searchParams.set('mode', mode); + window.history.replaceState({}, '', url); + } } \ No newline at end of file diff --git a/pkg/web/assets/js/download.js b/pkg/web/assets/js/download.js index c3d1663..a3f8783 100644 --- a/pkg/web/assets/js/download.js +++ b/pkg/web/assets/js/download.js @@ -2,16 +2,29 @@ class DownloadManager { constructor(downloadFolder) { this.downloadFolder = downloadFolder; + this.currentMode = 'torrent'; // Default mode this.refs = { downloadForm: document.getElementById('downloadForm'), + // Mode controls + torrentMode: document.getElementById('torrentMode'), + nzbMode: document.getElementById('nzbMode'), + // Torrent inputs magnetURI: document.getElementById('magnetURI'), torrentFiles: document.getElementById('torrentFiles'), + torrentInputs: document.getElementById('torrentInputs'), + // NZB inputs + nzbURLs: document.getElementById('nzbURLs'), + nzbFiles: document.getElementById('nzbFiles'), + nzbInputs: document.getElementById('nzbInputs'), + // Common form elements arr: document.getElementById('arr'), downloadAction: document.getElementById('downloadAction'), downloadUncached: document.getElementById('downloadUncached'), downloadFolder: document.getElementById('downloadFolder'), + downloadFolderHint: document.getElementById('downloadFolderHint'), debrid: document.getElementById('debrid'), submitBtn: document.getElementById('submitDownload'), + submitButtonText: document.getElementById('submitButtonText'), activeCount: document.getElementById('activeCount'), completedCount: document.getElementById('completedCount'), totalSize: document.getElementById('totalSize') @@ -24,12 +37,17 @@ class DownloadManager { this.loadSavedOptions(); this.bindEvents(); this.handleMagnetFromURL(); + this.loadModeFromURL(); } bindEvents() { // Form submission this.refs.downloadForm.addEventListener('submit', (e) => this.handleSubmit(e)); + // Mode switching + this.refs.torrentMode.addEventListener('click', () => this.switchMode('torrent')); + this.refs.nzbMode.addEventListener('click', () => this.switchMode('nzb')); + // Save options on change this.refs.arr.addEventListener('change', () => this.saveOptions()); this.refs.downloadAction.addEventListener('change', () => this.saveOptions()); @@ -38,6 +56,7 @@ class DownloadManager { // File input enhancement this.refs.torrentFiles.addEventListener('change', (e) => this.handleFileSelection(e)); + this.refs.nzbFiles.addEventListener('change', (e) => this.handleFileSelection(e)); // Drag and drop this.setupDragAndDrop(); @@ -48,13 +67,15 @@ class DownloadManager { category: localStorage.getItem('downloadCategory') || '', action: localStorage.getItem('downloadAction') || 'symlink', uncached: localStorage.getItem('downloadUncached') === 'true', - folder: localStorage.getItem('downloadFolder') || this.downloadFolder + folder: localStorage.getItem('downloadFolder') || this.downloadFolder, + mode: localStorage.getItem('downloadMode') || 'torrent' }; this.refs.arr.value = savedOptions.category; this.refs.downloadAction.value = savedOptions.action; this.refs.downloadUncached.checked = savedOptions.uncached; this.refs.downloadFolder.value = savedOptions.folder; + this.currentMode = savedOptions.mode; } saveOptions() { @@ -62,6 +83,7 @@ class DownloadManager { localStorage.setItem('downloadAction', this.refs.downloadAction.value); localStorage.setItem('downloadUncached', this.refs.downloadUncached.checked.toString()); localStorage.setItem('downloadFolder', this.refs.downloadFolder.value); + localStorage.setItem('downloadMode', this.currentMode); } handleMagnetFromURL() { @@ -81,31 +103,57 @@ class DownloadManager { e.preventDefault(); const formData = new FormData(); + let urls = []; + let files = []; + let endpoint = '/api/add'; + let itemType = 'torrent'; - // Get URLs - const urls = this.refs.magnetURI.value - .split('\n') - .map(url => url.trim()) - .filter(url => url.length > 0); + if (this.currentMode === 'torrent') { + // Get torrent URLs + urls = this.refs.magnetURI.value + .split('\n') + .map(url => url.trim()) + .filter(url => url.length > 0); - if (urls.length > 0) { - formData.append('urls', urls.join('\n')); - } + if (urls.length > 0) { + formData.append('urls', urls.join('\n')); + } - // Get files - for (let i = 0; i < this.refs.torrentFiles.files.length; i++) { - formData.append('files', this.refs.torrentFiles.files[i]); + // Get torrent files + for (let i = 0; i < this.refs.torrentFiles.files.length; i++) { + formData.append('files', this.refs.torrentFiles.files[i]); + files.push(this.refs.torrentFiles.files[i]); + } + } else if (this.currentMode === 'nzb') { + // Get NZB URLs + urls = this.refs.nzbURLs.value + .split('\n') + .map(url => url.trim()) + .filter(url => url.length > 0); + + if (urls.length > 0) { + formData.append('nzbUrls', urls.join('\n')); + } + + // Get NZB files + for (let i = 0; i < this.refs.nzbFiles.files.length; i++) { + formData.append('nzbFiles', this.refs.nzbFiles.files[i]); + files.push(this.refs.nzbFiles.files[i]); + } + + endpoint = '/api/nzbs/add'; + itemType = 'NZB'; } // Validation - const totalItems = urls.length + this.refs.torrentFiles.files.length; + const totalItems = urls.length + files.length; if (totalItems === 0) { - window.decypharrUtils.createToast('Please provide at least one torrent', 'warning'); + window.decypharrUtils.createToast(`Please provide at least one ${itemType}`, 'warning'); return; } if (totalItems > 100) { - window.decypharrUtils.createToast('Please submit up to 100 torrents at a time', 'warning'); + window.decypharrUtils.createToast(`Please submit up to 100 ${itemType}s at a time`, 'warning'); return; } @@ -123,7 +171,7 @@ class DownloadManager { // Set loading state window.decypharrUtils.setButtonLoading(this.refs.submitBtn, true); - const response = await window.decypharrUtils.fetcher('/api/add', { + const response = await window.decypharrUtils.fetcher(endpoint, { method: 'POST', body: formData, headers: {} // Remove Content-Type to let browser set it for FormData @@ -137,19 +185,19 @@ class DownloadManager { // Handle partial success if (result.errors && result.errors.length > 0) { + console.log(result.errors); + let errorMessage = ` ${result.errors.join('\n')}`; if (result.results.length > 0) { window.decypharrUtils.createToast( - `Added ${result.results.length} torrents with ${result.errors.length} errors`, + `Added ${result.results.length} ${itemType}s with ${result.errors.length} errors \n${errorMessage}`, 'warning' ); - this.showErrorDetails(result.errors); } else { - window.decypharrUtils.createToast('Failed to add torrents', 'error'); - this.showErrorDetails(result.errors); + window.decypharrUtils.createToast(`Failed to add ${itemType}s \n${errorMessage}`, 'error'); } } else { window.decypharrUtils.createToast( - `Successfully added ${result.results.length} torrent${result.results.length > 1 ? 's' : ''}!` + `Successfully added ${result.results.length} ${itemType}${result.results.length > 1 ? 's' : ''}!` ); this.clearForm(); } @@ -162,22 +210,49 @@ class DownloadManager { } } - showErrorDetails(errors) { - // Create a modal or detailed view for errors - const errorList = errors.map(error => `• ${error}`).join('\n'); - console.error('Download errors:', errorList); + switchMode(mode) { + this.currentMode = mode; + this.saveOptions(); + this.updateURL(mode); - // You could also show this in a modal for better UX - setTimeout(() => { - if (confirm('Some torrents failed to add. Would you like to see the details?')) { - alert(errorList); - } - }, 1000); + // Update button states + if (mode === 'torrent') { + this.refs.torrentMode.classList.remove('btn-outline'); + this.refs.torrentMode.classList.add('btn-primary'); + this.refs.nzbMode.classList.remove('btn-primary'); + this.refs.nzbMode.classList.add('btn-outline'); + + // Show/hide sections + this.refs.torrentInputs.classList.remove('hidden'); + this.refs.nzbInputs.classList.add('hidden'); + + // Update UI text + this.refs.submitButtonText.textContent = 'Add to Download Queue'; + this.refs.downloadFolderHint.textContent = 'Leave empty to use default qBittorrent folder'; + } else { + this.refs.nzbMode.classList.remove('btn-outline'); + this.refs.nzbMode.classList.add('btn-primary'); + this.refs.torrentMode.classList.remove('btn-primary'); + this.refs.torrentMode.classList.add('btn-outline'); + + // Show/hide sections + this.refs.nzbInputs.classList.remove('hidden'); + this.refs.torrentInputs.classList.add('hidden'); + + // Update UI text + this.refs.submitButtonText.textContent = 'Add to NZB Queue'; + this.refs.downloadFolderHint.textContent = 'Leave empty to use default SABnzbd folder'; + } } clearForm() { - this.refs.magnetURI.value = ''; - this.refs.torrentFiles.value = ''; + if (this.currentMode === 'torrent') { + this.refs.magnetURI.value = ''; + this.refs.torrentFiles.value = ''; + } else { + this.refs.nzbURLs.value = ''; + this.refs.nzbFiles.value = ''; + } } handleFileSelection(e) { @@ -226,20 +301,84 @@ class DownloadManager { const dt = e.dataTransfer; const files = dt.files; - // Filter for .torrent files - const torrentFiles = Array.from(files).filter(file => - file.name.toLowerCase().endsWith('.torrent') - ); + if (this.currentMode === 'torrent') { + // Filter for .torrent files + const torrentFiles = Array.from(files).filter(file => + file.name.toLowerCase().endsWith('.torrent') + ); - if (torrentFiles.length > 0) { - // Create a new FileList-like object - const dataTransfer = new DataTransfer(); - torrentFiles.forEach(file => dataTransfer.items.add(file)); - this.refs.torrentFiles.files = dataTransfer.files; + if (torrentFiles.length > 0) { + // Create a new FileList-like object + const dataTransfer = new DataTransfer(); + torrentFiles.forEach(file => dataTransfer.items.add(file)); + this.refs.torrentFiles.files = dataTransfer.files; - this.handleFileSelection({ target: { files: torrentFiles } }); + this.handleFileSelection({ target: { files: torrentFiles } }); + } else { + window.decypharrUtils.createToast('Please drop .torrent files only', 'warning'); + } } else { - window.decypharrUtils.createToast('Please drop .torrent files only', 'warning'); + // Filter for .nzb files + const nzbFiles = Array.from(files).filter(file => + file.name.toLowerCase().endsWith('.nzb') + ); + + if (nzbFiles.length > 0) { + // Create a new FileList-like object + const dataTransfer = new DataTransfer(); + nzbFiles.forEach(file => dataTransfer.items.add(file)); + this.refs.nzbFiles.files = dataTransfer.files; + + this.handleFileSelection({ target: { files: nzbFiles } }); + } else { + window.decypharrUtils.createToast('Please drop .nzb files only', 'warning'); + } } } + + loadModeFromURL() { + const urlParams = new URLSearchParams(window.location.search); + const mode = urlParams.get('mode'); + + if (mode === 'nzb' || mode === 'torrent') { + this.currentMode = mode; + } else { + this.currentMode = this.currentMode || 'torrent'; // Use saved preference or default + } + + // Initialize the mode without updating URL again + this.setModeUI(this.currentMode); + } + + setModeUI(mode) { + if (mode === 'torrent') { + this.refs.torrentMode.classList.remove('btn-outline'); + this.refs.torrentMode.classList.add('btn-primary'); + this.refs.nzbMode.classList.remove('btn-primary'); + this.refs.nzbMode.classList.add('btn-outline'); + + this.refs.torrentInputs.classList.remove('hidden'); + this.refs.nzbInputs.classList.add('hidden'); + + this.refs.submitButtonText.textContent = 'Add to Download Queue'; + this.refs.downloadFolderHint.textContent = 'Leave empty to use default qBittorrent folder'; + } else { + this.refs.nzbMode.classList.remove('btn-outline'); + this.refs.nzbMode.classList.add('btn-primary'); + this.refs.torrentMode.classList.remove('btn-primary'); + this.refs.torrentMode.classList.add('btn-outline'); + + this.refs.nzbInputs.classList.remove('hidden'); + this.refs.torrentInputs.classList.add('hidden'); + + this.refs.submitButtonText.textContent = 'Add to NZB Queue'; + this.refs.downloadFolderHint.textContent = 'Leave empty to use default SABnzbd folder'; + } + } + + updateURL(mode) { + const url = new URL(window.location); + url.searchParams.set('mode', mode); + window.history.replaceState({}, '', url); + } } \ No newline at end of file diff --git a/pkg/web/api.go b/pkg/web/handlers.go similarity index 65% rename from pkg/web/api.go rename to pkg/web/handlers.go index 99f69af..7d8c5a6 100644 --- a/pkg/web/api.go +++ b/pkg/web/handlers.go @@ -3,8 +3,12 @@ package web import ( "fmt" "github.com/sirrobot01/decypharr/pkg/store" + "github.com/sirrobot01/decypharr/pkg/usenet" + "io" + "mime/multipart" "net/http" "strings" + "sync" "time" "encoding/json" @@ -28,6 +32,7 @@ func (wb *Web) handleAddContent(w http.ResponseWriter, r *http.Request) { return } _store := store.Get() + cfg := config.Get() results := make([]*store.ImportRequest, 0) errs := make([]string, 0) @@ -37,8 +42,8 @@ func (wb *Web) handleAddContent(w http.ResponseWriter, r *http.Request) { debridName := r.FormValue("debrid") callbackUrl := r.FormValue("callbackUrl") downloadFolder := r.FormValue("downloadFolder") - if downloadFolder == "" { - downloadFolder = config.Get().QBitTorrent.DownloadFolder + if downloadFolder == "" && cfg.QBitTorrent != nil { + downloadFolder = cfg.QBitTorrent.DownloadFolder } downloadUncached := r.FormValue("downloadUncached") == "true" @@ -236,8 +241,6 @@ func (wb *Web) handleUpdateConfig(w http.ResponseWriter, r *http.Request) { currentConfig.RemoveStalledAfter = updatedConfig.RemoveStalledAfter currentConfig.AllowedExt = updatedConfig.AllowedExt currentConfig.DiscordWebhook = updatedConfig.DiscordWebhook - - // Should this be added? currentConfig.URLBase = updatedConfig.URLBase currentConfig.BindAddress = updatedConfig.BindAddress currentConfig.Port = updatedConfig.Port @@ -251,9 +254,11 @@ func (wb *Web) handleUpdateConfig(w http.ResponseWriter, r *http.Request) { // Update Debrids if len(updatedConfig.Debrids) > 0 { currentConfig.Debrids = updatedConfig.Debrids - // Clear legacy single debrid if using array } + currentConfig.Usenet = updatedConfig.Usenet + currentConfig.SABnzbd = updatedConfig.SABnzbd + // Update Arrs through the service storage := store.Get() arrStorage := storage.Arr() @@ -359,3 +364,198 @@ func (wb *Web) handleStopRepairJob(w http.ResponseWriter, r *http.Request) { } w.WriteHeader(http.StatusOK) } + +// NZB API Handlers + +func (wb *Web) handleGetNZBs(w http.ResponseWriter, r *http.Request) { + // Get query parameters for filtering + status := r.URL.Query().Get("status") + category := r.URL.Query().Get("category") + nzbs := wb.usenet.Store().GetQueue() + + // Apply filters if provided + filteredNZBs := make([]*usenet.NZB, 0) + for _, nzb := range nzbs { + if status != "" && nzb.Status != status { + continue + } + if category != "" && nzb.Category != category { + continue + } + filteredNZBs = append(filteredNZBs, nzb) + } + + response := map[string]interface{}{ + "nzbs": filteredNZBs, + "count": len(filteredNZBs), + } + + request.JSONResponse(w, response, http.StatusOK) +} + +func (wb *Web) handleDeleteNZB(w http.ResponseWriter, r *http.Request) { + nzbID := chi.URLParam(r, "id") + if nzbID == "" { + http.Error(w, "No NZB ID provided", http.StatusBadRequest) + return + } + wb.usenet.Store().RemoveFromQueue(nzbID) + + wb.logger.Info().Str("nzb_id", nzbID).Msg("NZB delete requested") + request.JSONResponse(w, map[string]string{"status": "success"}, http.StatusOK) +} + +func (wb *Web) handleAddNZBContent(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + cfg := config.Get() + _store := store.Get() + if err := r.ParseMultipartForm(32 << 20); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + results := make([]interface{}, 0) + errs := make([]string, 0) + + arrName := r.FormValue("arr") + action := r.FormValue("action") + downloadFolder := r.FormValue("downloadFolder") + if downloadFolder == "" { + downloadFolder = cfg.SABnzbd.DownloadFolder + } + + _arr := _store.Arr().Get(arrName) + if _arr == nil { + // These are not found in the config. They are throwaway arrs. + _arr = arr.New(arrName, "", "", false, false, nil, "", "") + } + _nzbURLS := r.FormValue("nzbUrls") + urlList := make([]string, 0) + if _nzbURLS != "" { + for _, u := range strings.Split(_nzbURLS, "\n") { + if trimmed := strings.TrimSpace(u); trimmed != "" { + urlList = append(urlList, trimmed) + } + } + } + files := r.MultipartForm.File["nzbFiles"] + totalItems := len(files) + len(urlList) + if totalItems == 0 { + request.JSONResponse(w, map[string]any{ + "results": nil, + "errors": "No NZB URLs or files provided", + }, http.StatusBadRequest) + return + } + + var wg sync.WaitGroup + for _, url := range urlList { + wg.Add(1) + go func(url string) { + defer wg.Done() + select { + case <-ctx.Done(): + return // Exit if context is done + default: + } + if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") { + errs = append(errs, fmt.Sprintf("Invalid URL format: %s", url)) + return + } + // Download the NZB file from the URL + filename, content, err := utils.DownloadFile(url) + if err != nil { + wb.logger.Error().Err(err).Str("url", url).Msg("Failed to download NZB from URL") + errs = append(errs, fmt.Sprintf("Failed to download NZB from URL %s: %v", url, err)) + return // Continue processing other URLs + } + req := &usenet.ProcessRequest{ + NZBContent: content, + Name: filename, + Arr: _arr, + Action: action, + DownloadDir: downloadFolder, + } + nzb, err := wb.usenet.ProcessNZB(ctx, req) + if err != nil { + errs = append(errs, fmt.Sprintf("Failed to process NZB from URL %s: %v", url, err)) + return + } + wb.logger.Info().Str("nzb_id", nzb.ID).Str("url", url).Msg("NZB added from URL") + + result := map[string]interface{}{ + "id": nzb.ID, + "name": "NZB from URL", + "url": url, + "category": arrName, + } + results = append(results, result) + }(url) + } + + // Handle NZB files + for _, fileHeader := range files { + wg.Add(1) + go func(fileHeader *multipart.FileHeader) { + defer wg.Done() + select { + case <-ctx.Done(): + return + default: + } + file, err := fileHeader.Open() + if err != nil { + errs = append(errs, fmt.Sprintf("failed to open NZB file %s: %v", fileHeader.Filename, err)) + return + } + defer file.Close() + + content, err := io.ReadAll(file) + if err != nil { + errs = append(errs, fmt.Sprintf("failed to read NZB file %s: %v", fileHeader.Filename, err)) + return + } + req := &usenet.ProcessRequest{ + NZBContent: content, + Name: fileHeader.Filename, + Arr: _arr, + Action: action, + DownloadDir: downloadFolder, + } + nzb, err := wb.usenet.ProcessNZB(ctx, req) + if err != nil { + errs = append(errs, fmt.Sprintf("failed to process NZB file %s: %v", fileHeader.Filename, err)) + return + } + wb.logger.Info().Str("nzb_id", nzb.ID).Str("file", fileHeader.Filename).Msg("NZB added from file") + // Simulate successful addition + result := map[string]interface{}{ + "id": nzb.ID, + "name": fileHeader.Filename, + "filename": fileHeader.Filename, + "category": arrName, + } + results = append(results, result) + }(fileHeader) + } + + // Wait for all goroutines to finish + wg.Wait() + + // Validation + if len(results) == 0 && len(errs) == 0 { + request.JSONResponse(w, map[string]any{ + "results": nil, + "errors": "No NZB URLs or files processed successfully", + }, http.StatusBadRequest) + return + } + + request.JSONResponse(w, struct { + Results []interface{} `json:"results"` + Errors []string `json:"errors,omitempty"` + }{ + Results: results, + Errors: errs, + }, http.StatusOK) +} diff --git a/pkg/web/routes.go b/pkg/web/routes.go index b7dd393..15fa935 100644 --- a/pkg/web/routes.go +++ b/pkg/web/routes.go @@ -47,6 +47,9 @@ func (wb *Web) Routes() http.Handler { r.Get("/torrents", wb.handleGetTorrents) r.Delete("/torrents/{category}/{hash}", wb.handleDeleteTorrent) r.Delete("/torrents/", wb.handleDeleteTorrents) + r.Get("/nzbs", wb.handleGetNZBs) + r.Post("/nzbs/add", wb.handleAddNZBContent) + r.Delete("/nzbs/{id}", wb.handleDeleteNZB) r.Get("/config", wb.handleGetConfig) r.Post("/config", wb.handleUpdateConfig) }) diff --git a/pkg/web/templates/config.html b/pkg/web/templates/config.html index 87d8785..1c1d30a 100644 --- a/pkg/web/templates/config.html +++ b/pkg/web/templates/config.html @@ -24,6 +24,14 @@ + + + + + + + + + + + +
    +

    + Usenet Servers +

    + +
    + +
    + +
    + + + + + + diff --git a/pkg/web/templates/download.html b/pkg/web/templates/download.html index 58f5a7c..1ee792f 100644 --- a/pkg/web/templates/download.html +++ b/pkg/web/templates/download.html @@ -4,8 +4,20 @@
    + +
    +
    + + +
    +
    + -
    +
    + + +
    @@ -75,7 +125,7 @@ name="downloadFolder" placeholder="/downloads/torrents">
    - Leave empty to use default qBittorrent folder + Leave empty to use default qBittorrent folder
    @@ -131,7 +181,7 @@
    diff --git a/pkg/web/templates/index.html b/pkg/web/templates/index.html index 885f84f..6369eb3 100644 --- a/pkg/web/templates/index.html +++ b/pkg/web/templates/index.html @@ -4,6 +4,18 @@
    + +
    +
    + + +
    +
    +
    @@ -47,12 +59,13 @@
    - +
    - + + - + + + + + + + + + + + + + + + + +
    Actions
    @@ -95,7 +142,7 @@
    - Loading torrents... + Loading data...
    @@ -108,8 +155,8 @@
    -

    No Torrents Found

    -

    You haven't added any torrents yet. Start by adding your first download!

    +

    No Data Found

    +

    No downloads found.

    Add New Download @@ -117,7 +164,7 @@
    - + + + + {{ end }} \ No newline at end of file diff --git a/pkg/web/ui.go b/pkg/web/ui.go index 9ce2009..f6992fa 100644 --- a/pkg/web/ui.go +++ b/pkg/web/ui.go @@ -126,13 +126,17 @@ func (wb *Web) DownloadHandler(w http.ResponseWriter, r *http.Request) { for _, d := range cfg.Debrids { debrids = append(debrids, d.Name) } + downloadFolder := "" + if cfg.QBitTorrent != nil { + downloadFolder = cfg.QBitTorrent.DownloadFolder + } data := map[string]interface{}{ "URLBase": cfg.URLBase, "Page": "download", "Title": "Download", "Debrids": debrids, "HasMultiDebrid": len(debrids) > 1, - "DownloadFolder": cfg.QBitTorrent.DownloadFolder, + "DownloadFolder": downloadFolder, } _ = wb.templates.ExecuteTemplate(w, "layout", data) } diff --git a/pkg/web/web.go b/pkg/web/web.go index f766621..d74a9b1 100644 --- a/pkg/web/web.go +++ b/pkg/web/web.go @@ -7,6 +7,7 @@ import ( "github.com/rs/zerolog" "github.com/sirrobot01/decypharr/internal/logger" "github.com/sirrobot01/decypharr/pkg/store" + "github.com/sirrobot01/decypharr/pkg/usenet" "html/template" "os" ) @@ -61,9 +62,10 @@ type Web struct { cookie *sessions.CookieStore templates *template.Template torrents *store.TorrentStorage + usenet usenet.Usenet } -func New() *Web { +func New(usenet usenet.Usenet) *Web { templates := template.Must(template.ParseFS( content, "templates/layout.html", @@ -86,5 +88,6 @@ func New() *Web { templates: templates, cookie: cookieStore, torrents: store.Get().Torrents(), + usenet: usenet, } } diff --git a/pkg/webdav/file.go b/pkg/webdav/file.go index 7444855..1e3315b 100644 --- a/pkg/webdav/file.go +++ b/pkg/webdav/file.go @@ -1,472 +1,8 @@ package webdav -import ( - "crypto/tls" - "fmt" - "io" - "net/http" - "os" - "strings" - "time" - - "github.com/sirrobot01/decypharr/pkg/debrid/store" -) - -var streamingTransport = &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, - MaxIdleConns: 200, - MaxIdleConnsPerHost: 100, - MaxConnsPerHost: 200, - IdleConnTimeout: 90 * time.Second, - TLSHandshakeTimeout: 10 * time.Second, - ResponseHeaderTimeout: 60 * time.Second, // give the upstream a minute to send headers - ExpectContinueTimeout: 1 * time.Second, - DisableKeepAlives: true, // close after each request - ForceAttemptHTTP2: false, // don’t speak HTTP/2 - // this line is what truly blocks HTTP/2: - TLSNextProto: make(map[string]func(string, *tls.Conn) http.RoundTripper), -} - -var sharedClient = &http.Client{ - Transport: streamingTransport, - Timeout: 0, -} - -type streamError struct { - Err error - StatusCode int - IsClientDisconnection bool -} - -func (e *streamError) Error() string { - return e.Err.Error() -} - -func (e *streamError) Unwrap() error { - return e.Err -} - -type File struct { - name string - torrentName string - link string - downloadLink string - size int64 - isDir bool - fileId string - isRar bool - metadataOnly bool - content []byte - children []os.FileInfo // For directories - cache *store.Cache - modTime time.Time - - // Minimal state for interface compliance only - readOffset int64 // Only used for Read() method compliance -} - -// File interface implementations for File - -func (f *File) Close() error { - if f.isDir { - return nil // No resources to close for directories - } - - // For files, we don't have any resources to close either - // This is just to satisfy the os.File interface - f.content = nil - f.children = nil - f.downloadLink = "" - f.readOffset = 0 - return nil -} - -func (f *File) getDownloadLink() (string, error) { - // Check if we already have a final URL cached - - if f.downloadLink != "" && isValidURL(f.downloadLink) { - return f.downloadLink, nil - } - downloadLink, err := f.cache.GetDownloadLink(f.torrentName, f.name, f.link) - if err != nil { - return "", err - } - if downloadLink != "" && isValidURL(downloadLink) { - f.downloadLink = downloadLink - return downloadLink, nil - } - return "", os.ErrNotExist -} - -func (f *File) getDownloadByteRange() (*[2]int64, error) { - byteRange, err := f.cache.GetDownloadByteRange(f.torrentName, f.name) - if err != nil { - return nil, err - } - return byteRange, nil -} - -func (f *File) servePreloadedContent(w http.ResponseWriter, r *http.Request) error { - content := f.content - size := int64(len(content)) - - // Handle range requests for preloaded content - if rangeHeader := r.Header.Get("Range"); rangeHeader != "" { - ranges, err := parseRange(rangeHeader, size) - if err != nil || len(ranges) != 1 { - w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", size)) - return &streamError{Err: fmt.Errorf("invalid range"), StatusCode: http.StatusRequestedRangeNotSatisfiable} - } - - start, end := ranges[0].start, ranges[0].end - w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, size)) - w.Header().Set("Content-Length", fmt.Sprintf("%d", end-start+1)) - w.Header().Set("Accept-Ranges", "bytes") - w.WriteHeader(http.StatusPartialContent) - - _, err = w.Write(content[start : end+1]) - return err - } - - // Full content - w.Header().Set("Content-Length", fmt.Sprintf("%d", size)) - w.Header().Set("Accept-Ranges", "bytes") - w.WriteHeader(http.StatusOK) - - _, err := w.Write(content) - return err -} - -func (f *File) StreamResponse(w http.ResponseWriter, r *http.Request) error { - // Handle preloaded content files - if f.content != nil { - return f.servePreloadedContent(w, r) - } - - // Try streaming with retry logic - return f.streamWithRetry(w, r, 0) -} - -func (f *File) streamWithRetry(w http.ResponseWriter, r *http.Request, retryCount int) error { - const maxRetries = 3 - _log := f.cache.Logger() - - // Get download link (with caching optimization) - downloadLink, err := f.getDownloadLink() - if err != nil { - return &streamError{Err: err, StatusCode: http.StatusPreconditionFailed} - } - - if downloadLink == "" { - return &streamError{Err: fmt.Errorf("empty download link"), StatusCode: http.StatusNotFound} - } - - // Create upstream request with streaming optimizations - upstreamReq, err := http.NewRequest("GET", downloadLink, nil) - if err != nil { - return &streamError{Err: err, StatusCode: http.StatusInternalServerError} - } - - setVideoStreamingHeaders(upstreamReq) - - // Handle range requests (critical for video seeking) - isRangeRequest := f.handleRangeRequest(upstreamReq, r, w) - if isRangeRequest == -1 { - return &streamError{Err: fmt.Errorf("invalid range"), StatusCode: http.StatusRequestedRangeNotSatisfiable} - } - - resp, err := sharedClient.Do(upstreamReq) - if err != nil { - return &streamError{Err: err, StatusCode: http.StatusServiceUnavailable} - } - defer resp.Body.Close() - - // Handle upstream errors with retry logic - shouldRetry, retryErr := f.handleUpstream(resp, retryCount, maxRetries) - if shouldRetry && retryCount < maxRetries { - // Retry with new download link - _log.Debug(). - Int("retry_count", retryCount+1). - Str("file", f.name). - Msg("Retrying stream request") - return f.streamWithRetry(w, r, retryCount+1) - } - if retryErr != nil { - return retryErr - } - - setVideoResponseHeaders(w, resp, isRangeRequest == 1) - - return f.streamBuffer(w, resp.Body) -} - -func (f *File) streamBuffer(w http.ResponseWriter, src io.Reader) error { - flusher, ok := w.(http.Flusher) - if !ok { - return fmt.Errorf("response does not support flushing") - } - - smallBuf := make([]byte, 64*1024) // 64 KB - if n, err := src.Read(smallBuf); n > 0 { - if _, werr := w.Write(smallBuf[:n]); werr != nil { - return werr - } - flusher.Flush() - } else if err != nil && err != io.EOF { - return err - } - - buf := make([]byte, 256*1024) // 256 KB - for { - n, readErr := src.Read(buf) - if n > 0 { - if _, writeErr := w.Write(buf[:n]); writeErr != nil { - if isClientDisconnection(writeErr) { - return &streamError{Err: writeErr, StatusCode: 0, IsClientDisconnection: true} - } - return writeErr - } - flusher.Flush() - } - if readErr != nil { - if readErr == io.EOF { - return nil - } - if isClientDisconnection(readErr) { - return &streamError{Err: readErr, StatusCode: 0, IsClientDisconnection: true} - } - return readErr - } - } -} - -func (f *File) handleUpstream(resp *http.Response, retryCount, maxRetries int) (shouldRetry bool, err error) { - if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusPartialContent { - return false, nil - } - - _log := f.cache.Logger() - - // Clean up response body properly - cleanupResp := func(resp *http.Response) { - if resp.Body != nil { - _, _ = io.Copy(io.Discard, resp.Body) - resp.Body.Close() - } - } - - switch resp.StatusCode { - case http.StatusServiceUnavailable: - // Read the body to check for specific error messages - body, readErr := io.ReadAll(resp.Body) - cleanupResp(resp) - - if readErr != nil { - _log.Error().Err(readErr).Msg("Failed to read response body") - return false, &streamError{ - Err: fmt.Errorf("failed to read error response: %w", readErr), - StatusCode: http.StatusServiceUnavailable, - } - } - - bodyStr := string(body) - if strings.Contains(bodyStr, "you have exceeded your traffic") { - _log.Debug(). - Str("file", f.name). - Int("retry_count", retryCount). - Msg("Bandwidth exceeded. Marking link as invalid") - - f.cache.MarkDownloadLinkAsInvalid(f.link, f.downloadLink, "bandwidth_exceeded") - - // Retry with a different API key if available and we haven't exceeded retries - if retryCount < maxRetries { - return true, nil - } - - return false, &streamError{ - Err: fmt.Errorf("bandwidth exceeded after %d retries", retryCount), - StatusCode: http.StatusServiceUnavailable, - } - } - - return false, &streamError{ - Err: fmt.Errorf("service unavailable: %s", bodyStr), - StatusCode: http.StatusServiceUnavailable, - } - - case http.StatusNotFound: - cleanupResp(resp) - - _log.Debug(). - Str("file", f.name). - Int("retry_count", retryCount). - Msg("Link not found (404). Marking link as invalid and regenerating") - - f.cache.MarkDownloadLinkAsInvalid(f.link, f.downloadLink, "link_not_found") - - // Try to regenerate download link if we haven't exceeded retries - if retryCount < maxRetries { - // Clear cached link to force regeneration - f.downloadLink = "" - return true, nil - } - - return false, &streamError{ - Err: fmt.Errorf("file not found after %d retries", retryCount), - StatusCode: http.StatusNotFound, - } - - default: - body, _ := io.ReadAll(resp.Body) - cleanupResp(resp) - - _log.Error(). - Int("status_code", resp.StatusCode). - Str("file", f.name). - Str("response_body", string(body)). - Msg("Unexpected upstream error") - - return false, &streamError{ - Err: fmt.Errorf("upstream error %d: %s", resp.StatusCode, string(body)), - StatusCode: http.StatusBadGateway, - } - } -} - -func (f *File) handleRangeRequest(upstreamReq *http.Request, r *http.Request, w http.ResponseWriter) int { - rangeHeader := r.Header.Get("Range") - if rangeHeader == "" { - // For video files, apply byte range if exists - if byteRange, _ := f.getDownloadByteRange(); byteRange != nil { - upstreamReq.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", byteRange[0], byteRange[1])) - } - return 0 // No range request - } - - // Parse range request - ranges, err := parseRange(rangeHeader, f.size) - if err != nil || len(ranges) != 1 { - w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", f.size)) - return -1 // Invalid range - } - - // Apply byte range offset if exists - byteRange, _ := f.getDownloadByteRange() - start, end := ranges[0].start, ranges[0].end - - if byteRange != nil { - start += byteRange[0] - end += byteRange[0] - } - - upstreamReq.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", start, end)) - return 1 // Valid range request -} - -/* -These are the methods that implement the os.File interface for the File type. -Only Stat and ReadDir are used -*/ - -func (f *File) Stat() (os.FileInfo, error) { - if f.isDir { - return &FileInfo{ - name: f.name, - size: 0, - mode: 0755 | os.ModeDir, - modTime: f.modTime, - isDir: true, - }, nil - } - - return &FileInfo{ - name: f.name, - size: f.size, - mode: 0644, - modTime: f.modTime, - isDir: false, - }, nil -} - -func (f *File) Read(p []byte) (n int, err error) { - if f.isDir { - return 0, os.ErrInvalid - } - - if f.metadataOnly { - return 0, io.EOF - } - - // For preloaded content files (like version.txt) - if f.content != nil { - if f.readOffset >= int64(len(f.content)) { - return 0, io.EOF - } - n = copy(p, f.content[f.readOffset:]) - f.readOffset += int64(n) - return n, nil - } - - // For streaming files, return an error to force use of StreamResponse - return 0, fmt.Errorf("use StreamResponse method for streaming files") -} - -func (f *File) Seek(offset int64, whence int) (int64, error) { - if f.isDir { - return 0, os.ErrInvalid - } - - // Only handle seeking for preloaded content - if f.content != nil { - newOffset := f.readOffset - switch whence { - case io.SeekStart: - newOffset = offset - case io.SeekCurrent: - newOffset += offset - case io.SeekEnd: - newOffset = int64(len(f.content)) + offset - default: - return 0, os.ErrInvalid - } - - if newOffset < 0 { - newOffset = 0 - } - if newOffset > int64(len(f.content)) { - newOffset = int64(len(f.content)) - } - - f.readOffset = newOffset - return f.readOffset, nil - } - - // For streaming files, return error to force use of StreamResponse - return 0, fmt.Errorf("use StreamResponse method for streaming files") -} - -func (f *File) Write(p []byte) (n int, err error) { - return 0, os.ErrPermission -} - -func (f *File) Readdir(count int) ([]os.FileInfo, error) { - if !f.isDir { - return nil, os.ErrInvalid - } - - if count <= 0 { - return f.children, nil - } - - if len(f.children) == 0 { - return nil, io.EOF - } - - if count > len(f.children) { - count = len(f.children) - } - - files := f.children[:count] - f.children = f.children[count:] - return files, nil +type File interface { + Name() string + Size() int64 + IsDir() bool + ModTime() string } diff --git a/pkg/webdav/misc.go b/pkg/webdav/misc.go index 024b54c..f49af92 100644 --- a/pkg/webdav/misc.go +++ b/pkg/webdav/misc.go @@ -240,3 +240,28 @@ func setVideoResponseHeaders(w http.ResponseWriter, resp *http.Response, isRange w.WriteHeader(resp.StatusCode) } + +func getContentType(fileName string) string { + contentType := "application/octet-stream" + + // Determine content type based on file extension + switch { + case strings.HasSuffix(fileName, ".mp4"): + contentType = "video/mp4" + case strings.HasSuffix(fileName, ".mkv"): + contentType = "video/x-matroska" + case strings.HasSuffix(fileName, ".avi"): + contentType = "video/x-msvideo" + case strings.HasSuffix(fileName, ".mov"): + contentType = "video/quicktime" + case strings.HasSuffix(fileName, ".m4v"): + contentType = "video/x-m4v" + case strings.HasSuffix(fileName, ".ts"): + contentType = "video/mp2t" + case strings.HasSuffix(fileName, ".srt"): + contentType = "application/x-subrip" + case strings.HasSuffix(fileName, ".vtt"): + contentType = "text/vtt" + } + return contentType +} diff --git a/pkg/webdav/propfind.go b/pkg/webdav/propfind.go index f6a22ae..ac4de09 100644 --- a/pkg/webdav/propfind.go +++ b/pkg/webdav/propfind.go @@ -2,6 +2,7 @@ package webdav import ( "context" + "github.com/rs/zerolog" "github.com/stanNthe5/stringbuf" "net/http" "os" @@ -18,7 +19,7 @@ const ( metadataOnlyKey contextKey = "metadataOnly" ) -func (h *Handler) handlePropfind(w http.ResponseWriter, r *http.Request) { +func handlePropfind(h Handler, logger zerolog.Logger, w http.ResponseWriter, r *http.Request) { // Setup context for metadata only ctx := context.WithValue(r.Context(), metadataOnlyKey, true) r = r.WithContext(ctx) @@ -37,7 +38,7 @@ func (h *Handler) handlePropfind(w http.ResponseWriter, r *http.Request) { // Always include the resource itself f, err := h.OpenFile(r.Context(), cleanPath, os.O_RDONLY, 0) if err != nil { - h.logger.Error().Err(err).Str("path", cleanPath).Msg("Failed to open file") + logger.Error().Err(err).Str("path", cleanPath).Msg("Failed to open file") http.NotFound(w, r) return } @@ -45,14 +46,14 @@ func (h *Handler) handlePropfind(w http.ResponseWriter, r *http.Request) { fi, err := f.Stat() if err != nil { - h.logger.Error().Err(err).Msg("Failed to stat file") + logger.Error().Err(err).Msg("Failed to stat file") http.Error(w, "Server Error", http.StatusInternalServerError) return } var rawEntries []os.FileInfo if fi.IsDir() { - rawEntries = append(rawEntries, h.getChildren(cleanPath)...) + rawEntries = append(rawEntries, h.GetChildren(cleanPath)...) } entries := make([]entry, 0, len(rawEntries)+1) diff --git a/pkg/webdav/torrent_file.go b/pkg/webdav/torrent_file.go new file mode 100644 index 0000000..1d94df4 --- /dev/null +++ b/pkg/webdav/torrent_file.go @@ -0,0 +1,472 @@ +package webdav + +import ( + "crypto/tls" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" + + "github.com/sirrobot01/decypharr/pkg/debrid/store" +) + +var streamingTransport = &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + MaxIdleConns: 200, + MaxIdleConnsPerHost: 100, + MaxConnsPerHost: 200, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ResponseHeaderTimeout: 60 * time.Second, // give the upstream a minute to send headers + ExpectContinueTimeout: 1 * time.Second, + DisableKeepAlives: true, // close after each request + ForceAttemptHTTP2: false, // don’t speak HTTP/2 + // this line is what truly blocks HTTP/2: + TLSNextProto: make(map[string]func(string, *tls.Conn) http.RoundTripper), +} + +var sharedClient = &http.Client{ + Transport: streamingTransport, + Timeout: 0, +} + +type streamError struct { + Err error + StatusCode int + IsClientDisconnection bool +} + +func (e *streamError) Error() string { + return e.Err.Error() +} + +func (e *streamError) Unwrap() error { + return e.Err +} + +type TorrentFile struct { + name string + torrentName string + link string + downloadLink string + size int64 + isDir bool + fileId string + isRar bool + metadataOnly bool + content []byte + children []os.FileInfo // For directories + cache *store.Cache + modTime time.Time + + // Minimal state for interface compliance only + readOffset int64 // Only used for Read() method compliance +} + +// TorrentFile interface implementations for TorrentFile + +func (f *TorrentFile) Close() error { + if f.isDir { + return nil // No resources to close for directories + } + + // For files, we don't have any resources to close either + // This is just to satisfy the os.TorrentFile interface + f.content = nil + f.children = nil + f.downloadLink = "" + f.readOffset = 0 + return nil +} + +func (f *TorrentFile) getDownloadLink() (string, error) { + // Check if we already have a final URL cached + + if f.downloadLink != "" && isValidURL(f.downloadLink) { + return f.downloadLink, nil + } + downloadLink, err := f.cache.GetDownloadLink(f.torrentName, f.name, f.link) + if err != nil { + return "", err + } + if downloadLink != "" && isValidURL(downloadLink) { + f.downloadLink = downloadLink + return downloadLink, nil + } + return "", os.ErrNotExist +} + +func (f *TorrentFile) getDownloadByteRange() (*[2]int64, error) { + byteRange, err := f.cache.GetDownloadByteRange(f.torrentName, f.name) + if err != nil { + return nil, err + } + return byteRange, nil +} + +func (f *TorrentFile) servePreloadedContent(w http.ResponseWriter, r *http.Request) error { + content := f.content + size := int64(len(content)) + + // Handle range requests for preloaded content + if rangeHeader := r.Header.Get("Range"); rangeHeader != "" { + ranges, err := parseRange(rangeHeader, size) + if err != nil || len(ranges) != 1 { + w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", size)) + return &streamError{Err: fmt.Errorf("invalid range"), StatusCode: http.StatusRequestedRangeNotSatisfiable} + } + + start, end := ranges[0].start, ranges[0].end + w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, size)) + w.Header().Set("Content-Length", fmt.Sprintf("%d", end-start+1)) + w.Header().Set("Accept-Ranges", "bytes") + w.WriteHeader(http.StatusPartialContent) + + _, err = w.Write(content[start : end+1]) + return err + } + + // Full content + w.Header().Set("Content-Length", fmt.Sprintf("%d", size)) + w.Header().Set("Accept-Ranges", "bytes") + w.WriteHeader(http.StatusOK) + + _, err := w.Write(content) + return err +} + +func (f *TorrentFile) StreamResponse(w http.ResponseWriter, r *http.Request) error { + // Handle preloaded content files + if f.content != nil { + return f.servePreloadedContent(w, r) + } + + // Try streaming with retry logic + return f.streamWithRetry(w, r, 0) +} + +func (f *TorrentFile) streamWithRetry(w http.ResponseWriter, r *http.Request, retryCount int) error { + const maxRetries = 3 + _log := f.cache.Logger() + + // Get download link (with caching optimization) + downloadLink, err := f.getDownloadLink() + if err != nil { + return &streamError{Err: err, StatusCode: http.StatusPreconditionFailed} + } + + if downloadLink == "" { + return &streamError{Err: fmt.Errorf("empty download link"), StatusCode: http.StatusNotFound} + } + + // Create upstream request with streaming optimizations + upstreamReq, err := http.NewRequest("GET", downloadLink, nil) + if err != nil { + return &streamError{Err: err, StatusCode: http.StatusInternalServerError} + } + + setVideoStreamingHeaders(upstreamReq) + + // Handle range requests (critical for video seeking) + isRangeRequest := f.handleRangeRequest(upstreamReq, r, w) + if isRangeRequest == -1 { + return &streamError{Err: fmt.Errorf("invalid range"), StatusCode: http.StatusRequestedRangeNotSatisfiable} + } + + resp, err := sharedClient.Do(upstreamReq) + if err != nil { + return &streamError{Err: err, StatusCode: http.StatusServiceUnavailable} + } + defer resp.Body.Close() + + // Handle upstream errors with retry logic + shouldRetry, retryErr := f.handleUpstream(resp, retryCount, maxRetries) + if shouldRetry && retryCount < maxRetries { + // Retry with new download link + _log.Debug(). + Int("retry_count", retryCount+1). + Str("file", f.name). + Msg("Retrying stream request") + return f.streamWithRetry(w, r, retryCount+1) + } + if retryErr != nil { + return retryErr + } + + setVideoResponseHeaders(w, resp, isRangeRequest == 1) + + return f.streamBuffer(w, resp.Body) +} + +func (f *TorrentFile) streamBuffer(w http.ResponseWriter, src io.Reader) error { + flusher, ok := w.(http.Flusher) + if !ok { + return fmt.Errorf("response does not support flushing") + } + + smallBuf := make([]byte, 64*1024) // 64 KB + if n, err := src.Read(smallBuf); n > 0 { + if _, werr := w.Write(smallBuf[:n]); werr != nil { + return werr + } + flusher.Flush() + } else if err != nil && err != io.EOF { + return err + } + + buf := make([]byte, 256*1024) // 256 KB + for { + n, readErr := src.Read(buf) + if n > 0 { + if _, writeErr := w.Write(buf[:n]); writeErr != nil { + if isClientDisconnection(writeErr) { + return &streamError{Err: writeErr, StatusCode: 0, IsClientDisconnection: true} + } + return writeErr + } + flusher.Flush() + } + if readErr != nil { + if readErr == io.EOF { + return nil + } + if isClientDisconnection(readErr) { + return &streamError{Err: readErr, StatusCode: 0, IsClientDisconnection: true} + } + return readErr + } + } +} + +func (f *TorrentFile) handleUpstream(resp *http.Response, retryCount, maxRetries int) (shouldRetry bool, err error) { + if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusPartialContent { + return false, nil + } + + _log := f.cache.Logger() + + // Clean up response body properly + cleanupResp := func(resp *http.Response) { + if resp.Body != nil { + _, _ = io.Copy(io.Discard, resp.Body) + resp.Body.Close() + } + } + + switch resp.StatusCode { + case http.StatusServiceUnavailable: + // Read the body to check for specific error messages + body, readErr := io.ReadAll(resp.Body) + cleanupResp(resp) + + if readErr != nil { + _log.Error().Err(readErr).Msg("Failed to read response body") + return false, &streamError{ + Err: fmt.Errorf("failed to read error response: %w", readErr), + StatusCode: http.StatusServiceUnavailable, + } + } + + bodyStr := string(body) + if strings.Contains(bodyStr, "you have exceeded your traffic") { + _log.Debug(). + Str("file", f.name). + Int("retry_count", retryCount). + Msg("Bandwidth exceeded. Marking link as invalid") + + f.cache.MarkDownloadLinkAsInvalid(f.link, f.downloadLink, "bandwidth_exceeded") + + // Retry with a different API key if available and we haven't exceeded retries + if retryCount < maxRetries { + return true, nil + } + + return false, &streamError{ + Err: fmt.Errorf("bandwidth exceeded after %d retries", retryCount), + StatusCode: http.StatusServiceUnavailable, + } + } + + return false, &streamError{ + Err: fmt.Errorf("service unavailable: %s", bodyStr), + StatusCode: http.StatusServiceUnavailable, + } + + case http.StatusNotFound: + cleanupResp(resp) + + _log.Debug(). + Str("file", f.name). + Int("retry_count", retryCount). + Msg("Link not found (404). Marking link as invalid and regenerating") + + f.cache.MarkDownloadLinkAsInvalid(f.link, f.downloadLink, "link_not_found") + + // Try to regenerate download link if we haven't exceeded retries + if retryCount < maxRetries { + // Clear cached link to force regeneration + f.downloadLink = "" + return true, nil + } + + return false, &streamError{ + Err: fmt.Errorf("file not found after %d retries", retryCount), + StatusCode: http.StatusNotFound, + } + + default: + body, _ := io.ReadAll(resp.Body) + cleanupResp(resp) + + _log.Error(). + Int("status_code", resp.StatusCode). + Str("file", f.name). + Str("response_body", string(body)). + Msg("Unexpected upstream error") + + return false, &streamError{ + Err: fmt.Errorf("upstream error %d: %s", resp.StatusCode, string(body)), + StatusCode: http.StatusBadGateway, + } + } +} + +func (f *TorrentFile) handleRangeRequest(upstreamReq *http.Request, r *http.Request, w http.ResponseWriter) int { + rangeHeader := r.Header.Get("Range") + if rangeHeader == "" { + // For video files, apply byte range if exists + if byteRange, _ := f.getDownloadByteRange(); byteRange != nil { + upstreamReq.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", byteRange[0], byteRange[1])) + } + return 0 // No range request + } + + // Parse range request + ranges, err := parseRange(rangeHeader, f.size) + if err != nil || len(ranges) != 1 { + w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", f.size)) + return -1 // Invalid range + } + + // Apply byte range offset if exists + byteRange, _ := f.getDownloadByteRange() + start, end := ranges[0].start, ranges[0].end + + if byteRange != nil { + start += byteRange[0] + end += byteRange[0] + } + + upstreamReq.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", start, end)) + return 1 // Valid range request +} + +/* +These are the methods that implement the os.TorrentFile interface for the TorrentFile type. +Only Stat and ReadDir are used +*/ + +func (f *TorrentFile) Stat() (os.FileInfo, error) { + if f.isDir { + return &FileInfo{ + name: f.name, + size: 0, + mode: 0755 | os.ModeDir, + modTime: f.modTime, + isDir: true, + }, nil + } + + return &FileInfo{ + name: f.name, + size: f.size, + mode: 0644, + modTime: f.modTime, + isDir: false, + }, nil +} + +func (f *TorrentFile) Read(p []byte) (n int, err error) { + if f.isDir { + return 0, os.ErrInvalid + } + + if f.metadataOnly { + return 0, io.EOF + } + + // For preloaded content files (like version.txt) + if f.content != nil { + if f.readOffset >= int64(len(f.content)) { + return 0, io.EOF + } + n = copy(p, f.content[f.readOffset:]) + f.readOffset += int64(n) + return n, nil + } + + // For streaming files, return an error to force use of StreamResponse + return 0, fmt.Errorf("use StreamResponse method for streaming files") +} + +func (f *TorrentFile) Seek(offset int64, whence int) (int64, error) { + if f.isDir { + return 0, os.ErrInvalid + } + + // Only handle seeking for preloaded content + if f.content != nil { + newOffset := f.readOffset + switch whence { + case io.SeekStart: + newOffset = offset + case io.SeekCurrent: + newOffset += offset + case io.SeekEnd: + newOffset = int64(len(f.content)) + offset + default: + return 0, os.ErrInvalid + } + + if newOffset < 0 { + newOffset = 0 + } + if newOffset > int64(len(f.content)) { + newOffset = int64(len(f.content)) + } + + f.readOffset = newOffset + return f.readOffset, nil + } + + // For streaming files, return error to force use of StreamResponse + return 0, fmt.Errorf("use StreamResponse method for streaming files") +} + +func (f *TorrentFile) Write(p []byte) (n int, err error) { + return 0, os.ErrPermission +} + +func (f *TorrentFile) Readdir(count int) ([]os.FileInfo, error) { + if !f.isDir { + return nil, os.ErrInvalid + } + + if count <= 0 { + return f.children, nil + } + + if len(f.children) == 0 { + return nil, io.EOF + } + + if count > len(f.children) { + count = len(f.children) + } + + files := f.children[:count] + f.children = f.children[count:] + return files, nil +} diff --git a/pkg/webdav/handler.go b/pkg/webdav/torrent_handler.go similarity index 68% rename from pkg/webdav/handler.go rename to pkg/webdav/torrent_handler.go index 310c46f..ce7ae88 100644 --- a/pkg/webdav/handler.go +++ b/pkg/webdav/torrent_handler.go @@ -24,17 +24,17 @@ import ( const DeleteAllBadTorrentKey = "DELETE_ALL_BAD_TORRENTS" -type Handler struct { - Name string +type TorrentHandler struct { + name string logger zerolog.Logger cache *store.Cache URLBase string RootPath string } -func NewHandler(name, urlBase string, cache *store.Cache, logger zerolog.Logger) *Handler { - h := &Handler{ - Name: name, +func NewTorrentHandler(name, urlBase string, cache *store.Cache, logger zerolog.Logger) Handler { + h := &TorrentHandler{ + name: name, cache: cache, logger: logger, URLBase: urlBase, @@ -43,15 +43,18 @@ func NewHandler(name, urlBase string, cache *store.Cache, logger zerolog.Logger) return h } -// Mkdir implements webdav.FileSystem -func (h *Handler) Mkdir(ctx context.Context, name string, perm os.FileMode) error { - return os.ErrPermission // Read-only filesystem +func (ht *TorrentHandler) Start(ctx context.Context) error { + return ht.cache.Start(ctx) } -func (h *Handler) readinessMiddleware(next http.Handler) http.Handler { +func (ht *TorrentHandler) Type() string { + return "torrent" +} + +func (ht *TorrentHandler) Readiness(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { select { - case <-h.cache.IsReady(): + case <-ht.cache.IsReady(): // WebDAV is ready, proceed next.ServeHTTP(w, r) default: @@ -62,13 +65,23 @@ func (h *Handler) readinessMiddleware(next http.Handler) http.Handler { }) } +// Name returns the name of the handler +func (ht *TorrentHandler) Name() string { + return ht.name +} + +// Mkdir implements webdav.FileSystem +func (ht *TorrentHandler) Mkdir(ctx context.Context, name string, perm os.FileMode) error { + return os.ErrPermission // Read-only filesystem +} + // RemoveAll implements webdav.FileSystem -func (h *Handler) RemoveAll(ctx context.Context, name string) error { +func (ht *TorrentHandler) RemoveAll(ctx context.Context, name string) error { if !strings.HasPrefix(name, "/") { name = "/" + name } name = utils.PathUnescape(path.Clean(name)) - rootDir := path.Clean(h.RootPath) + rootDir := path.Clean(ht.RootPath) if name == rootDir { return os.ErrPermission @@ -80,33 +93,33 @@ func (h *Handler) RemoveAll(ctx context.Context, name string) error { } // Check if the name is a parent path - if _, ok := h.isParentPath(name); ok { + if _, ok := ht.isParentPath(name); ok { return os.ErrPermission } // Check if the name is a torrent folder rel := strings.TrimPrefix(name, rootDir+"/") parts := strings.Split(rel, "/") - if len(parts) == 2 && utils.Contains(h.getParentItems(), parts[0]) { + if len(parts) == 2 && utils.Contains(ht.getParentItems(), parts[0]) { torrentName := parts[1] - torrent := h.cache.GetTorrentByName(torrentName) + torrent := ht.cache.GetTorrentByName(torrentName) if torrent == nil { return os.ErrNotExist } // Remove the torrent from the cache and debrid - h.cache.OnRemove(torrent.Id) + ht.cache.OnRemove(torrent.Id) return nil } // If we reach here, it means the path is a file if len(parts) >= 2 { - if utils.Contains(h.getParentItems(), parts[0]) { + if utils.Contains(ht.getParentItems(), parts[0]) { torrentName := parts[1] - cached := h.cache.GetTorrentByName(torrentName) + cached := ht.cache.GetTorrentByName(torrentName) if cached != nil && len(parts) >= 3 { filename := filepath.Clean(path.Join(parts[2:]...)) if file, ok := cached.GetFile(filename); ok { - if err := h.cache.RemoveFile(cached.Id, file.Name); err != nil { - h.logger.Error().Err(err).Msgf("Failed to remove file %s from torrent %s", file.Name, torrentName) + if err := ht.cache.RemoveFile(cached.Id, file.Name); err != nil { + ht.logger.Error().Err(err).Msgf("Failed to remove file %s from torrent %s", file.Name, torrentName) return err } // If the file was successfully removed, we can return nil @@ -120,29 +133,29 @@ func (h *Handler) RemoveAll(ctx context.Context, name string) error { } // Rename implements webdav.FileSystem -func (h *Handler) Rename(ctx context.Context, oldName, newName string) error { +func (ht *TorrentHandler) Rename(ctx context.Context, oldName, newName string) error { return os.ErrPermission // Read-only filesystem } -func (h *Handler) getTorrentsFolders(folder string) []os.FileInfo { - return h.cache.GetListing(folder) +func (ht *TorrentHandler) getTorrentsFolders(folder string) []os.FileInfo { + return ht.cache.GetListing(folder) } -func (h *Handler) getParentItems() []string { +func (ht *TorrentHandler) getParentItems() []string { parents := []string{"__all__", "torrents", "__bad__"} // Add custom folders - parents = append(parents, h.cache.GetCustomFolders()...) + parents = append(parents, ht.cache.GetCustomFolders()...) // version.txt parents = append(parents, "version.txt") return parents } -func (h *Handler) getParentFiles() []os.FileInfo { +func (ht *TorrentHandler) getParentFiles() []os.FileInfo { now := time.Now() - rootFiles := make([]os.FileInfo, 0, len(h.getParentItems())) - for _, item := range h.getParentItems() { + rootFiles := make([]os.FileInfo, 0, len(ht.getParentItems())) + for _, item := range ht.getParentItems() { f := &FileInfo{ name: item, size: 0, @@ -159,49 +172,49 @@ func (h *Handler) getParentFiles() []os.FileInfo { return rootFiles } -// returns the os.FileInfo slice for “depth-1” children of cleanPath -func (h *Handler) getChildren(name string) []os.FileInfo { +// GetChildren returns the os.FileInfo slice for “depth-1” children of cleanPath +func (ht *TorrentHandler) GetChildren(name string) []os.FileInfo { if name[0] != '/' { name = "/" + name } name = utils.PathUnescape(path.Clean(name)) - root := path.Clean(h.RootPath) + root := path.Clean(ht.RootPath) // top‐level “parents” (e.g. __all__, torrents etc) if name == root { - return h.getParentFiles() + return ht.getParentFiles() } // one level down (e.g. /root/parentFolder) - if parent, ok := h.isParentPath(name); ok { - return h.getTorrentsFolders(parent) + if parent, ok := ht.isParentPath(name); ok { + return ht.getTorrentsFolders(parent) } // torrent-folder level (e.g. /root/parentFolder/torrentName) rel := strings.TrimPrefix(name, root+"/") parts := strings.Split(rel, "/") - if len(parts) == 2 && utils.Contains(h.getParentItems(), parts[0]) { + if len(parts) == 2 && utils.Contains(ht.getParentItems(), parts[0]) { torrentName := parts[1] - if t := h.cache.GetTorrentByName(torrentName); t != nil { - return h.getFileInfos(t) + if t := ht.cache.GetTorrentByName(torrentName); t != nil { + return ht.getFileInfos(t) } } return nil } -func (h *Handler) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (webdav.File, error) { +func (ht *TorrentHandler) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (webdav.File, error) { if !strings.HasPrefix(name, "/") { name = "/" + name } name = utils.PathUnescape(path.Clean(name)) - rootDir := path.Clean(h.RootPath) + rootDir := path.Clean(ht.RootPath) metadataOnly := ctx.Value(metadataOnlyKey) != nil now := time.Now() // 1) special case version.txt if name == path.Join(rootDir, "version.txt") { versionInfo := version.GetInfo().String() - return &File{ - cache: h.cache, + return &TorrentFile{ + cache: ht.cache, isDir: false, content: []byte(versionInfo), name: "version.txt", @@ -211,14 +224,14 @@ func (h *Handler) OpenFile(ctx context.Context, name string, flag int, perm os.F }, nil } - // 2) directory case: ask getChildren - if children := h.getChildren(name); children != nil { + // 2) directory case: ask Children + if children := ht.GetChildren(name); children != nil { displayName := filepath.Clean(path.Base(name)) if name == rootDir { displayName = "/" } - return &File{ - cache: h.cache, + return &TorrentFile{ + cache: ht.cache, isDir: true, children: children, name: displayName, @@ -233,14 +246,14 @@ func (h *Handler) OpenFile(ctx context.Context, name string, flag int, perm os.F rel := strings.TrimPrefix(name, rootDir+"/") parts := strings.Split(rel, "/") if len(parts) >= 2 { - if utils.Contains(h.getParentItems(), parts[0]) { + if utils.Contains(ht.getParentItems(), parts[0]) { torrentName := parts[1] - cached := h.cache.GetTorrentByName(torrentName) + cached := ht.cache.GetTorrentByName(torrentName) if cached != nil && len(parts) >= 3 { filename := filepath.Clean(path.Join(parts[2:]...)) if file, ok := cached.GetFile(filename); ok && !file.Deleted { - return &File{ - cache: h.cache, + return &TorrentFile{ + cache: ht.cache, torrentName: torrentName, fileId: file.Id, isDir: false, @@ -255,21 +268,19 @@ func (h *Handler) OpenFile(ctx context.Context, name string, flag int, perm os.F } } } - - h.logger.Info().Msgf("File not found: %s", name) return nil, os.ErrNotExist } // Stat implements webdav.FileSystem -func (h *Handler) Stat(ctx context.Context, name string) (os.FileInfo, error) { - f, err := h.OpenFile(ctx, name, os.O_RDONLY, 0) +func (ht *TorrentHandler) Stat(ctx context.Context, name string) (os.FileInfo, error) { + f, err := ht.OpenFile(ctx, name, os.O_RDONLY, 0) if err != nil { return nil, err } return f.Stat() } -func (h *Handler) getFileInfos(torrent *store.CachedTorrent) []os.FileInfo { +func (ht *TorrentHandler) getFileInfos(torrent *store.CachedTorrent) []os.FileInfo { torrentFiles := torrent.GetFiles() files := make([]os.FileInfo, 0, len(torrentFiles)) @@ -294,33 +305,33 @@ func (h *Handler) getFileInfos(torrent *store.CachedTorrent) []os.FileInfo { return files } -func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { +func (ht *TorrentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { switch r.Method { case "GET": - h.handleGet(w, r) + ht.handleGet(w, r) return case "HEAD": - h.handleHead(w, r) + ht.handleHead(w, r) return case "OPTIONS": - h.handleOptions(w, r) + ht.handleOptions(w, r) return case "PROPFIND": - h.handlePropfind(w, r) + ht.handlePropfind(w, r) return case "DELETE": - if err := h.handleDelete(w, r); err == nil { + if err := ht.handleDelete(w, r); err == nil { return } // fallthrough to default } handler := &webdav.Handler{ - FileSystem: h, + FileSystem: ht, LockSystem: webdav.NewMemLS(), Logger: func(r *http.Request, err error) { if err != nil { - h.logger.Trace(). + ht.logger.Trace(). Err(err). Str("method", r.Method). Str("path", r.URL.Path). @@ -331,33 +342,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { handler.ServeHTTP(w, r) } -func getContentType(fileName string) string { - contentType := "application/octet-stream" - - // Determine content type based on file extension - switch { - case strings.HasSuffix(fileName, ".mp4"): - contentType = "video/mp4" - case strings.HasSuffix(fileName, ".mkv"): - contentType = "video/x-matroska" - case strings.HasSuffix(fileName, ".avi"): - contentType = "video/x-msvideo" - case strings.HasSuffix(fileName, ".mov"): - contentType = "video/quicktime" - case strings.HasSuffix(fileName, ".m4v"): - contentType = "video/x-m4v" - case strings.HasSuffix(fileName, ".ts"): - contentType = "video/mp2t" - case strings.HasSuffix(fileName, ".srt"): - contentType = "application/x-subrip" - case strings.HasSuffix(fileName, ".vtt"): - contentType = "text/vtt" - } - return contentType -} - -func (h *Handler) isParentPath(urlPath string) (string, bool) { - parents := h.getParentItems() +func (ht *TorrentHandler) isParentPath(urlPath string) (string, bool) { + parents := ht.getParentItems() lastComponent := path.Base(urlPath) for _, p := range parents { if p == lastComponent { @@ -367,9 +353,9 @@ func (h *Handler) isParentPath(urlPath string) (string, bool) { return "", false } -func (h *Handler) serveDirectory(w http.ResponseWriter, r *http.Request, file webdav.File) { +func (ht *TorrentHandler) serveDirectory(w http.ResponseWriter, r *http.Request, file webdav.File) { var children []os.FileInfo - if f, ok := file.(*File); ok { + if f, ok := file.(*TorrentFile); ok { children = f.children } else { var err error @@ -385,7 +371,7 @@ func (h *Handler) serveDirectory(w http.ResponseWriter, r *http.Request, file we parentPath := path.Dir(cleanPath) showParent := cleanPath != "/" && parentPath != "." && parentPath != cleanPath isBadPath := strings.HasSuffix(cleanPath, "__bad__") - _, canDelete := h.isParentPath(cleanPath) + _, canDelete := ht.isParentPath(cleanPath) // Prepare template data data := struct { @@ -402,7 +388,7 @@ func (h *Handler) serveDirectory(w http.ResponseWriter, r *http.Request, file we ParentPath: parentPath, ShowParent: showParent, Children: children, - URLBase: h.URLBase, + URLBase: ht.URLBase, IsBadPath: isBadPath, CanDelete: canDelete, DeleteAllBadTorrentKey: DeleteAllBadTorrentKey, @@ -416,8 +402,8 @@ func (h *Handler) serveDirectory(w http.ResponseWriter, r *http.Request, file we // Handlers -func (h *Handler) handleGet(w http.ResponseWriter, r *http.Request) { - fRaw, err := h.OpenFile(r.Context(), r.URL.Path, os.O_RDONLY, 0) +func (ht *TorrentHandler) handleGet(w http.ResponseWriter, r *http.Request) { + fRaw, err := ht.OpenFile(r.Context(), r.URL.Path, os.O_RDONLY, 0) if err != nil { http.NotFound(w, r) return @@ -431,7 +417,7 @@ func (h *Handler) handleGet(w http.ResponseWriter, r *http.Request) { } if fi.IsDir() { - h.serveDirectory(w, r, fRaw) + ht.serveDirectory(w, r, fRaw) return } @@ -448,9 +434,9 @@ func (h *Handler) handleGet(w http.ResponseWriter, r *http.Request) { } // Handle File struct with direct streaming - if file, ok := fRaw.(*File); ok { + if file, ok := fRaw.(*TorrentFile); ok { // Handle nginx proxy (X-Accel-Redirect) - if file.content == nil && !file.isRar && h.cache.StreamWithRclone() { + if file.content == nil && !file.isRar && ht.cache.StreamWithRclone() { link, err := file.getDownloadLink() if err != nil || link == "" { http.Error(w, "Could not fetch download link", http.StatusPreconditionFailed) @@ -475,7 +461,7 @@ func (h *Handler) handleGet(w http.ResponseWriter, r *http.Request) { if streamErr.StatusCode > 0 && !hasHeadersWritten(w) { http.Error(w, streamErr.Error(), streamErr.StatusCode) } else { - h.logger.Error(). + ht.logger.Error(). Err(streamErr.Err). Str("path", r.URL.Path). Msg("Stream error") @@ -485,7 +471,7 @@ func (h *Handler) handleGet(w http.ResponseWriter, r *http.Request) { if !hasHeadersWritten(w) { http.Error(w, "Stream error", http.StatusInternalServerError) } else { - h.logger.Error(). + ht.logger.Error(). Err(err). Str("path", r.URL.Path). Msg("Stream error after headers written") @@ -505,10 +491,14 @@ func (h *Handler) handleGet(w http.ResponseWriter, r *http.Request) { } } -func (h *Handler) handleHead(w http.ResponseWriter, r *http.Request) { - f, err := h.OpenFile(r.Context(), r.URL.Path, os.O_RDONLY, 0) +func (ht *TorrentHandler) handlePropfind(w http.ResponseWriter, r *http.Request) { + handlePropfind(ht, ht.logger, w, r) +} + +func (ht *TorrentHandler) handleHead(w http.ResponseWriter, r *http.Request) { + f, err := ht.OpenFile(r.Context(), r.URL.Path, os.O_RDONLY, 0) if err != nil { - h.logger.Error().Err(err).Str("path", r.URL.Path).Msg("Failed to open file") + ht.logger.Error().Err(err).Str("path", r.URL.Path).Msg("Failed to open file") http.NotFound(w, r) return } @@ -521,7 +511,7 @@ func (h *Handler) handleHead(w http.ResponseWriter, r *http.Request) { fi, err := f.Stat() if err != nil { - h.logger.Error().Err(err).Msg("Failed to stat file") + ht.logger.Error().Err(err).Msg("Failed to stat file") http.Error(w, "Server Error", http.StatusInternalServerError) return } @@ -532,14 +522,14 @@ func (h *Handler) handleHead(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } -func (h *Handler) handleOptions(w http.ResponseWriter, r *http.Request) { +func (ht *TorrentHandler) handleOptions(w http.ResponseWriter, r *http.Request) { w.Header().Set("Allow", "OPTIONS, GET, HEAD, PUT, DELETE, MKCOL, COPY, MOVE, PROPFIND") w.Header().Set("DAV", "1, 2") w.WriteHeader(http.StatusOK) } // handleDelete deletes a torrent by id, or all bad torrents if the id is DeleteAllBadTorrentKey -func (h *Handler) handleDelete(w http.ResponseWriter, r *http.Request) error { +func (ht *TorrentHandler) handleDelete(w http.ResponseWriter, r *http.Request) error { cleanPath := path.Clean(r.URL.Path) // Remove any leading slashes _, torrentId := path.Split(cleanPath) @@ -548,25 +538,25 @@ func (h *Handler) handleDelete(w http.ResponseWriter, r *http.Request) error { } if torrentId == DeleteAllBadTorrentKey { - return h.handleDeleteAll(w) + return ht.handleDeleteAll(w) } - return h.handleDeleteById(w, torrentId) + return ht.handleDeleteById(w, torrentId) } -func (h *Handler) handleDeleteById(w http.ResponseWriter, tId string) error { - cachedTorrent := h.cache.GetTorrent(tId) +func (ht *TorrentHandler) handleDeleteById(w http.ResponseWriter, tId string) error { + cachedTorrent := ht.cache.GetTorrent(tId) if cachedTorrent == nil { return os.ErrNotExist } - h.cache.OnRemove(cachedTorrent.Id) + ht.cache.OnRemove(cachedTorrent.Id) w.WriteHeader(http.StatusNoContent) return nil } -func (h *Handler) handleDeleteAll(w http.ResponseWriter) error { - badTorrents := h.cache.GetListing("__bad__") +func (ht *TorrentHandler) handleDeleteAll(w http.ResponseWriter) error { + badTorrents := ht.cache.GetListing("__bad__") if len(badTorrents) == 0 { http.Error(w, "No bad torrents to delete", http.StatusNotFound) return nil @@ -574,9 +564,9 @@ func (h *Handler) handleDeleteAll(w http.ResponseWriter) error { for _, fi := range badTorrents { tName := strings.TrimSpace(strings.SplitN(fi.Name(), "||", 2)[0]) - t := h.cache.GetTorrentByName(tName) + t := ht.cache.GetTorrentByName(tName) if t != nil { - h.cache.OnRemove(t.Id) + ht.cache.OnRemove(t.Id) } } diff --git a/pkg/webdav/usenet_file.go b/pkg/webdav/usenet_file.go new file mode 100644 index 0000000..8bf7c60 --- /dev/null +++ b/pkg/webdav/usenet_file.go @@ -0,0 +1,263 @@ +package webdav + +import ( + "context" + "errors" + "fmt" + "github.com/sirrobot01/decypharr/pkg/usenet" + "io" + "net/http" + "os" + "strings" + "time" +) + +type UsenetFile struct { + name string + nzbID string + downloadLink string + size int64 + isDir bool + fileId string + metadataOnly bool + content []byte + children []os.FileInfo // For directories + usenet usenet.Usenet + modTime time.Time + readOffset int64 + rPipe io.ReadCloser +} + +// UsenetFile interface implementations for UsenetFile + +func (f *UsenetFile) Close() error { + if f.isDir { + return nil // No resources to close for directories + } + + f.content = nil + f.children = nil + f.downloadLink = "" + return nil +} + +func (f *UsenetFile) servePreloadedContent(w http.ResponseWriter, r *http.Request) error { + content := f.content + size := int64(len(content)) + + // Handle range requests for preloaded content + if rangeHeader := r.Header.Get("Range"); rangeHeader != "" { + ranges, err := parseRange(rangeHeader, size) + if err != nil || len(ranges) != 1 { + w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", size)) + return &streamError{Err: fmt.Errorf("invalid range"), StatusCode: http.StatusRequestedRangeNotSatisfiable} + } + + start, end := ranges[0].start, ranges[0].end + w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, size)) + w.Header().Set("Content-Length", fmt.Sprintf("%d", end-start+1)) + w.Header().Set("Accept-Ranges", "bytes") + w.WriteHeader(http.StatusPartialContent) + + _, err = w.Write(content[start : end+1]) + return err + } + + // Full content + w.Header().Set("Content-Length", fmt.Sprintf("%d", size)) + w.Header().Set("Accept-Ranges", "bytes") + w.WriteHeader(http.StatusOK) + + _, err := w.Write(content) + return err +} + +func (f *UsenetFile) StreamResponse(w http.ResponseWriter, r *http.Request) error { + // Handle preloaded content files + if f.content != nil { + return f.servePreloadedContent(w, r) + } + + // Try streaming with retry logic + return f.streamWithRetry(w, r, 0) +} + +func (f *UsenetFile) streamWithRetry(w http.ResponseWriter, r *http.Request, retryCount int) error { + start, end := f.getRange(r) + + if retryCount == 0 { + contentLength := end - start + 1 + + w.Header().Set("Content-Length", fmt.Sprintf("%d", contentLength)) + w.Header().Set("Accept-Ranges", "bytes") + + if r.Header.Get("Range") != "" { + contentRange := fmt.Sprintf("bytes %d-%d/%d", start, end, f.size) + w.Header().Set("Content-Range", contentRange) + w.WriteHeader(http.StatusPartialContent) + } else { + w.WriteHeader(http.StatusOK) + } + } + err := f.usenet.Stream(r.Context(), f.nzbID, f.name, start, end, w) + + if err != nil { + if isConnectionError(err) || strings.Contains(err.Error(), "client disconnected") { + return nil + } + // Don't treat cancellation as an error - it's expected for seek operations + if errors.Is(err, context.Canceled) { + return nil + } + return &streamError{Err: fmt.Errorf("failed to stream file %s: %w", f.name, err), StatusCode: http.StatusInternalServerError} + } + return nil +} + +// isConnectionError checks if the error is due to client disconnection +func isConnectionError(err error) bool { + errStr := err.Error() + if errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) { + return true // EOF or context cancellation is a common disconnection error + } + return strings.Contains(errStr, "broken pipe") || + strings.Contains(errStr, "connection reset by peer") +} + +func (f *UsenetFile) getRange(r *http.Request) (int64, int64) { + rangeHeader := r.Header.Get("Range") + if rangeHeader == "" { + // No range header - return full file range (0 to size-1) + return 0, f.size - 1 + } + + // Parse the range header for this specific file + ranges, err := parseRange(rangeHeader, f.size) + if err != nil || len(ranges) != 1 { + return -1, -1 + } + + // Return the requested range (this is relative to the file, not the entire NZB) + start, end := ranges[0].start, ranges[0].end + if start < 0 || end < 0 || start > end || end >= f.size { + return -1, -1 // Invalid range + } + + return start, end +} + +func (f *UsenetFile) Stat() (os.FileInfo, error) { + if f.isDir { + return &FileInfo{ + name: f.name, + size: f.size, + mode: 0755 | os.ModeDir, + modTime: f.modTime, + isDir: true, + }, nil + } + + return &FileInfo{ + name: f.name, + size: f.size, + mode: 0644, + modTime: f.modTime, + isDir: false, + }, nil +} + +func (f *UsenetFile) Read(p []byte) (int, error) { + if f.isDir { + return 0, os.ErrInvalid + } + + // preloaded content (unchanged) + if f.metadataOnly { + return 0, io.EOF + } + if f.content != nil { + if f.readOffset >= int64(len(f.content)) { + return 0, io.EOF + } + n := copy(p, f.content[f.readOffset:]) + f.readOffset += int64(n) + return n, nil + } + + if f.rPipe == nil { + pr, pw := io.Pipe() + f.rPipe = pr + + // start fetch from current offset + go func(start int64) { + err := f.usenet.Stream(context.Background(), f.nzbID, f.name, start, f.size-1, pw) + if err := pw.CloseWithError(err); err != nil { + return + } + }(f.readOffset) + } + + n, err := f.rPipe.Read(p) + f.readOffset += int64(n) + return n, err +} + +// Seek simply moves the readOffset pointer within [0…size] +func (f *UsenetFile) Seek(offset int64, whence int) (int64, error) { + if f.isDir { + return 0, os.ErrInvalid + } + + // preload path (unchanged) + var newOffset int64 + switch whence { + case io.SeekStart: + newOffset = offset + case io.SeekCurrent: + newOffset = f.readOffset + offset + case io.SeekEnd: + newOffset = f.size + offset + default: + return 0, os.ErrInvalid + } + if newOffset < 0 { + newOffset = 0 + } + if newOffset > f.size { + newOffset = f.size + } + + // drop in-flight stream + if f.rPipe != nil { + f.rPipe.Close() + f.rPipe = nil + } + f.readOffset = newOffset + return f.readOffset, nil +} + +func (f *UsenetFile) Write(_ []byte) (n int, err error) { + return 0, os.ErrPermission +} + +func (f *UsenetFile) Readdir(count int) ([]os.FileInfo, error) { + if !f.isDir { + return nil, os.ErrInvalid + } + + if count <= 0 { + return f.children, nil + } + + if len(f.children) == 0 { + return nil, io.EOF + } + + if count > len(f.children) { + count = len(f.children) + } + + files := f.children[:count] + f.children = f.children[count:] + return files, nil +} diff --git a/pkg/webdav/usenet_handler.go b/pkg/webdav/usenet_handler.go new file mode 100644 index 0000000..c6da6ed --- /dev/null +++ b/pkg/webdav/usenet_handler.go @@ -0,0 +1,529 @@ +package webdav + +import ( + "context" + "errors" + "fmt" + "github.com/sirrobot01/decypharr/pkg/usenet" + "golang.org/x/net/webdav" + "io" + "net/http" + "os" + "path" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/rs/zerolog" + "github.com/sirrobot01/decypharr/internal/utils" + "github.com/sirrobot01/decypharr/pkg/version" +) + +type UsenetHandler struct { + name string + logger zerolog.Logger + usenet usenet.Usenet + URLBase string + RootPath string +} + +func NewUsenetHandler(name, urlBase string, usenet usenet.Usenet, logger zerolog.Logger) Handler { + h := &UsenetHandler{ + name: name, + usenet: usenet, + logger: logger, + URLBase: urlBase, + RootPath: path.Join(urlBase, "webdav", name), + } + return h +} + +func (hu *UsenetHandler) Type() string { + return "usenet" +} + +func (hu *UsenetHandler) Name() string { + return hu.name +} + +func (hu *UsenetHandler) Start(ctx context.Context) error { + return hu.usenet.Start(ctx) +} + +func (hu *UsenetHandler) Readiness(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + select { + case <-hu.usenet.IsReady(): + // WebDAV is ready, proceed + next.ServeHTTP(w, r) + default: + // WebDAV is still initializing + w.Header().Set("Retry-After", "5") + http.Error(w, "WebDAV service is initializing, please try again shortly", http.StatusServiceUnavailable) + } + }) +} + +// Mkdir implements webdav.FileSystem +func (hu *UsenetHandler) Mkdir(ctx context.Context, name string, perm os.FileMode) error { + return os.ErrPermission // Read-only filesystem +} + +// RemoveAll implements webdav.FileSystem +func (hu *UsenetHandler) RemoveAll(ctx context.Context, name string) error { + if !strings.HasPrefix(name, "/") { + name = "/" + name + } + name = utils.PathUnescape(path.Clean(name)) + rootDir := path.Clean(hu.RootPath) + + if name == rootDir { + return os.ErrPermission + } + + // Skip if it's version.txt + if name == path.Join(rootDir, "version.txt") { + return os.ErrPermission + } + + // Check if the name is a parent path + if _, ok := hu.isParentPath(name); ok { + return os.ErrPermission + } + + // Check if the name is a torrent folder + rel := strings.TrimPrefix(name, rootDir+"/") + parts := strings.Split(rel, "/") + if len(parts) == 2 && utils.Contains(hu.getParentItems(), parts[0]) { + nzb := hu.usenet.Store().GetByName(parts[1]) + if nzb == nil { + return os.ErrNotExist + } + // Remove the nzb from the store + if err := hu.usenet.Store().Delete(nzb.ID); err != nil { + hu.logger.Error().Err(err).Msgf("Failed to remove torrent %s", parts[1]) + return err + } + return nil + } + // If we reach here, it means the path is a file + if len(parts) >= 2 { + if utils.Contains(hu.getParentItems(), parts[0]) { + cached := hu.usenet.Store().GetByName(parts[1]) + if cached != nil && len(parts) >= 3 { + filename := filepath.Clean(path.Join(parts[2:]...)) + if file := cached.GetFileByName(filename); file != nil { + if err := hu.usenet.Store().RemoveFile(cached.ID, file.Name); err != nil { + hu.logger.Error().Err(err).Msgf("Failed to remove file %s from torrent %s", file.Name, parts[1]) + return err + } + // If the file was successfully removed, we can return nil + return nil + } + } + } + } + return nil +} + +// Rename implements webdav.FileSystem +func (hu *UsenetHandler) Rename(ctx context.Context, oldName, newName string) error { + return os.ErrPermission // Read-only filesystem +} + +func (hu *UsenetHandler) getTorrentsFolders(folder string) []os.FileInfo { + return hu.usenet.Store().GetListing(folder) +} + +func (hu *UsenetHandler) getParentItems() []string { + parents := []string{"__all__", "__bad__"} + + // version.txt + parents = append(parents, "version.txt") + return parents +} + +func (hu *UsenetHandler) getParentFiles() []os.FileInfo { + now := time.Now() + rootFiles := make([]os.FileInfo, 0, len(hu.getParentItems())) + for _, item := range hu.getParentItems() { + f := &FileInfo{ + name: item, + size: 0, + mode: 0755 | os.ModeDir, + modTime: now, + isDir: true, + } + if item == "version.txt" { + f.isDir = false + f.size = int64(len(version.GetInfo().String())) + } + rootFiles = append(rootFiles, f) + } + return rootFiles +} + +// GetChildren returns the os.FileInfo slice for “depth-1” children of cleanPath +func (hu *UsenetHandler) GetChildren(name string) []os.FileInfo { + + if name[0] != '/' { + name = "/" + name + } + name = utils.PathUnescape(path.Clean(name)) + root := path.Clean(hu.RootPath) + + // top‐level “parents” (e.g. __all__, torrents etc) + if name == root { + return hu.getParentFiles() + } + if parent, ok := hu.isParentPath(name); ok { + return hu.getTorrentsFolders(parent) + } + // torrent-folder level (e.g. /root/parentFolder/torrentName) + rel := strings.TrimPrefix(name, root+"/") + parts := strings.Split(rel, "/") + if len(parts) == 2 && utils.Contains(hu.getParentItems(), parts[0]) { + if u := hu.usenet.Store().GetByName(parts[1]); u != nil { + return hu.getFileInfos(u) + } + } + return nil +} + +func (hu *UsenetHandler) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (webdav.File, error) { + if !strings.HasPrefix(name, "/") { + name = "/" + name + } + name = utils.PathUnescape(path.Clean(name)) + rootDir := path.Clean(hu.RootPath) + metadataOnly := ctx.Value(metadataOnlyKey) != nil + now := time.Now() + + // 1) special case version.txt + if name == path.Join(rootDir, "version.txt") { + versionInfo := version.GetInfo().String() + return &UsenetFile{ + usenet: hu.usenet, + isDir: false, + content: []byte(versionInfo), + name: "version.txt", + size: int64(len(versionInfo)), + metadataOnly: metadataOnly, + modTime: now, + }, nil + } + + // 2) directory case: ask GetChildren + if children := hu.GetChildren(name); children != nil { + displayName := filepath.Clean(path.Base(name)) + if name == rootDir { + displayName = "/" + } + return &UsenetFile{ + usenet: hu.usenet, + isDir: true, + children: children, + name: displayName, + size: 0, + metadataOnly: metadataOnly, + modTime: now, + }, nil + } + + // 3) file‐within‐torrent case + // everything else must be a file under a torrent folder + rel := strings.TrimPrefix(name, rootDir+"/") + parts := strings.Split(rel, "/") + if len(parts) >= 2 { + if utils.Contains(hu.getParentItems(), parts[0]) { + cached := hu.usenet.Store().GetByName(parts[1]) + if cached != nil && len(parts) >= 3 { + filename := filepath.Clean(path.Join(parts[2:]...)) + if file := cached.GetFileByName(filename); file != nil { + return &UsenetFile{ + usenet: hu.usenet, + nzbID: cached.ID, + fileId: file.Name, + isDir: false, + name: file.Name, + size: file.Size, + metadataOnly: metadataOnly, + modTime: cached.AddedOn, + }, nil + } + } + } + } + return nil, os.ErrNotExist +} + +// Stat implements webdav.FileSystem +func (hu *UsenetHandler) Stat(ctx context.Context, name string) (os.FileInfo, error) { + f, err := hu.OpenFile(ctx, name, os.O_RDONLY, 0) + if err != nil { + return nil, err + } + return f.Stat() +} + +func (hu *UsenetHandler) getFileInfos(nzb *usenet.NZB) []os.FileInfo { + nzbFiles := nzb.GetFiles() + files := make([]os.FileInfo, 0, len(nzbFiles)) + + sort.Slice(nzbFiles, func(i, j int) bool { + return nzbFiles[i].Name < nzbFiles[j].Name + }) + + for _, file := range nzbFiles { + files = append(files, &FileInfo{ + name: file.Name, + size: file.Size, + mode: 0644, + modTime: nzb.AddedOn, + isDir: false, + }) + } + return files +} + +func (hu *UsenetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "GET": + hu.handleGet(w, r) + return + case "HEAD": + hu.handleHead(w, r) + return + case "OPTIONS": + hu.handleOptions(w, r) + return + case "PROPFIND": + hu.handlePropfind(w, r) + return + case "DELETE": + if err := hu.handleDelete(w, r); err == nil { + return + } + // fallthrough to default + } + handler := &webdav.Handler{ + FileSystem: hu, + LockSystem: webdav.NewMemLS(), + Logger: func(r *http.Request, err error) { + if err != nil { + hu.logger.Trace(). + Err(err). + Str("method", r.Method). + Str("path", r.URL.Path). + Msg("WebDAV error") + } + }, + } + handler.ServeHTTP(w, r) +} + +func (hu *UsenetHandler) isParentPath(urlPath string) (string, bool) { + parents := hu.getParentItems() + lastComponent := path.Base(urlPath) + for _, p := range parents { + if p == lastComponent { + return p, true + } + } + return "", false +} + +func (hu *UsenetHandler) serveDirectory(w http.ResponseWriter, r *http.Request, file webdav.File) { + var children []os.FileInfo + if f, ok := file.(*UsenetFile); ok { + children = f.children + } else { + var err error + children, err = file.Readdir(-1) + if err != nil { + http.Error(w, "Failed to list directory", http.StatusInternalServerError) + return + } + } + + // Clean and prepare the path + cleanPath := path.Clean(r.URL.Path) + parentPath := path.Dir(cleanPath) + showParent := cleanPath != "/" && parentPath != "." && parentPath != cleanPath + isBadPath := strings.HasSuffix(cleanPath, "__bad__") + _, canDelete := hu.isParentPath(cleanPath) + + // Prepare template data + data := struct { + Path string + ParentPath string + ShowParent bool + Children []os.FileInfo + URLBase string + IsBadPath bool + CanDelete bool + DeleteAllBadTorrentKey string + }{ + Path: cleanPath, + ParentPath: parentPath, + ShowParent: showParent, + Children: children, + URLBase: hu.URLBase, + IsBadPath: isBadPath, + CanDelete: canDelete, + DeleteAllBadTorrentKey: DeleteAllBadTorrentKey, + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := tplDirectory.ExecuteTemplate(w, "directory.html", data); err != nil { + return + } +} + +// Handlers + +func (hu *UsenetHandler) handlePropfind(w http.ResponseWriter, r *http.Request) { + handlePropfind(hu, hu.logger, w, r) +} + +func (hu *UsenetHandler) handleGet(w http.ResponseWriter, r *http.Request) { + fRaw, err := hu.OpenFile(r.Context(), r.URL.Path, os.O_RDONLY, 0) + if err != nil { + http.NotFound(w, r) + return + } + defer fRaw.Close() + + fi, err := fRaw.Stat() + if err != nil { + http.Error(w, "Server Error", http.StatusInternalServerError) + return + } + + if fi.IsDir() { + hu.serveDirectory(w, r, fRaw) + return + } + + // Set common headers + etag := fmt.Sprintf("\"%x-%x\"", fi.ModTime().Unix(), fi.Size()) + ext := path.Ext(fi.Name()) + w.Header().Set("ETag", etag) + w.Header().Set("Last-Modified", fi.ModTime().UTC().Format(http.TimeFormat)) + w.Header().Set("Content-Type", getContentType(ext)) + w.Header().Set("Connection", "keep-alive") + + // Handle File struct with direct streaming + if file, ok := fRaw.(*UsenetFile); ok { + if err := file.StreamResponse(w, r); err != nil { + var streamErr *streamError + if errors.As(err, &streamErr) { + // Handle client disconnections silently (just debug log) + if errors.Is(streamErr.Err, context.Canceled) || errors.Is(streamErr.Err, context.DeadlineExceeded) || streamErr.IsClientDisconnection { + return // Don't log as error or try to write response + } + + if streamErr.StatusCode > 0 && !hasHeadersWritten(w) { + return + } else { + hu.logger.Error(). + Err(streamErr.Err). + Str("path", r.URL.Path). + Msg("Stream error") + } + } else { + // Generic error + if !hasHeadersWritten(w) { + http.Error(w, "Stream error", http.StatusInternalServerError) + return + } else { + hu.logger.Error(). + Err(err). + Str("path", r.URL.Path). + Msg("Stream error after headers written") + } + } + return + } + return + } + + // Fallback to ServeContent for other webdav.File implementations + if rs, ok := fRaw.(io.ReadSeeker); ok { + http.ServeContent(w, r, fi.Name(), fi.ModTime(), rs) + } else { + w.Header().Set("Content-Length", fmt.Sprintf("%d", fi.Size())) + _, _ = io.Copy(w, fRaw) + } +} + +func (hu *UsenetHandler) handleHead(w http.ResponseWriter, r *http.Request) { + f, err := hu.OpenFile(r.Context(), r.URL.Path, os.O_RDONLY, 0) + if err != nil { + hu.logger.Error().Err(err).Str("path", r.URL.Path).Msg("Failed to open file") + http.NotFound(w, r) + return + } + defer func(f webdav.File) { + err := f.Close() + if err != nil { + return + } + }(f) + + fi, err := f.Stat() + if err != nil { + hu.logger.Error().Err(err).Msg("Failed to stat file") + http.Error(w, "Server Error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Length", fmt.Sprintf("%d", fi.Size())) + w.Header().Set("Last-Modified", fi.ModTime().UTC().Format(http.TimeFormat)) + w.Header().Set("Accept-Ranges", "bytes") + w.WriteHeader(http.StatusOK) +} + +func (hu *UsenetHandler) handleOptions(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Allow", "OPTIONS, GET, HEAD, PUT, DELETE, MKCOL, COPY, MOVE, PROPFIND") + w.WriteHeader(http.StatusOK) +} + +// handleDelete deletes a torrent by id, or all bad torrents if the id is DeleteAllBadTorrentKey +func (hu *UsenetHandler) handleDelete(w http.ResponseWriter, r *http.Request) error { + cleanPath := path.Clean(r.URL.Path) // Remove any leading slashes + + _, torrentId := path.Split(cleanPath) + if torrentId == "" { + return os.ErrNotExist + } + + if torrentId == DeleteAllBadTorrentKey { + return hu.handleDeleteAll(w) + } + + return hu.handleDeleteById(w, torrentId) +} + +func (hu *UsenetHandler) handleDeleteById(w http.ResponseWriter, nzID string) error { + cached := hu.usenet.Store().Get(nzID) + if cached == nil { + return os.ErrNotExist + } + + err := hu.usenet.Store().Delete(nzID) + if err != nil { + hu.logger.Error().Err(err).Str("nzbID", nzID).Msg("Failed to delete NZB") + http.Error(w, "Failed to delete NZB", http.StatusInternalServerError) + return err + } + w.WriteHeader(http.StatusNoContent) + return nil +} + +func (hu *UsenetHandler) handleDeleteAll(w http.ResponseWriter) error { + + w.WriteHeader(http.StatusNoContent) + return nil +} diff --git a/pkg/webdav/webdav.go b/pkg/webdav/webdav.go index 6a52b6b..29f297b 100644 --- a/pkg/webdav/webdav.go +++ b/pkg/webdav/webdav.go @@ -6,8 +6,12 @@ import ( "fmt" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" + "github.com/rs/zerolog" "github.com/sirrobot01/decypharr/internal/config" - "github.com/sirrobot01/decypharr/pkg/store" + "github.com/sirrobot01/decypharr/internal/logger" + "github.com/sirrobot01/decypharr/pkg/debrid/store" + "github.com/sirrobot01/decypharr/pkg/usenet" + "golang.org/x/net/webdav" "html/template" "net/http" "net/url" @@ -33,6 +37,10 @@ var ( } return strings.Join(segments, "/") }, + "split": strings.Split, + "sub": func(a, b int) int { + return a - b + }, "formatSize": func(bytes int64) string { const ( KB = 1024 @@ -84,21 +92,50 @@ func init() { chi.RegisterMethod("UNLOCK") } -type WebDav struct { - Handlers []*Handler - URLBase string +type Handler interface { + http.Handler + Start(ctx context.Context) error + Readiness(next http.Handler) http.Handler + Name() string + OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (webdav.File, error) + GetChildren(name string) []os.FileInfo + Type() string } -func New() *WebDav { +type WebDav struct { + Handlers []Handler + URLBase string + logger zerolog.Logger +} + +func New(debridCaches map[string]*store.Cache, usenet usenet.Usenet) *WebDav { urlBase := config.Get().URLBase w := &WebDav{ - Handlers: make([]*Handler, 0), + Handlers: make([]Handler, 0), URLBase: urlBase, + logger: logger.New("webdav"), } - for name, c := range store.Get().Debrid().Caches() { - h := NewHandler(name, urlBase, c, c.Logger()) + + // Set debrid handlers + for name, c := range debridCaches { + h := NewTorrentHandler(name, urlBase, c, c.Logger()) + if h == nil { + w.logger.Warn().Msgf("Debrid handler for %s is nil, skipping", name) + continue + } w.Handlers = append(w.Handlers, h) } + + // Set usenet handlers + if usenet != nil { + usenetHandler := NewUsenetHandler("usenet", urlBase, usenet, usenet.Logger()) + if usenetHandler != nil { + w.Handlers = append(w.Handlers, usenetHandler) + } else { + w.logger.Warn().Msg("Usenet handler is nil, skipping") + } + } + return w } @@ -119,9 +156,9 @@ func (wd *WebDav) Start(ctx context.Context) error { for _, h := range wd.Handlers { wg.Add(1) - go func(h *Handler) { + go func(h Handler) { defer wg.Done() - if err := h.cache.Start(ctx); err != nil { + if err := h.Start(ctx); err != nil { select { case errChan <- err: default: @@ -152,8 +189,8 @@ func (wd *WebDav) Start(ctx context.Context) error { func (wd *WebDav) mountHandlers(r chi.Router) { for _, h := range wd.Handlers { - r.Route("/"+h.Name, func(r chi.Router) { - r.Use(h.readinessMiddleware) + r.Route("/"+h.Name(), func(r chi.Router) { + r.Use(h.Readiness) r.Mount("/", h) }) // Mount to /name since router is already prefixed with /webdav } @@ -166,11 +203,7 @@ func (wd *WebDav) setupRootHandler(r chi.Router) { func (wd *WebDav) commonMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("DAV", "1, 2") w.Header().Set("Allow", "OPTIONS, PROPFIND, GET, HEAD, POST, PUT, DELETE, MKCOL, PROPPATCH, COPY, MOVE, LOCK, UNLOCK") - w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Access-Control-Allow-Methods", "OPTIONS, PROPFIND, GET, HEAD, POST, PUT, DELETE, MKCOL, PROPPATCH, COPY, MOVE, LOCK, UNLOCK") - w.Header().Set("Access-Control-Allow-Headers", "Depth, Content-Type, Authorization") next.ServeHTTP(w, r) }) @@ -181,7 +214,7 @@ func (wd *WebDav) handleGetRoot() http.HandlerFunc { w.Header().Set("Content-Type", "text/html; charset=utf-8") data := struct { - Handlers []*Handler + Handlers []Handler URLBase string }{ Handlers: wd.Handlers, @@ -205,7 +238,7 @@ func (wd *WebDav) handleWebdavRoot() http.HandlerFunc { children := make([]os.FileInfo, 0, len(wd.Handlers)) for _, h := range wd.Handlers { children = append(children, &FileInfo{ - name: h.Name, + name: h.Name(), size: 0, mode: 0755 | os.ModeDir, modTime: time.Now(), diff --git a/scripts/download-assets.js b/scripts/download-assets.js index f9a76ca..0bdb510 100755 --- a/scripts/download-assets.js +++ b/scripts/download-assets.js @@ -101,10 +101,10 @@ async function downloadAssets() { await downloadFile(download.url, download.path); } - console.log('\n✅ External assets downloaded successfully!'); + console.log('\nExternal assets downloaded successfully!'); } catch (error) { - console.error('💥 Error downloading assets:', error); + console.error('Error downloading assets:', error); process.exit(1); } } diff --git a/scripts/minify-js.js b/scripts/minify-js.js index 71e5cd7..1a90144 100644 --- a/scripts/minify-js.js +++ b/scripts/minify-js.js @@ -105,8 +105,8 @@ async function minifyAllJS() { if (processedFiles > 0) { const totalReduction = ((totalOriginal - totalMinified) / totalOriginal * 100).toFixed(1); - console.log(`\n✅ Successfully minified ${processedFiles}/${jsFiles.length} JavaScript file(s)`); - console.log(`📊 Total: ${(totalOriginal/1024).toFixed(1)}KB → ${(totalMinified/1024).toFixed(1)}KB (${totalReduction}% reduction)`); + console.log(`\nSuccessfully minified ${processedFiles}/${jsFiles.length} JavaScript file(s)`); + console.log(`Total: ${(totalOriginal/1024).toFixed(1)}KB → ${(totalMinified/1024).toFixed(1)}KB (${totalReduction}% reduction)`); } } catch (error) {