add vibeui player
Some checks failed
ci / test (push) Failing after 2m41s
fp/our CI/CD / build (push) Successful in 36s

This commit is contained in:
CJ_Clippy 2025-12-03 03:59:28 -08:00
parent a5433e7bd5
commit 9a708fce1d
58 changed files with 2534 additions and 1431 deletions

View File

@ -3,3 +3,32 @@ node_modules
pb_data pb_data
.DS_Store .DS_Store
.secrets .secrets
# Created by https://www.toptal.com/developers/gitignore/api/go
# Edit at https://www.toptal.com/developers/gitignore?templates=go
### Go ###
# If you prefer the allow list template instead of the deny list, see community template:
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
#
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work
# End of https://www.toptal.com/developers/gitignore/api/go

View File

@ -0,0 +1,46 @@
module futureporn
go 1.25.1
require github.com/pocketbase/pocketbase v0.34.0
require (
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/disintegration/imaging v1.6.2 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
github.com/dop251/base64dec v0.0.0-20231022112746-c6c9f9a96217 // indirect
github.com/dop251/goja v0.0.0-20251103141225-af2ceb9156d7 // indirect
github.com/dop251/goja_nodejs v0.0.0-20250409162600-f7acab6894b0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
github.com/ganigeorgiev/fexpr v0.5.0 // indirect
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/google/pprof v0.0.0-20251007162407-5df77e3f7d1d // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pocketbase/dbx v1.11.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/cobra v1.10.1 // indirect
github.com/spf13/pflag v1.0.10 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect
golang.org/x/image v0.33.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/oauth2 v0.33.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.0 // indirect
modernc.org/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.40.1 // indirect
)

141
services/pocketbase/go.sum Normal file
View File

@ -0,0 +1,141 @@
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8=
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
github.com/dop251/base64dec v0.0.0-20231022112746-c6c9f9a96217 h1:16iT9CBDOniJwFGPI41MbUDfEk74hFaKTqudrX8kenY=
github.com/dop251/base64dec v0.0.0-20231022112746-c6c9f9a96217/go.mod h1:eIb+f24U+eWQCIsj9D/ah+MD9UP+wdxuqzsdLD+mhGM=
github.com/dop251/goja v0.0.0-20251103141225-af2ceb9156d7 h1:jxmXU5V9tXxJnydU5v/m9SG8TRUa/Z7IXODBpMs/P+U=
github.com/dop251/goja v0.0.0-20251103141225-af2ceb9156d7/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
github.com/dop251/goja_nodejs v0.0.0-20250409162600-f7acab6894b0 h1:fuHXpEVTTk7TilRdfGRLHpiTD6tnT0ihEowCfWjlFvw=
github.com/dop251/goja_nodejs v0.0.0-20250409162600-f7acab6894b0/go.mod h1:Tb7Xxye4LX7cT3i8YLvmPMGCV92IOi4CDZvm/V8ylc0=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/ganigeorgiev/fexpr v0.5.0 h1:XA9JxtTE/Xm+g/JFI6RfZEHSiQlk+1glLvRK1Lpv/Tk=
github.com/ganigeorgiev/fexpr v0.5.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es=
github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew=
github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q=
github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20251007162407-5df77e3f7d1d h1:KJIErDwbSHjnp/SGzE5ed8Aol7JsKiI5X7yWKAtzhM0=
github.com/google/pprof v0.0.0-20251007162407-5df77e3f7d1d/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
github.com/pocketbase/pocketbase v0.34.0 h1:5W80PrGvkRYIMAIK90F7w031/hXgZVz1KSuCJqSpgJo=
github.com/pocketbase/pocketbase v0.34.0/go.mod h1:K/9z/Zb9PR9yW2Qyoc73jHV/EKT8cMTk9bQWyrzYlvI=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ=
golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
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.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
google.golang.org/appengine v1.6.5 h1:tycE03LOZYQNhDpS27tcQdAzLCVMaj7QT2SXxebnpCM=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.40.1 h1:VfuXcxcUWWKRBuP8+BR9L7VnmusMgBNNnBYGEe9w/iY=
modernc.org/sqlite v1.40.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

148
services/pocketbase/main.go Normal file
View File

@ -0,0 +1,148 @@
// @see https://github.com/pocketbase/pocketbase/blob/master/examples/base/main.go
package main
import (
"futureporn/src/handlers"
"log"
"net/http"
"os"
"path/filepath"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/plugins/ghupdate"
"github.com/pocketbase/pocketbase/plugins/jsvm"
"github.com/pocketbase/pocketbase/plugins/migratecmd"
"github.com/pocketbase/pocketbase/tools/hook"
"github.com/pocketbase/pocketbase/tools/osutils"
)
func main() {
app := pocketbase.New()
// ---------------------------------------------------------------
// Optional plugin flags:
// ---------------------------------------------------------------
var hooksDir string
app.RootCmd.PersistentFlags().StringVar(
&hooksDir,
"hooksDir",
"",
"the directory with the JS app hooks",
)
var hooksWatch bool
app.RootCmd.PersistentFlags().BoolVar(
&hooksWatch,
"hooksWatch",
true,
"auto restart the app on pb_hooks file change; it has no effect on Windows",
)
var hooksPool int
app.RootCmd.PersistentFlags().IntVar(
&hooksPool,
"hooksPool",
15,
"the total prewarm goja.Runtime instances for the JS app hooks execution",
)
var migrationsDir string
app.RootCmd.PersistentFlags().StringVar(
&migrationsDir,
"migrationsDir",
"",
"the directory with the user defined migrations",
)
var automigrate bool
app.RootCmd.PersistentFlags().BoolVar(
&automigrate,
"automigrate",
true,
"enable/disable auto migrations",
)
var publicDir string
app.RootCmd.PersistentFlags().StringVar(
&publicDir,
"publicDir",
defaultPublicDir(),
"the directory to serve static files",
)
var indexFallback bool
app.RootCmd.PersistentFlags().BoolVar(
&indexFallback,
"indexFallback",
true,
"fallback the request to index.html on missing static path, e.g. when pretty urls are used with SPA",
)
app.RootCmd.ParseFlags(os.Args[1:])
// ---------------------------------------------------------------
// Plugins and hooks:
// ---------------------------------------------------------------
// load jsvm (pb_hooks and pb_migrations)
jsvm.MustRegister(app, jsvm.Config{
MigrationsDir: migrationsDir,
HooksDir: hooksDir,
HooksWatch: hooksWatch,
HooksPoolSize: hooksPool,
})
// migrate command (with js templates)
migratecmd.MustRegister(app, app.RootCmd, migratecmd.Config{
TemplateLang: migratecmd.TemplateLangJS,
Automigrate: automigrate,
Dir: migrationsDir,
})
// GitHub selfupdate
ghupdate.MustRegister(app, app.RootCmd, ghupdate.Config{})
// route for generating hls playlists with BunnyCDN signed URLs
app.OnServe().BindFunc(func(se *core.ServeEvent) error {
se.Router.GET("/api/hls/{collectionId}/{recordId}/hls/{fileName}", handlers.PackageHandler)
return se.Next()
})
// route for direct downloads (patrons only)
app.OnServe().BindFunc(func(se *core.ServeEvent) error {
se.Router.GET("/api/download/{collectionId}/{recordId}/{fileName}", handlers.S3Download)
return se.Next()
})
// static route to serves files from the provided public dir
// (if publicDir exists and the route path is not already defined)
app.OnServe().Bind(&hook.Handler[*core.ServeEvent]{
Func: func(e *core.ServeEvent) error {
if !e.Router.HasRoute(http.MethodGet, "/{path...}") {
e.Router.GET("/{path...}", apis.Static(os.DirFS(publicDir), indexFallback))
}
return e.Next()
},
Priority: 999, // execute as latest as possible to allow users to provide their own route
})
app.OnFileDownloadRequest().BindFunc(handlers.HandleFileDownload)
if err := app.Start(); err != nil {
log.Fatal(err)
}
}
// the default pb_public dir location is relative to the executable
func defaultPublicDir() string {
if osutils.IsProbablyGoRun() {
return "./pb_public"
}
return filepath.Join(os.Args[0], "../pb_public")
}

View File

@ -1,6 +1,6 @@
{ {
"name": "futureporn", "name": "futureporn",
"version": "4.0.0", "version": "4.2.0",
"private": true, "private": true,
"description": "Dedication to the preservation of lewdtuber history", "description": "Dedication to the preservation of lewdtuber history",
"license": "Unlicense", "license": "Unlicense",

View File

@ -1,7 +1,5 @@
/// <reference path="../pb_data/types.d.ts" /> /// <reference path="../pb_data/types.d.ts" />
/** /**
* onFileDownloadRequest hook is triggered before each API File download request. Could be used to validate or modify the file response before returning it to the client. * onFileDownloadRequest hook is triggered before each API File download request. Could be used to validate or modify the file response before returning it to the client.
* @see https://pocketbase.io/docs/js-event-hooks/#onfiledownloadrequest * @see https://pocketbase.io/docs/js-event-hooks/#onfiledownloadrequest
@ -13,7 +11,12 @@
onFileDownloadRequest((event) => { onFileDownloadRequest((event) => {
// console.log('event', JSON.stringify(event)) console.log('>>> We got a file download request event', JSON.stringify(event))
console.log('the below the following is event.request');
console.log(JSON.stringify(event.request));
console.log('the above the previous line is event.request');
// e.app // e.app
// e.collection // e.collection
// e.record // e.record
@ -24,7 +27,7 @@ onFileDownloadRequest((event) => {
const securityKey = process.env?.BUNNY_TOKEN_KEY; const securityKey = process.env?.BUNNY_TOKEN_KEY;
const baseUrl = process.env?.BUNNY_ZONE_URL; const baseUrl = process.env?.BUNNY_ZONE_URL;
// console.log(`securityKey=${securityKey}, baseUrl=${baseUrl}`) console.log(`securityKey=${securityKey}, baseUrl=${baseUrl}`)
if (!securityKey) { if (!securityKey) {
console.error('BUNNY_TOKEN_KEY was missing from env'); console.error('BUNNY_TOKEN_KEY was missing from env');
@ -37,34 +40,52 @@ onFileDownloadRequest((event) => {
/** /**
* Generate a signed BunnyCDN URL. * Generate a signed BunnyCDN URL.
*
* @see https://support.bunny.net/hc/en-us/articles/360016055099-How-to-sign-URLs-for-BunnyCDN-Token-Authentication
*
* @param {string} securityKey - Your BunnyCDN security token * @param {string} securityKey - Your BunnyCDN security token
* @param {string} baseUrl - The base URL (protocol + host) * @param {string} inputUrl - The base URL (protocol + host + path + (optional) qs)
* @param {string} path - Path to the file (starting with /)
* @param {string} rawQuery - Raw query string, e.g., "width=500&quality=5"
* @param {number} expires - Unix timestamp for expiration * @param {number} expires - Unix timestamp for expiration
* @param {string} tokenPath - (optional) create a token that has access to any file within that path
*/ */
function signUrlCool(securityKey, baseUrl, path, rawQuery = "", expires) { function signUrlCool(securityKey, inputUrl, expires, tokenPath = '') {
if (!path.startsWith('/')) path = '/' + path; if (inputUrl.endsWith('/')) throw new Error(`url must not end with a slash. got inputUrl=${inputUrl}`);
if (baseUrl.endsWith('/')) throw new Error(`baseUrl must not end with a slash. got baseUrl=${baseUrl}`);
// reading query parameters
// let search = e.request.url.query().get("search")
const URL = event.request.url
let parsedURL = URL.parse(inputUrl); // this is pre-parsed. no need to parse again
let params = (URL.parse(parsedURL)).searchParams; // these are pre-parsed as event.request.url.rawQuery. no need to parse again
let signaturePath = '';
let parameterData = '';
let parameterDataUrl = '';
console.log(`eyy we got parsedURL=${parsedURL}`);
throw new Error('@todo');
// Build parameter string (sort keys alphabetically) // Build parameter string (sort keys alphabetically)
let parameterData = ""; // let parameterData = "";
if (rawQuery) { // if (rawQuery) {
const params = rawQuery // const params = rawQuery
.split("&") // .split("&")
.map(p => p.split("=")) // .map(p => p.split("="))
.filter(([key]) => key && key !== "token" && key !== "expires") // .filter(([key]) => key && key !== "token" && key !== "expires")
.sort(([a], [b]) => a.localeCompare(b)); // .sort(([a], [b]) => a.localeCompare(b));
if (params.length) { // if (params.length) {
parameterData = params.map(([k, v]) => `${k}=${v}`).join("&"); // parameterData = params.map(([k, v]) => `${k}=${v}`).join("&");
} // }
} // }
// Build hashable base // Build hashable base
const hashableBase = securityKey + path + expires + parameterData; const hashableBase = securityKey + path + expires + parameterData;
// console.log(`hashableBase`, hashableBase) console.log(`hashableBase`, hashableBase)
// Compute token using your $security.sha256 workflow // Compute token using your $security.sha256 workflow
const tokenH = $security.sha256(hashableBase); const tokenH = $security.sha256(hashableBase);
@ -79,6 +100,7 @@ onFileDownloadRequest((event) => {
let tokenUrl = baseUrl + path + "?token=" + token; let tokenUrl = baseUrl + path + "?token=" + token;
if (parameterData) tokenUrl += "&" + parameterData; if (parameterData) tokenUrl += "&" + parameterData;
tokenUrl += "&expires=" + expires; tokenUrl += "&expires=" + expires;
if (tokenPath) tokenUrl += "&token_path=" + tokenPath;
return tokenUrl; return tokenUrl;
} }
@ -87,12 +109,12 @@ onFileDownloadRequest((event) => {
const rawQuery = event.requestEvent.request.url.rawQuery; const rawQuery = event.requestEvent.request.url.rawQuery;
// console.log(`record: ${JSON.stringify(event.record)}`) console.log(`record: ${JSON.stringify(event.record)}`)
// // console.log(`collection: ${JSON.stringify(event.collection)}`) // console.log(`collection: ${JSON.stringify(event.collection)}`)
// console.log(`app: ${JSON.stringify(event.app)}`) console.log(`app: ${JSON.stringify(event.app)}`)
// console.log(`fileField: ${JSON.stringify(event.fileField)}`) console.log(`fileField: ${JSON.stringify(event.fileField)}`)
// console.log(`servedPath: ${JSON.stringify(event.servedPath)}`) console.log(`servedPath: ${JSON.stringify(event.servedPath)}`)
// console.log(`servedName: ${JSON.stringify(event.servedName)}`) console.log(`servedName: ${JSON.stringify(event.servedName)}`)
// Our job here is to take the servedPath, and sign it using bunnycdn method // Our job here is to take the servedPath, and sign it using bunnycdn method
// Then serve a 302 redirect instead of serving the file proxied thru PB // Then serve a 302 redirect instead of serving the file proxied thru PB
@ -100,8 +122,8 @@ onFileDownloadRequest((event) => {
const path = event.servedPath; const path = event.servedPath;
const expires = Math.round(Date.now() / 1000) + 7 * 24 * 3600; // 7 days const expires = Math.round(Date.now() / 1000) + 7 * 24 * 3600; // 7 days
const signedUrl = signUrlCool(securityKey, baseUrl, path, rawQuery, expires); const signedUrl = signUrlCool(securityKey, baseUrl, path, rawQuery, expires);
// console.log(`rawQUery`, rawQuery, 'path', path); console.log(`rawQuery`, rawQuery, 'path', path);
// console.log(`signedUrl=${signedUrl}`); console.log(`signedUrl=${signedUrl}`);
// This redirect is a tricky thing. We do this to avoid proxying file requests via our pocketbase origin server. // This redirect is a tricky thing. We do this to avoid proxying file requests via our pocketbase origin server.
// The idea is to reduce load. // The idea is to reduce load.

View File

@ -71,8 +71,9 @@
<footer class="footer mt-5"> <footer class="footer mt-5">
<div class="content has-text-centered"> <div class="content has-text-centered">
<p> <p>
<strong>Futureporn <%= meta('version') %></strong> made with love by <a href="https://cjclippy.carrd.co/">@CJ_Clippy</a>. <strong>🔞💦 Futureporn <%= meta('version') %></strong> made with love by <a href="https://cjclippy.carrd.co/">@CJ_Clippy</a>.
</p> </p>
<p><i><a href="/about">Dedication to the preservation of lewdtuber history</a></i></p>
<div class="container"> <div class="container">

View File

@ -0,0 +1,19 @@
<div class="content">
<h1 class="heading is-1">About</h1>
<p><b>R18</b>. For adults only.</p>
<p><i>Dedication to the preservation of VTuber history.</i></p>
<h2 class="heading is-2">Mission</h2>
<p>We archive what would otherwise disappear. Streams vanish, accounts get banned, VODs get wiped. We're here to make sure VTuber culture doesn't evaporate overnight.</p>
<p>Our goal is to document creators' work so fans, researchers, and future communities can look back on it.</p>
<p>We record and share and want our favorite lewdtuber moments to last long into the future.</p>
<h2 class="heading is-2">Our patrons keep us going</h2>
<p>We couldn't do this work without continuing support from our <a href="/patrons">patrons</a></p>
</div>

View File

@ -31,7 +31,7 @@
<% providers.forEach(provider => { %> <% providers.forEach(provider => { %>
<form method="POST" action="/auth/oauth/login"> <form method="POST" action="/auth/oauth/login">
<input type="hidden" name="provider" value="<%=provider.name%>"> <input type="hidden" name="provider" value="<%=provider.name%>">
<button class="button" type="submit">Log in with <%=provider.displayName%></button> <button class="button is-primary" type="submit">Log in with <%=provider.displayName%></button>
</form> </form>
<% }) %> <% }) %>
<% if (providers.length === 0) { %> <% if (providers.length === 0) { %>

View File

@ -1,3 +1,6 @@
<% const buttplugSVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="-5 -10 51.480372 52.012249"><path d="m 20.221554,24.21173 3.6797,3.6797 c 0.53906,0.53906 0.73047,1.3281 0.48828,2.058599 l -1.6211,4.878901 c -0.51953,1.558599 -0.30859,3.261698 0.578121,4.6719 0.980469,1.539099 2.952215,2.636214 4.730499,2.5 5.799113,-1.246595 18.520815,-15.042558 18.4025,-18.562601 -0.200697,-2.557237 -2.4414,-5.441399 -5.4414,-5.441399 -0.57813,0 -1.1602,0.08984 -1.7188,0.28125 -1.808761,0.530862 -3.664125,1.462123 -5.48046,1.71874 -0.533759,0.07541 -1.078099,-0.21094 -1.4492,-0.57812 l -3.6797,-3.679701 C 31.639694,10.239 30.889819,4.5008743 26.659194,0.2699988 22.42882,-3.960626 11.331194,-10.000001 3.4091936,-10 c -16.3546746,0.5198707 -4.9007052,25.702135 1.3475999,32.1723 4.7260967,4.893907 9.7749655,4.584497 15.4647605,2.03943 z" fill="currentColor"/></svg>' %>
<% if (!data?.user) { %> <% if (!data?.user) { %>
<div class="notification is-warning"> <div class="notification is-warning">
<span class="icon-text"> <span class="icon-text">
@ -16,9 +19,9 @@
</div> </div>
<% } else { %> <% } else { %>
<div class="players" <% if (data?.user?.get('patron')) { %> data-signals="{'selected':'cdn1'}" <% } else { %> data-signals="{'selected':'cdn2'}" <% } %>> <div class="players" <% if (data?.user?.get('patron')) { %> data-signals="{'selected':'vibeui'}" <% } else { %> data-signals="{'selected':'cdn2'}" <% } %>>
<!-- CDN2 Player (B2) --> <%# CDN2 Player (B2) %>
<div class="b2-player" data-show="$selected == 'cdn2'"> <div class="b2-player" data-show="$selected == 'cdn2'">
<video controls <% if (!data?.user?.get('patron')) { %> preload="none" <% } %>> <video controls <% if (!data?.user?.get('patron')) { %> preload="none" <% } %>>
<% if (data.vod?.get('videoSrcB2')) { %> <% if (data.vod?.get('videoSrcB2')) { %>
@ -29,22 +32,115 @@
</video> </video>
</div> </div>
<!-- CDN1 Player (Mux, patrons only) -->
<% if (data?.user?.get('patron')) { %> <% if (data?.user?.get('patron')) { %>
<%# CDN1 Player (Mux, patrons only) %>
<div class="mux-player" data-show="$selected == 'cdn1'"> <div class="mux-player" data-show="$selected == 'cdn1'">
<script src="https://cdn.jsdelivr.net/npm/@mux/mux-player" defer></script> <script src="https://cdn.jsdelivr.net/npm/@mux/mux-player" defer></script>
<mux-player playback-id="<%= data.vod?.get('muxPlaybackId') %>" playback-token="<%= data.vod?.get('muxPlaybackToken') %>"></mux-player> <mux-player playback-id="<%= data.vod?.get('muxPlaybackId') %>" playback-token="<%= data.vod?.get('muxPlaybackToken') %>"></mux-player>
</div> </div>
<%# vibeui player (patrons only) %>
<% if (
data?.user?.get('patron') &&
data?.vod?.get('funscriptThrust') || data?.vod?.get('funscriptVibrate') &&
data?.vod?.get('hlsPlaylist')
) { %>
<div class="vibeui-player" data-show="$selected == 'vibeui'">
<style>
.vjs-menu-button.vjs-funscript-selector button {
/* match control size */
display: flex;
/* center contents */
align-items: center;
justify-content: center;
padding: 0;
color: white;
}
.vjs-menu-button.vjs-funscript-selector button::before {
content: "";
display: block;
width: 1.5em;
height: 1.5em;
background: no-repeat center/contain url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='-5 -10 51.480372 52.012249'><path d='m 20.221554,24.21173 3.6797,3.6797 c 0.53906,0.53906 0.73047,1.3281 0.48828,2.058599 l -1.6211,4.878901 c -0.51953,1.558599 -0.30859,3.261698 0.578121,4.6719 0.980469,1.539099 2.952215,2.636214 4.730499,2.5 5.799113,-1.246595 18.520815,-15.042558 18.4025,-18.562601 -0.200697,-2.557237 -2.4414,-5.441399 -5.4414,-5.441399 -0.57813,0 -1.1602,0.08984 -1.7188,0.28125 -1.808761,0.530862 -3.664125,1.462123 -5.48046,1.71874 -0.533759,0.07541 -1.078099,-0.21094 -1.4492,-0.57812 l -3.6797,-3.679701 C 31.639694,10.239 30.889819,4.5008743 26.659194,0.2699988 22.42882,-3.960626 11.331194,-10.000001 3.4091936,-10 c -16.3546746,0.5198707 -4.9007052,25.702135 1.3475999,32.1723 4.7260967,4.893907 9.7749655,4.584497 15.4647605,2.03943 z' fill='white'/></svg>");
}
</style>
<link href="https://cdn.jsdelivr.net/npm/video.js@8/dist/video-js.min.css" rel="stylesheet" />
<script src="https://cdn.jsdelivr.net/npm/video.js@8.23.4/dist/video.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/buttplug@3.2.2/dist/web/buttplug.min.js"></script>
<script src="/Funscripts.js"></script>
<script>
function collectFunscripts() {
return Array.from(document.querySelectorAll('.funscript'))
.map(div => {
const url = div.getAttribute('data-url');
if (!url) return null;
const name = url.split('/').pop().split('?')[0]; // filename without query
return {
name,
url
};
})
.filter(Boolean); // remove nulls
}
window.HELP_IMPROVE_VIDEOJS = false; // disable videojs tracking
document.addEventListener("DOMContentLoaded", () => {
const player = videojs('vibeplayer');
player.ready(() => {
console.log('setting up plugins')
const funscripts = collectFunscripts()
console.log(funscripts)
const funscriptsOptions = {
buttplugClientName: "futureporn.net",
debug: true,
funscripts,
}
player.funscriptPlayer(funscriptsOptions);
// let qualityLevels = player.qualityLevels();
// console.log(qualityLevels)
// player.hlsQualitySelector({
// displayCurrentQuality: true,
// });
})
})
</script>
<video id="vibeplayer" class="video-js vjs-fluid" controls preload="auto" poster="/api/files/vods/<%= data.vod?.get('id') %>/<%= data.vod?.get('thumbnail') %>" data-playlist="<%= data.vod?.get('hlsPlaylist') %>">
<source src="/api/hls/<%= data.vod?.get('hlsPlaylist') %>" type="application/x-mpegURL">
<track kind="captions" src="<%= data.vod?.get('asrVtt') %>" srclang="en" label="English" default>
<div class="funscript" data-url="/api/files/vods/<%= data.vod?.get('id') %>/<%= data.vod?.get('funscriptVibrate') %>"></div>
<div class="funscript" data-url="/api/files/vods/<%= data.vod?.get('id') %>/<%= data.vod?.get('funscriptThrust') %>"></div>
<p class="vjs-no-js">
To view this video please enable JavaScript, and consider upgrading to a
web browser that
<a href="https://videojs.com/html5-video-support/" target="_blank">
supports HTML5 video
</a>
</p>
</video>
</div>
<% } %> <%# /if %>
<% } %> <% } %>
</div> </div>
<!-- Player toggle buttons --> <%# Player toggle buttons %>
<nav class="level mt-5"> <nav class="level mt-5">
<div class="level-left"> <div class="level-left">
<% if (data?.vod?.get('muxAssetId')) { %> <% if (data?.vod?.get('muxAssetId')) { %>
<% if (data?.user?.get('patron')) { %> <% if (data?.user?.get('patron')) { %>
<button class="button is-success" data-on-click="$selected = 'vibeui'">vibe player</button>
<button class="button is-success" data-on-click="$selected = 'cdn1'">CDN1 player</button> <button class="button is-success" data-on-click="$selected = 'cdn1'">CDN1 player</button>
<% } else { %> <% } else { %>
<button disabled class="button is-danger">vibe player (patrons only)</button>
<button disabled class="button is-danger">CDN1 player (patrons only)</button> <button disabled class="button is-danger">CDN1 player (patrons only)</button>
<% } %> <% } %>
<% } %> <% } %>
@ -97,12 +193,109 @@
<div class="p-2 level"><%- data.vod?.get('notes') %></div> <div class="p-2 level"><%- data.vod?.get('notes') %></div>
<% } %> <% } %>
<% if (data.vod?.get('thumbnail')) { %>
<p><b id="thumbnail">Thumbnail:</b></p> <% if (data.vod?.get('funscriptThrust') || data.vod?.get('funscriptVibrate')) { %>
<figure class="image"> <p><b>Funscripts</b> <i class="icon is-small"><%- buttplugSVG %></i></p>
<img src="/api/files/vods/<%= data.vod?.get('id') %>/<%= data.vod?.get('thumbnail') %>" />
</figure> <div class="buttons">
<% if (data.vod?.get('funscriptThrust') && data?.user?.get('patron')) { %>
<a class="funscript-thrust button" href="/api/download/<%= data.vod.get('funscriptThrust') %>">
thrust
</a>
<% } else { %>
<button class="button" disabled>thrust funscript (patrons only)</button>
<% } %>
<% if (data.vod?.get('funscriptVibrate') && data?.user?.get('patron')) { %>
<a class="funscript-vibrate button" href="/api/download/<%= data.vod.get('funscriptVibrate') %>">
vibrate
</a>
<% } else { %>
<button class="button" disabled>vibrate funscript (patrons only)</button>
<% } %>
</div>
<% if (data?.user?.get('patron')) { %>
<div class="notification">
<div class="content">
To get started with Futureporn vibrator integration, follow these steps.
<ol type="1" class="is-lower-alpha">
<li>Download and run <a target="_blank" href="https://intiface.com/central/">Intiface Central</a> on your device.</li>
<li>Pair your sex toy with Intiface Central.</li>
<li>Reload this page.</li>
<li>Click the vibe player button.</li>
<li>(optional) Assign funscripts to each of your sex toy's actuators in the video menu.</li>
<li>Play the video. Your vibrator will activate when Lovense UI is present in the video.</li>
</ol>
<figure class="image is-128x128">
<img src="/vibrator-controls.png" />
</figure>
</div>
</div>
<% } %> <% } %>
<% } %>
<% if (data.vod?.get('thumbnail')) { %>
<div class="mb-5">
<p><b id="thumbnail">Thumbnail:</b></p>
<figure class="image">
<img width="830" height="465" src="/api/files/vods/<%= data.vod?.get('id') %>/<%= data.vod?.get('thumbnail') %>" />
</figure>
</div>
<% } %>
<h2 id="advanced" class="title is-2">Advanced VOD Details</h2>
<h3 class="title is-3">Download links</h3>
<div class="mb-5">
<% if (data?.user?.get('patron')) { %>
<p><b>HLS Playlist:</b> <a href="/api/hls/<%= data.vod?.get('hlsPlaylist') %>"><%= data.vod?.get('hlsPlaylist') %> </a></p>
<p><b>Source Video: </b><a href="/api/download/<%= data.vod?.get('sourceVideo') %>"><%= data.vod?.get('sourceVideo') %> </a></p>
</p>
<% } else { %>
<p><b>HLS Playlist:</b> <span><i>(patrons only)</i></span></p>
<p><b>Source Video: </b> <span><i>(patrons only)</i></span></p>
</p>
<% } %>
</div>
<% if (data?.vod.get('audioIntegratedLufs')) { %>
<h3 id="audio-analysis" class="title is-3">Audio Analysis</h3>
<div class="ml-5 mt-5 mb-5">
<div class="content">
<p>
<abbr title="Loudness Units relative to Full Scale - the overall perceived loudness of the audio">LUFS-I</abbr>
<strong><%= data?.vod.get('audioIntegratedLufs') %></strong>
</p>
<p>
<abbr title="Loudness Range - the dynamic range of the audio in LUFS">LRA</abbr>
<strong><%= data?.vod.get('audioLoudnessRange') %></strong>
</p>
<p>
<abbr title="True Peak: the maximum instantaneous signal level in dBTP">TP</abbr>
<strong><%= data?.vod.get('audioTruePeak') %></strong>
</p>
</div>
<% } %>
</div>
</div> <%# /.vod-details %>
</div> </div>
<div class="mb-5"></div> <div class="mb-5"></div>

View File

@ -4,5 +4,7 @@
</script> </script>
<h3>Vtubers</h3> <div class="content">
<%- include('vtuber-list.ejs', { vtubers }) %> <h1 class="heading is-1">Vtubers</h1>
<%- include('vtuber-list.ejs', { vtubers }) %>
</div>

View File

@ -1,3 +1,5 @@
<% const buttplugSVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="-5 -10 51.480372 52.012249"><path d="m 20.221554,24.21173 3.6797,3.6797 c 0.53906,0.53906 0.73047,1.3281 0.48828,2.058599 l -1.6211,4.878901 c -0.51953,1.558599 -0.30859,3.261698 0.578121,4.6719 0.980469,1.539099 2.952215,2.636214 4.730499,2.5 5.799113,-1.246595 18.520815,-15.042558 18.4025,-18.562601 -0.200697,-2.557237 -2.4414,-5.441399 -5.4414,-5.441399 -0.57813,0 -1.1602,0.08984 -1.7188,0.28125 -1.808761,0.530862 -3.664125,1.462123 -5.48046,1.71874 -0.533759,0.07541 -1.078099,-0.21094 -1.4492,-0.57812 l -3.6797,-3.679701 C 31.639694,10.239 30.889819,4.5008743 26.659194,0.2699988 22.42882,-3.960626 11.331194,-10.000001 3.4091936,-10 c -16.3546746,0.5198707 -4.9007052,25.702135 1.3475999,32.1723 4.7260967,4.893907 9.7749655,4.584497 15.4647605,2.03943 z" fill="currentColor"/></svg>' %>
<% if (Array.isArray(data.vods.items) && data.vods.items.length > 0) { %> <% if (Array.isArray(data.vods.items) && data.vods.items.length > 0) { %>
<div class="table-container"> <div class="table-container">
<table class="table is-striped is-hoverable is-fullwidth"> <table class="table is-striped is-hoverable is-fullwidth">
@ -8,6 +10,7 @@
<th>Thumbnail</th> <th>Thumbnail</th>
<th>Torrent</th> <th>Torrent</th>
<th>Magnet Link</th> <th>Magnet Link</th>
<th>Funscript</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -60,7 +63,17 @@
<path fill="#f8312f" d="M11 23v-7.94c0-2.75 2.2-5.04 4.95-5.06c2.78-.03 5.05 2.23 5.05 5v8h8v-8c0-7.18-5.82-13-13-13S3 7.82 3 15v8z" /> <path fill="#f8312f" d="M11 23v-7.94c0-2.75 2.2-5.04 4.95-5.06c2.78-.03 5.05 2.23 5.05 5v8h8v-8c0-7.18-5.82-13-13-13S3 7.82 3 15v8z" />
</g> </g>
</svg> </svg>
</span></a> </span>
</a>
<% } %>
</td>
<td>
<% if (vod?.funscriptVibrate || vod?.vunscriptThrust) { %>
<a target="_blank" href="/vods/<%= vod.id %>#funscripts">
<span class="icon">
<%- buttplugSVG %>
</span>
</a>
<% } %> <% } %>
</td> </td>
</tr> </tr>

View File

@ -0,0 +1,29 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_144770472")
// add field
collection.fields.addAt(17, new Field({
"hidden": false,
"id": "file3704076214",
"maxSelect": 1,
"maxSize": 0,
"mimeTypes": [],
"name": "torrent",
"presentable": false,
"protected": false,
"required": false,
"system": false,
"thumbs": [],
"type": "file"
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_144770472")
// remove field
collection.fields.removeById("file3704076214")
return app.save(collection)
})

View File

@ -0,0 +1,48 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_144770472")
// add field
collection.fields.addAt(18, new Field({
"hidden": false,
"id": "file2376425770",
"maxSelect": 1,
"maxSize": 0,
"mimeTypes": [],
"name": "funscriptVibrate",
"presentable": false,
"protected": false,
"required": false,
"system": false,
"thumbs": [],
"type": "file"
}))
// add field
collection.fields.addAt(19, new Field({
"hidden": false,
"id": "file4278982190",
"maxSelect": 1,
"maxSize": 0,
"mimeTypes": [],
"name": "funscriptThrust",
"presentable": false,
"protected": false,
"required": false,
"system": false,
"thumbs": [],
"type": "file"
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_144770472")
// remove field
collection.fields.removeById("file2376425770")
// remove field
collection.fields.removeById("file4278982190")
return app.save(collection)
})

View File

@ -0,0 +1,29 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_144770472")
// add field
collection.fields.addAt(20, new Field({
"autogeneratePattern": "",
"hidden": false,
"id": "text3367203181",
"max": 0,
"min": 0,
"name": "hlsPlaylist",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_144770472")
// remove field
collection.fields.removeById("text3367203181")
return app.save(collection)
})

View File

@ -0,0 +1,48 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_144770472")
// remove field
collection.fields.removeById("text3367203181")
// add field
collection.fields.addAt(20, new Field({
"hidden": false,
"id": "file3367203181",
"maxSelect": 1,
"maxSize": 0,
"mimeTypes": [],
"name": "hlsPlaylist",
"presentable": false,
"protected": false,
"required": false,
"system": false,
"thumbs": [],
"type": "file"
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_144770472")
// add field
collection.fields.addAt(20, new Field({
"autogeneratePattern": "",
"hidden": false,
"id": "text3367203181",
"max": 0,
"min": 0,
"name": "hlsPlaylist",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
}))
// remove field
collection.fields.removeById("file3367203181")
return app.save(collection)
})

View File

@ -0,0 +1,48 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_144770472")
// remove field
collection.fields.removeById("file3367203181")
// add field
collection.fields.addAt(20, new Field({
"autogeneratePattern": "",
"hidden": false,
"id": "text3367203181",
"max": 0,
"min": 0,
"name": "hlsPlaylist",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_144770472")
// add field
collection.fields.addAt(20, new Field({
"hidden": false,
"id": "file3367203181",
"maxSelect": 1,
"maxSize": 0,
"mimeTypes": [],
"name": "hlsPlaylist",
"presentable": false,
"protected": false,
"required": false,
"system": false,
"thumbs": [],
"type": "file"
}))
// remove field
collection.fields.removeById("text3367203181")
return app.save(collection)
})

View File

@ -0,0 +1,48 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_144770472")
// remove field
collection.fields.removeById("file2376425770")
// remove field
collection.fields.removeById("file4278982190")
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_144770472")
// add field
collection.fields.addAt(18, new Field({
"hidden": false,
"id": "file2376425770",
"maxSelect": 1,
"maxSize": 0,
"mimeTypes": [],
"name": "funscriptVibrate",
"presentable": false,
"protected": false,
"required": false,
"system": false,
"thumbs": [],
"type": "file"
}))
// add field
collection.fields.addAt(19, new Field({
"hidden": false,
"id": "file4278982190",
"maxSelect": 1,
"maxSize": 0,
"mimeTypes": [],
"name": "funscriptThrust",
"presentable": false,
"protected": false,
"required": false,
"system": false,
"thumbs": [],
"type": "file"
}))
return app.save(collection)
})

View File

@ -0,0 +1,105 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_144770472")
// add field
collection.fields.addAt(19, new Field({
"autogeneratePattern": "",
"hidden": false,
"id": "text2376425770",
"max": 0,
"min": 0,
"name": "funscriptVibrate",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
}))
// add field
collection.fields.addAt(20, new Field({
"autogeneratePattern": "",
"hidden": false,
"id": "text4278982190",
"max": 0,
"min": 0,
"name": "funscriptThrust",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
}))
// add field
collection.fields.addAt(21, new Field({
"autogeneratePattern": "",
"hidden": false,
"id": "text7357270",
"max": 0,
"min": 0,
"name": "audioIntegratedLufs",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
}))
// add field
collection.fields.addAt(22, new Field({
"autogeneratePattern": "",
"hidden": false,
"id": "text513636770",
"max": 0,
"min": 0,
"name": "audioLoudnessRange",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
}))
// add field
collection.fields.addAt(23, new Field({
"autogeneratePattern": "",
"hidden": false,
"id": "text2917960678",
"max": 0,
"min": 0,
"name": "audioTruePeak",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_144770472")
// remove field
collection.fields.removeById("text2376425770")
// remove field
collection.fields.removeById("text4278982190")
// remove field
collection.fields.removeById("text7357270")
// remove field
collection.fields.removeById("text513636770")
// remove field
collection.fields.removeById("text2917960678")
return app.save(collection)
})

View File

@ -0,0 +1,305 @@
const MenuButton = videojs.getComponent('MenuButton');
const VideoJsMenuItem = videojs.getComponent('MenuItem');
/* ------------------------- Menu Components ------------------------- */
class ActuatorMenuButton extends MenuButton {
constructor(player, { actuator, funscripts = [], onAssign } = {}) {
super(player, {
title: actuator?.name ?? 'Actuator',
name: 'ActuatorMenuButton',
actuator,
funscripts,
onAssign,
});
}
createItems() {
const { actuator, funscripts, onAssign } = this.options_ || {};
if (!actuator || !funscripts?.length) return [];
return funscripts.map(funscript =>
new FunscriptMenuItem(this.player_, {
actuator,
funscript,
onAssign,
})
);
}
}
class FunscriptMenuItem extends VideoJsMenuItem {
constructor(player, { actuator, funscript, onAssign } = {}) {
super(player, { label: funscript.name, selectable: true });
this.actuator = actuator;
this.funscript = funscript;
this.onAssign = onAssign;
// Pre-select if this actuator already has this funscript assigned
this.selected(this.actuator.assignedFunscript?.url === this.funscript.url);
this.on('click', () => {
if (!this.actuator || !this.onAssign) return;
// Use centralized assignment method
this.onAssign?.(this, this.actuator, this.funscript);
});
}
}
/* ------------------------- Helper ------------------------- */
function pickInitialFunscript(actuatorType, availableFunscripts) {
actuatorType = actuatorType.toLowerCase();
if (actuatorType.includes('vibrator')) {
return availableFunscripts.find(f => f.name.includes('vibrate')) ?? availableFunscripts[0];
}
if (actuatorType.includes('thrust')) {
return availableFunscripts.find(f => f.name.includes('thrust')) ?? availableFunscripts[0];
}
return availableFunscripts[0];
}
/* ------------------------- Main Plugin ------------------------- */
class FunscriptPlayer {
constructor(player, { debug = false, funscripts, ...options } = {}) {
this.player = player;
this.debug = debug;
this.funscriptCache = new Map(); // key: url, value: parsed funscript
this.funscripts = (funscripts || []).map(f => {
if (typeof f === 'string') {
const url = f;
const name = url.split('/').pop().split('?')[0];
return { name, url };
}
return f;
});
this.options = options;
this.menuButton = null;
this.devices = [];
player.on('toyConnected', () => this.refreshMenu());
player.on('toyDisconnected', () => this.refreshMenu());
player.on('pause', () => this.stopAllDevices());
}
async init() {
const connector = new window.buttplug.ButtplugBrowserWebsocketClientConnector("ws://localhost:12345");
this.client = new window.buttplug.ButtplugClient(this.options?.buttplugClientName || "Funscripts VideoJS Client");
this.client.addListener("deviceadded", device => {
this.addDevice(device);
this.debug && console.log("Device added:", device.name, device);
this.player.trigger('toyConnected');
});
this.client.addListener("deviceremoved", device => {
this.removeDevice(device);
this.debug && console.log("Device removed:", device.name);
this.player.trigger('toyDisconnected');
});
try {
await this.client.connect(connector);
await this.client.startScanning();
this.initVideoListeners();
this.debug && console.log("[buttplug] Connected and scanning");
} catch (err) {
console.error("[buttplug] Connection error:", err);
}
}
async loadFunscript(funscript) {
try {
const response = await fetch(funscript.url);
if (!response.ok) throw new Error(`Failed to load ${funscript.name}`);
return await response.json();
} catch (err) {
console.error("[funscripts] Error loading funscript:", funscript.name, err);
return null;
}
}
getFunscriptValueAtTime(funscriptData, timeMs) {
if (!funscriptData?.actions?.length) return null;
let lastAction = null;
for (const action of funscriptData.actions) {
if (action.at <= timeMs) {
lastAction = action;
} else {
break;
}
}
return lastAction ? lastAction.pos / 100 : null; // normalize 01
}
initVideoListeners() {
this.player.on('pause', () => {
this.debug && console.log('Video paused. Stopping devices...');
this.stopAllDevices();
});
this.player.on('timeupdate', () => {
if (this.player.paused() || this.player.ended()) return;
const currentTime = this.player.currentTime() * 1000;
this.devices.forEach(device => {
device.actuators.forEach(actuator => {
const fun = actuator.assignedFunscript;
if (!fun) return;
const funscriptData = this.funscriptCache.get(fun.url);
if (!funscriptData) return;
const position = this.getFunscriptValueAtTime(funscriptData, currentTime);
this.debug && console.log(`${fun.name} position=${position}`);
if (position !== null) {
this.sendToDevice(device, actuator, position);
}
});
});
});
}
async stopAllDevices() {
if (!this.client || !this.client.devices) return;
for (const [deviceIndex, device] of this.client.devices.entries()) {
const scalarCmds = device.messageAttributes?.ScalarCmd || [];
for (const cmd of scalarCmds) {
try {
await this.sendToDevice(device, {
index: cmd.Index,
name: `${device.name} - ${cmd.ActuatorType} #${cmd.Index + 1}`
}, 0);
} catch (err) {
console.warn(`[buttplug] Failed to stop ${device.name}`, err);
}
}
}
}
async sendToDevice(device, actuator, position) {
try {
this.client.devices[device.index].vibrate(position);
if (this.debug) {
console.log(`[buttplug] Sent to ${actuator.name}:`, position);
}
} catch (err) {
console.error("[buttplug] Error sending command:", err);
}
}
/* ------------------------- Device Tracking ------------------------- */
addDevice(device) {
const scalarCmds = device._deviceInfo?.DeviceMessages?.ScalarCmd || [];
const actuators = scalarCmds.map((cmd, i) => {
const assignedFunscript = pickInitialFunscript(cmd.ActuatorType, this.funscripts);
if (assignedFunscript && !this.funscriptCache.has(assignedFunscript.url)) {
this.loadFunscript(assignedFunscript).then(data => {
if (data) this.funscriptCache.set(assignedFunscript.url, data);
});
}
return {
name: `${device.name} - ${cmd.ActuatorType} #${cmd.Index + 1}`,
type: cmd.ActuatorType,
index: cmd.Index,
assignedFunscript,
deviceIndex: this.devices.length, // track device index
};
});
this.devices.push({
name: device._deviceInfo.DeviceName,
index: this.devices.length,
actuators,
});
}
removeDevice(device) {
this.devices = this.devices.filter(d => d.name !== device.name);
}
/* ------------------------- Central Assignment ------------------------- */
async assignFunscript(deviceIndex, actuatorIndex, funscript) {
const actuator = this.devices[deviceIndex]?.actuators[actuatorIndex];
if (!actuator) return;
actuator.assignedFunscript = funscript;
if (!this.funscriptCache.has(funscript.url)) {
const data = await this.loadFunscript(funscript);
if (data) this.funscriptCache.set(funscript.url, data);
}
this.debug && console.log(`Assigned ${funscript.name} to ${actuator.name}`);
}
/* ------------------------- Menu Management ------------------------- */
createMenuButtons() {
if (this.menuButtons?.length) {
this.menuButtons.forEach(btn => this.player.controlBar.removeChild(btn));
}
this.menuButtons = [];
this.devices.forEach(device => {
device.actuators.forEach(actuator => {
const button = new ActuatorMenuButton(this.player, {
actuator,
funscripts: this.funscripts,
onAssign: async (item, act, funscript) => {
await this.assignFunscript(device.index, act.index, funscript);
const menuItems = item.parentComponent_.children()
.filter(c => c instanceof FunscriptMenuItem);
menuItems.forEach(i => {
i.selected(i.funscript.url === funscript.url);
});
}
});
const placementIndex = Math.max(
0,
this.options?.placementIndex ?? (this.player.controlBar.children().length - 2)
);
this.player.controlBar.addChild(button, { componentClass: 'funscriptSelector' }, placementIndex);
setTimeout(() => {
button.addClass('vjs-funscript-selector');
button.removeClass('vjs-hidden');
}, 0);
this.menuButtons.push(button);
});
});
}
refreshMenu() {
this.createMenuButtons();
}
}
/* ------------------------- Register Plugin ------------------------- */
videojs.registerPlugin("funscriptPlayer", async function (options) {
try {
const plugin = new FunscriptPlayer(this, options);
await plugin.init();
return plugin;
} catch (err) {
console.error("Funscripts plugin failed to initialize:", err);
}
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

View File

@ -0,0 +1,70 @@
package handlers
import (
"futureporn/src/misc"
"log"
"net/http"
"os"
"path"
"strings"
"github.com/pocketbase/pocketbase/core"
)
// getPathAllowedForMaster returns the pathAllowedRoute for master playlists,
// or an empty string if the request is not a master playlist.
func getPathAllowedForMaster(e *core.FileDownloadRequestEvent) string {
if strings.HasPrefix(e.ServedName, "master_") && strings.HasSuffix(e.ServedName, ".m3u8") {
dir := path.Dir(misc.GetCDNPath(e.Collection.Id, e.Record.Id, e.ServedName, true))
return dir + "/"
}
return ""
}
// HandleFileDownload redirects a file request to a signed BunnyCDN URL.
// We use this to avoid having Pocketbase acting as a proxy to assets on S3. (Avoiding a performance hit)
// @see https://pocketbase.io/docs/go-event-hooks/#onfiledownloadrequest
func HandleFileDownload(e *core.FileDownloadRequestEvent) error {
// e.App
// e.Collection
// e.Record
// e.FileField
// e.ServedPath
// e.ServedName
// and all RequestEvent fields...
securityKey := os.Getenv("BUNNY_TOKEN_KEY")
if securityKey == "" {
log.Fatal("missing BUNNY_TOKEN_KEY env var")
}
cdnUrl := os.Getenv("BUNNY_ZONE_URL")
if cdnUrl == "" {
log.Fatal("missing BUNNY_ZONE_URL env var")
}
log.Println(">> ServedName=", e.ServedName)
expirationTime := int64(3600)
cdnPath := misc.GetCDNPath(e.Collection.Id, e.Record.Id, e.ServedName, false)
urlStr := cdnUrl + cdnPath
log.Println(">> We are handling a file download request with urlStr:", urlStr)
log.Println(">> FileField", e.FileField)
log.Println(">> Record", e.Record)
log.Println(">> Collection", e.Collection)
// If the request is for a master .m3u8 file,
// we respond with a signed URL that has a pathAllowedRoute. ("" otherwise)
pathAllowedRoute := getPathAllowedForMaster(e)
log.Println(">> pathAllowedRoute:", pathAllowedRoute)
signedUrl := misc.SignBunnyCDNURL(securityKey, urlStr, expirationTime, pathAllowedRoute)
log.Println(">> Signed URL is", signedUrl)
e.Redirect(http.StatusTemporaryRedirect, signedUrl)
return e.Next()
}

View File

@ -0,0 +1,52 @@
package handlers
import (
"fmt"
"futureporn/src/misc"
"log"
"net/http"
"os"
"github.com/pocketbase/pocketbase/core"
)
// func getCDNPath(e *core.FileDownloadRequestEvent, mediaPackage bool) string {
// // e.Collection.Name is "vods"
// // e.Record.ID is the record ID
// // e.ServedName is the filename
// }
// PackageHandler handles /api/package/:vodId requests
func PackageHandler(e *core.RequestEvent) error {
securityKey := os.Getenv("BUNNY_TOKEN_KEY")
if securityKey == "" {
log.Fatal("missing BUNNY_TOKEN_KEY env var")
}
cdnUrl := os.Getenv("BUNNY_ZONE_URL")
if cdnUrl == "" {
log.Fatal("missing BUNNY_ZONE_URL env var")
}
// get the vodId from the path
recordId := e.Request.PathValue("recordId")
collectionId := e.Request.PathValue("collectionId")
fileName := e.Request.PathValue("fileName")
log.Println("getting CDNPath")
urlStr := cdnUrl + misc.GetCDNPath(collectionId, recordId, fileName, true)
log.Println(">>> urlStr:", urlStr)
expirationTime := int64(3600)
pathAllowed := fmt.Sprintf("/%s/%s/hls/", collectionId, recordId)
// pathAllowed := path // fileName is something like `master_1234.m3u8` and the urlStr is something like `https://fppbdev.b-cdn.net/`
// urlStr is something like https://fppbdev.b-cdn.net/pbc_144770472/j8cgd0qqjbzyfi3/hls/master_j50o7q3n0i.m3u8
// we need to use existing variables and set pathAllowed to `/pbc_144770472/j8cgd0qqjbzyfi3/hls/`
log.Println(">>> pathAllowed:", pathAllowed, "urlStr:", urlStr)
signedUrl := misc.SignBunnyCDNURL(securityKey, urlStr, expirationTime, pathAllowed)
// redirect the client
return e.Redirect(http.StatusFound, signedUrl)
}

View File

@ -0,0 +1,56 @@
package handlers
import (
"futureporn/src/misc"
"log"
"net/http"
"os"
"github.com/pocketbase/pocketbase/core"
)
// S3Download redirects a file request to a signed BunnyCDN URL.
// This bypasses Pocketbase file downloading API
// This is useful for serving files which have been uploaded outside of Pocketbase
// Instead of being uploaded directly to Pocketbase using FormData.
// We use this method for serving large VOD files.
// @see https://pocketbase.io/docs/go-event-hooks/#onfiledownloadrequest
func S3Download(e *core.RequestEvent) error {
// e.App
// e.Collection
// e.Record
// e.FileField
// e.ServedPath
// e.ServedName
// and all RequestEvent fields...
securityKey := os.Getenv("BUNNY_TOKEN_KEY")
if securityKey == "" {
log.Fatal("missing BUNNY_TOKEN_KEY env var")
}
cdnUrl := os.Getenv("BUNNY_ZONE_URL")
if cdnUrl == "" {
log.Fatal("missing BUNNY_ZONE_URL env var")
}
expirationTime := int64(3600)
recordId := e.Request.PathValue("recordId")
collectionId := e.Request.PathValue("collectionId")
fileName := e.Request.PathValue("fileName")
cdnPath := misc.GetCDNPath(collectionId, recordId, fileName, false)
urlStr := cdnUrl + cdnPath
log.Println(">>>>>>>>>>>>>>>>>>>>>>>>> urlStr:", urlStr)
signedUrl := misc.SignBunnyCDNURL(securityKey, urlStr, expirationTime, "")
log.Println(">>>>>>>>>>>>>>>> Signed URL is", signedUrl)
e.Redirect(http.StatusTemporaryRedirect, signedUrl)
return e.Next()
}

View File

@ -0,0 +1,80 @@
package misc
import (
"crypto/sha256"
"encoding/base64"
"fmt"
"net/url"
"sort"
"strings"
"time"
)
func GetCDNPath(collectionId string, recordId string, fileName string, mediaPackage bool) string {
var base string
if mediaPackage {
base = fmt.Sprintf("/%s/%s/hls/%s", collectionId, recordId, fileName)
} else {
base = fmt.Sprintf("/%s/%s/%s", collectionId, recordId, fileName)
}
return base
}
func SignBunnyCDNURL(securityKey string, urlStr string, expirationTime int64, pathAllowed string) string {
expires := time.Now().Unix() + expirationTime
parsedURL, _ := url.Parse(urlStr)
parameters := parsedURL.Query()
var signaturePath string
if pathAllowed != "" {
signaturePath = pathAllowed
parameters.Set("token_path", signaturePath)
} else {
signaturePath, _ = url.QueryUnescape(parsedURL.Path)
}
// Manually sort the parameters
var keys []string
for key := range parameters {
keys = append(keys, key)
}
sort.Strings(keys)
var parameterData string
var parameterDataURL string
for _, key := range keys {
values := parameters[key]
sort.Strings(values)
for _, value := range values {
if value == "" {
continue
}
if parameterData != "" {
parameterData += "&"
}
parameterData += key + "=" + value
parameterDataURL += "&" + key + "=" + url.QueryEscape(value)
}
}
hashableBase := securityKey + signaturePath + fmt.Sprint(expires) + parameterData
hash := sha256.New()
hash.Write([]byte(hashableBase))
token := base64.StdEncoding.EncodeToString(hash.Sum(nil))
token = strings.ReplaceAll(token, "\n", "")
token = strings.ReplaceAll(token, "+", "-")
token = strings.ReplaceAll(token, "/", "_")
token = strings.ReplaceAll(token, "=", "")
isDirectory := pathAllowed != ""
if isDirectory {
return parsedURL.Scheme + "://" + parsedURL.Host + "/bcdn_token=" + token + parameterDataURL + "&expires=" + fmt.Sprint(expires) + parsedURL.Path
} else {
return parsedURL.Scheme + "://" + parsedURL.Host + parsedURL.Path + "?token=" + token + parameterDataURL + "&expires=" + fmt.Sprint(expires)
}
}

View File

@ -1,957 +0,0 @@
{
"name": "pocketpages-plugin-patreon",
"version": "0.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pocketpages-plugin-patreon",
"version": "0.0.1",
"license": "Unlicense",
"dependencies": {
"pocketpages-plugin-js-sdk": "0.2.0"
},
"devDependencies": {
"tsdown": "^0.15.12",
"typescript": "^5.7.3"
}
},
"node_modules/@babel/generator": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz",
"integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.28.5",
"@babel/types": "^7.28.5",
"@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28",
"jsesc": "^3.0.2"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz",
"integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.28.5"
},
"bin": {
"parser": "bin/babel-parser.js"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@babel/types": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz",
"integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.28.5"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.0.tgz",
"integrity": "sha512-pJdKGq/1iquWYtv1RRSljZklxHCOCAJFJrImO5ZLKPJVJlVUcs8yFwNQlqS0Lo8xT1VAXXTCZocF9n26FWEKsw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.1.0",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.0.tgz",
"integrity": "sha512-oAYoQnCYaQZKVS53Fq23ceWMRxq5EhQsE0x0RdQ55jT7wagMu5k+fS39v1fiSLrtrLQlXwVINenqhLMtTrV/1Q==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz",
"integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.7.tgz",
"integrity": "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.5.0",
"@emnapi/runtime": "^1.5.0",
"@tybys/wasm-util": "^0.10.1"
}
},
"node_modules/@oxc-project/types": {
"version": "0.95.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.95.0.tgz",
"integrity": "sha512-vACy7vhpMPhjEJhULNxrdR0D943TkA/MigMpJCHmBHvMXxRStRi/dPtTlfQ3uDwWSzRpT8z+7ImjZVf8JWBocQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@quansync/fs": {
"version": "0.1.5",
"resolved": "https://registry.npmjs.org/@quansync/fs/-/fs-0.1.5.tgz",
"integrity": "sha512-lNS9hL2aS2NZgNW7BBj+6EBl4rOf8l+tQ0eRY6JWCI8jI2kc53gSoqbjojU0OnAWhzoXiOjFyGsHcDGePB3lhA==",
"dev": true,
"license": "MIT",
"dependencies": {
"quansync": "^0.2.11"
},
"funding": {
"url": "https://github.com/sponsors/sxzz"
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-beta.45",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-beta.45.tgz",
"integrity": "sha512-bfgKYhFiXJALeA/riil908+2vlyWGdwa7Ju5S+JgWZYdR4jtiPOGdM6WLfso1dojCh+4ZWeiTwPeV9IKQEX+4g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.0.0-beta.45",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-beta.45.tgz",
"integrity": "sha512-xjCv4CRVsSnnIxTuyH1RDJl5OEQ1c9JYOwfDAHddjJDxCw46ZX9q80+xq7Eok7KC4bRSZudMJllkvOKv0T9SeA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.0.0-beta.45",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-beta.45.tgz",
"integrity": "sha512-ddcO9TD3D/CLUa/l8GO8LHzBOaZqWg5ClMy3jICoxwCuoz47h9dtqPsIeTiB6yR501LQTeDsjA4lIFd7u3Ljfw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.0.0-beta.45",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-beta.45.tgz",
"integrity": "sha512-MBTWdrzW9w+UMYDUvnEuh0pQvLENkl2Sis15fHTfHVW7ClbGuez+RWopZudIDEGkpZXdeI4CkRXk+vdIIebrmg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.0.0-beta.45",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-beta.45.tgz",
"integrity": "sha512-4YgoCFiki1HR6oSg+GxxfzfnVCesQxLF1LEnw9uXS/MpBmuog0EOO2rYfy69rWP4tFZL9IWp6KEfGZLrZ7aUog==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.0.0-beta.45",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-beta.45.tgz",
"integrity": "sha512-LE1gjAwQRrbCOorJJ7LFr10s5vqYf5a00V5Ea9wXcT2+56n5YosJkcp8eQ12FxRBv2YX8dsdQJb+ZTtYJwb6XQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.0.0-beta.45",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-beta.45.tgz",
"integrity": "sha512-tdy8ThO/fPp40B81v0YK3QC+KODOmzJzSUOO37DinQxzlTJ026gqUSOM8tzlVixRbQJltgVDCTYF8HNPRErQTA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.0.0-beta.45",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-beta.45.tgz",
"integrity": "sha512-lS082ROBWdmOyVY/0YB3JmsiClaWoxvC+dA8/rbhyB9VLkvVEaihLEOr4CYmrMse151C4+S6hCw6oa1iewox7g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.0.0-beta.45",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-beta.45.tgz",
"integrity": "sha512-Hi73aYY0cBkr1/SvNQqH8Cd+rSV6S9RB5izCv0ySBcRnd/Wfn5plguUoGYwBnhHgFbh6cPw9m2dUVBR6BG1gxA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.0.0-beta.45",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-beta.45.tgz",
"integrity": "sha512-fljEqbO7RHHogNDxYtTzr+GNjlfOx21RUyGmF+NrkebZ8emYYiIqzPxsaMZuRx0rgZmVmliOzEp86/CQFDKhJQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.0.0-beta.45",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-beta.45.tgz",
"integrity": "sha512-ZJDB7lkuZE9XUnWQSYrBObZxczut+8FZ5pdanm8nNS1DAo8zsrPuvGwn+U3fwU98WaiFsNrA4XHngesCGr8tEQ==",
"cpu": [
"wasm32"
],
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@napi-rs/wasm-runtime": "^1.0.7"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.0-beta.45",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-beta.45.tgz",
"integrity": "sha512-zyzAjItHPUmxg6Z8SyRhLdXlJn3/D9KL5b9mObUrBHhWS/GwRH4665xCiFqeuktAhhWutqfc+rOV2LjK4VYQGQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-ia32-msvc": {
"version": "1.0.0-beta.45",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.0.0-beta.45.tgz",
"integrity": "sha512-wODcGzlfxqS6D7BR0srkJk3drPwXYLu7jPHN27ce2c4PUnVVmJnp9mJzUQGT4LpmHmmVdMZ+P6hKvyTGBzc1CA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.0.0-beta.45",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-beta.45.tgz",
"integrity": "sha512-wiU40G1nQo9rtfvF9jLbl79lUgjfaD/LTyUEw2Wg/gdF5OhjzpKMVugZQngO+RNdwYaNj+Fs+kWBWfp4VXPMHA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.45",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.45.tgz",
"integrity": "sha512-Le9ulGCrD8ggInzWw/k2J8QcbPz7eGIOWqfJ2L+1R0Opm7n6J37s2hiDWlh6LJN0Lk9L5sUzMvRHKW7UxBZsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
"integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/ansis": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/ansis/-/ansis-4.2.0.tgz",
"integrity": "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=14"
}
},
"node_modules/ast-kit": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.1.3.tgz",
"integrity": "sha512-TH+b3Lv6pUjy/Nu0m6A2JULtdzLpmqF9x1Dhj00ZoEiML8qvVA9j1flkzTKNYgdEhWrjDwtWNpyyCUbfQe514g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.28.4",
"pathe": "^2.0.3"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/sponsors/sxzz"
}
},
"node_modules/birpc": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/birpc/-/birpc-2.6.1.tgz",
"integrity": "sha512-LPnFhlDpdSH6FJhJyn4M0kFO7vtQ5iPw24FnG0y21q09xC7e8+1LeR31S1MAIrDAHp4m7aas4bEkTDTvMAtebQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/cac": {
"version": "6.7.14",
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"dev": true,
"license": "MIT",
"dependencies": {
"readdirp": "^4.0.1"
},
"engines": {
"node": ">= 14.16.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/defu": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
"integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==",
"dev": true,
"license": "MIT"
},
"node_modules/diff": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/diff/-/diff-8.0.2.tgz",
"integrity": "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
}
},
"node_modules/dts-resolver": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/dts-resolver/-/dts-resolver-2.1.2.tgz",
"integrity": "sha512-xeXHBQkn2ISSXxbJWD828PFjtyg+/UrMDo7W4Ffcs7+YWCquxU8YjV1KoxuiL+eJ5pg3ll+bC6flVv61L3LKZg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.18.0"
},
"funding": {
"url": "https://github.com/sponsors/sxzz"
},
"peerDependencies": {
"oxc-resolver": ">=11.0.0"
},
"peerDependenciesMeta": {
"oxc-resolver": {
"optional": true
}
}
},
"node_modules/empathic": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz",
"integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/get-tsconfig": {
"version": "4.13.0",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz",
"integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
},
"funding": {
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/hookable": {
"version": "5.5.3",
"resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz",
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
"dev": true,
"license": "MIT"
},
"node_modules/jiti": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
"dev": true,
"license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
"integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
"dev": true,
"license": "MIT",
"bin": {
"jsesc": "bin/jsesc"
},
"engines": {
"node": ">=6"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
"node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pocketbase-js-sdk-jsvm": {
"version": "0.25.10004",
"resolved": "https://registry.npmjs.org/pocketbase-js-sdk-jsvm/-/pocketbase-js-sdk-jsvm-0.25.10004.tgz",
"integrity": "sha512-/0RkFa6X4LeKMdv4KCXhC5i2cvpG0elvXInRyk8bApjG8jyyPiNDL6o9VBvgxjl6j8/HcQYf3oKYXmZteqAFvQ==",
"license": "MIT"
},
"node_modules/pocketpages-plugin-js-sdk": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/pocketpages-plugin-js-sdk/-/pocketpages-plugin-js-sdk-0.2.0.tgz",
"integrity": "sha512-/uY1nCz7Zt19C/kyoOraH5ekTZa4PLPpHd0qIEoyf1XBPtEpHL4k7jZE0IPEMu1dH/8RfsKb/YzN52GR+9DdzQ==",
"dependencies": {
"pocketbase-js-sdk-jsvm": "^0.25.10004"
}
},
"node_modules/quansync": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",
"integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==",
"dev": true,
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/antfu"
},
{
"type": "individual",
"url": "https://github.com/sponsors/sxzz"
}
],
"license": "MIT"
},
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14.18.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/rolldown": {
"version": "1.0.0-beta.45",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-beta.45.tgz",
"integrity": "sha512-iMmuD72XXLf26Tqrv1cryNYLX6NNPLhZ3AmNkSf8+xda0H+yijjGJ+wVT9UdBUHOpKzq9RjKtQKRCWoEKQQBZQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.95.0",
"@rolldown/pluginutils": "1.0.0-beta.45"
},
"bin": {
"rolldown": "bin/cli.mjs"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.0.0-beta.45",
"@rolldown/binding-darwin-arm64": "1.0.0-beta.45",
"@rolldown/binding-darwin-x64": "1.0.0-beta.45",
"@rolldown/binding-freebsd-x64": "1.0.0-beta.45",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-beta.45",
"@rolldown/binding-linux-arm64-gnu": "1.0.0-beta.45",
"@rolldown/binding-linux-arm64-musl": "1.0.0-beta.45",
"@rolldown/binding-linux-x64-gnu": "1.0.0-beta.45",
"@rolldown/binding-linux-x64-musl": "1.0.0-beta.45",
"@rolldown/binding-openharmony-arm64": "1.0.0-beta.45",
"@rolldown/binding-wasm32-wasi": "1.0.0-beta.45",
"@rolldown/binding-win32-arm64-msvc": "1.0.0-beta.45",
"@rolldown/binding-win32-ia32-msvc": "1.0.0-beta.45",
"@rolldown/binding-win32-x64-msvc": "1.0.0-beta.45"
}
},
"node_modules/rolldown-plugin-dts": {
"version": "0.17.3",
"resolved": "https://registry.npmjs.org/rolldown-plugin-dts/-/rolldown-plugin-dts-0.17.3.tgz",
"integrity": "sha512-8mGnNUVNrqEdTnrlcaDxs4sAZg0No6njO+FuhQd4L56nUbJO1tHxOoKDH3mmMJg7f/BhEj/1KjU5W9kZ9zM/kQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/generator": "^7.28.5",
"@babel/parser": "^7.28.5",
"@babel/types": "^7.28.5",
"ast-kit": "^2.1.3",
"birpc": "^2.6.1",
"debug": "^4.4.3",
"dts-resolver": "^2.1.2",
"get-tsconfig": "^4.13.0",
"magic-string": "^0.30.21"
},
"engines": {
"node": ">=20.18.0"
},
"funding": {
"url": "https://github.com/sponsors/sxzz"
},
"peerDependencies": {
"@ts-macro/tsc": "^0.3.6",
"@typescript/native-preview": ">=7.0.0-dev.20250601.1",
"rolldown": "^1.0.0-beta.44",
"typescript": "^5.0.0",
"vue-tsc": "~3.1.0"
},
"peerDependenciesMeta": {
"@ts-macro/tsc": {
"optional": true
},
"@typescript/native-preview": {
"optional": true
},
"typescript": {
"optional": true
},
"vue-tsc": {
"optional": true
}
}
},
"node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/tinyexec": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.1.tgz",
"integrity": "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.3"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tree-kill": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
"dev": true,
"license": "MIT",
"bin": {
"tree-kill": "cli.js"
}
},
"node_modules/tsdown": {
"version": "0.15.12",
"resolved": "https://registry.npmjs.org/tsdown/-/tsdown-0.15.12.tgz",
"integrity": "sha512-c8VLlQm8/lFrOAg5VMVeN4NAbejZyVQkzd+ErjuaQgJFI/9MhR9ivr0H/CM7UlOF1+ELlF6YaI7sU/4itgGQ8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansis": "^4.2.0",
"cac": "^6.7.14",
"chokidar": "^4.0.3",
"debug": "^4.4.3",
"diff": "^8.0.2",
"empathic": "^2.0.0",
"hookable": "^5.5.3",
"rolldown": "1.0.0-beta.45",
"rolldown-plugin-dts": "^0.17.2",
"semver": "^7.7.3",
"tinyexec": "^1.0.1",
"tinyglobby": "^0.2.15",
"tree-kill": "^1.2.2",
"unconfig": "^7.3.3"
},
"bin": {
"tsdown": "dist/run.mjs"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/sponsors/sxzz"
},
"peerDependencies": {
"@arethetypeswrong/core": "^0.18.1",
"publint": "^0.3.0",
"typescript": "^5.0.0",
"unplugin-lightningcss": "^0.4.0",
"unplugin-unused": "^0.5.0",
"unrun": "^0.2.1"
},
"peerDependenciesMeta": {
"@arethetypeswrong/core": {
"optional": true
},
"publint": {
"optional": true
},
"typescript": {
"optional": true
},
"unplugin-lightningcss": {
"optional": true
},
"unplugin-unused": {
"optional": true
},
"unrun": {
"optional": true
}
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD",
"optional": true
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/unconfig": {
"version": "7.3.3",
"resolved": "https://registry.npmjs.org/unconfig/-/unconfig-7.3.3.tgz",
"integrity": "sha512-QCkQoOnJF8L107gxfHL0uavn7WD9b3dpBcFX6HtfQYmjw2YzWxGuFQ0N0J6tE9oguCBJn9KOvfqYDCMPHIZrBA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@quansync/fs": "^0.1.5",
"defu": "^6.1.4",
"jiti": "^2.5.1",
"quansync": "^0.2.11"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
}
}
}

View File

@ -1,19 +0,0 @@
{
"name": "pocketpages-plugin-patreon",
"version": "0.0.1",
"description": "",
"main": "dist/index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "tsdown",
"dev": "tsdown --watch"
},
"dependencies": {
"pocketpages-plugin-js-sdk": "0.2.0"
},
"devDependencies": {
"tsdown": "^0.15.12",
"typescript": "^5.7.3"
},
"license": "Unlicense"
}

View File

@ -1,40 +0,0 @@
import { type ExtendContextApiContext } from 'pocketpages'
import PocketBase, {
AuthModel,
RecordAuthResponse,
} from 'pocketbase-js-sdk-jsvm'
import { type OAuth2SignInOptions } from 'pocketpages-plugin-auth'
import { PluginFactory } from 'pocketpages'
import { OAuth2ProviderInfo } from 'pocketpages-plugin-auth'
const authPluginFactory: PluginFactory = (config) => {
return {
name: 'patreon',
onExtendContextApi(context: ExtendContextApiContext) {
const { api } = context;
const pb = () => api.pb() as PocketBase
api.savePatreonData = (
authData: AuthModel
) => {
pb.collection('users')
},
api.getPatreonUser = (
authData: AuthModel,
) => {
console.log("AYO we got authData as follo");
console.log(JSON.stringify(authData, null, 2));
// make a request to Patreon
}
},
}
}
export default authPluginFactory;

View File

@ -1,19 +0,0 @@
{
"compilerOptions": {
"types": [
"pocketbase-jsvm"
],
"strict": true,
"strictNullChecks": true,
"noUncheckedIndexedAccess": true,
"noImplicitAny": true,
"target": "ES6",
"module": "CommonJS",
"moduleResolution": "node",
"esModuleInterop": true,
"resolveJsonModule": true
},
"include": [
"src/**/*.ts"
]
}

View File

@ -1,10 +0,0 @@
import { defineConfig } from 'tsdown'
export default defineConfig({
entry: {
index: 'src/index.ts',
},
format: ['cjs'],
clean: true,
outDir: 'dist',
})

View File

@ -14,7 +14,7 @@ const env = (() => {
if (!process.env.MUX_SIGNING_KEY_PRIVATE_KEY) throw new Error('MUX_SIGNING_KEY_PRIVATE_KEY missing in env'); if (!process.env.MUX_SIGNING_KEY_PRIVATE_KEY) throw new Error('MUX_SIGNING_KEY_PRIVATE_KEY missing in env');
if (!process.env.PATREON_CREATOR_ACCESS_TOKEN) throw new Error('PATREON_CREATOR_ACCESS_TOKEN missing in env'); if (!process.env.PATREON_CREATOR_ACCESS_TOKEN) throw new Error('PATREON_CREATOR_ACCESS_TOKEN missing in env');
if (!process.env.VIBEUI_DIR) throw new Error('VIBEUI_DIR missing in env'); if (!process.env.VIBEUI_DIR) throw new Error('VIBEUI_DIR missing in env');
if (!process.env.APP_DIR) throw new Error('APP_DIR missing in env'); if (!process.env.WORKER_DIR) throw new Error('WORKER_DIR missing in env');
if (!process.env.AWS_BUCKET) throw new Error('AWS_BUCKET missing in env'); if (!process.env.AWS_BUCKET) throw new Error('AWS_BUCKET missing in env');
if (!process.env.AWS_ACCESS_KEY_ID) throw new Error('AWS_ACCESS_KEY_ID missing in env'); if (!process.env.AWS_ACCESS_KEY_ID) throw new Error('AWS_ACCESS_KEY_ID missing in env');
if (!process.env.AWS_SECRET_ACCESS_KEY) throw new Error('AWS_SECRET_ACCESS_KEY missing in env'); if (!process.env.AWS_SECRET_ACCESS_KEY) throw new Error('AWS_SECRET_ACCESS_KEY missing in env');
@ -33,6 +33,7 @@ const env = (() => {
if (!process.env.SEEDBOX_SFTP_PORT) throw new Error('SEEDBOX_SFTP_PORT missing in env'); if (!process.env.SEEDBOX_SFTP_PORT) throw new Error('SEEDBOX_SFTP_PORT missing in env');
if (!process.env.SEEDBOX_SFTP_USERNAME) throw new Error('SEEDBOX_SFTP_USERNAME missing in env'); if (!process.env.SEEDBOX_SFTP_USERNAME) throw new Error('SEEDBOX_SFTP_USERNAME missing in env');
if (!process.env.SEEDBOX_SFTP_PASSWORD) throw new Error('SEEDBOX_SFTP_PASSWORD missing in env'); if (!process.env.SEEDBOX_SFTP_PASSWORD) throw new Error('SEEDBOX_SFTP_PASSWORD missing in env');
if (!process.env.SEEDBOX_SFTP_KEY_FILE) throw new Error('SEEDBOX_SFTP_KEY_FILE missing in env');
if (!process.env.QBT_HOST) throw new Error('QBT_HOST missing in env'); if (!process.env.QBT_HOST) throw new Error('QBT_HOST missing in env');
if (!process.env.QBT_PORT) throw new Error('QBT_PORT missing in env'); if (!process.env.QBT_PORT) throw new Error('QBT_PORT missing in env');
if (!process.env.QBT_PASSWORD) throw new Error('QBT_PASSWORD missing in env'); if (!process.env.QBT_PASSWORD) throw new Error('QBT_PASSWORD missing in env');
@ -40,6 +41,10 @@ const env = (() => {
if (!process.env.WORKER_WORKERS) throw new Error('WORKER_WORKERS missing in env'); if (!process.env.WORKER_WORKERS) throw new Error('WORKER_WORKERS missing in env');
if (!process.env.BUNNY_ZONE_URL) throw new Error('BUNNY_ZONE_URL missing in env'); if (!process.env.BUNNY_ZONE_URL) throw new Error('BUNNY_ZONE_URL missing in env');
if (!process.env.BUNNY_TOKEN_KEY) throw new Error('BUNNY_TOKEN_KEY missing in env'); if (!process.env.BUNNY_TOKEN_KEY) throw new Error('BUNNY_TOKEN_KEY missing in env');
if (!process.env.TRACKER_SFTP_HOST) throw new Error('TRACKER_SFTP_HOST missing in env');
if (!process.env.TRACKER_SFTP_PORT) throw new Error('TRACKER_SFTP_PORT missing in env');
if (!process.env.TRACKER_SFTP_USERNAME) throw new Error('TRACKER_SFTP_USERNAME missing in env');
if (!process.env.TRACKER_SFTP_KEY_FILE) throw new Error('TRACKER_SFTP_KEY_FILE missing in env');
const CACHE_ROOT = process.env?.CACHE_ROOT || paths.cache; const CACHE_ROOT = process.env?.CACHE_ROOT || paths.cache;
@ -55,7 +60,7 @@ const env = (() => {
MUX_SIGNING_KEY_PRIVATE_KEY, MUX_SIGNING_KEY_PRIVATE_KEY,
PATREON_CREATOR_ACCESS_TOKEN, PATREON_CREATOR_ACCESS_TOKEN,
VIBEUI_DIR, VIBEUI_DIR,
APP_DIR, WORKER_DIR,
AWS_BUCKET, AWS_BUCKET,
AWS_ACCESS_KEY_ID, AWS_ACCESS_KEY_ID,
AWS_SECRET_ACCESS_KEY, AWS_SECRET_ACCESS_KEY,
@ -74,6 +79,12 @@ const env = (() => {
SEEDBOX_SFTP_PORT, SEEDBOX_SFTP_PORT,
SEEDBOX_SFTP_USERNAME, SEEDBOX_SFTP_USERNAME,
SEEDBOX_SFTP_PASSWORD, SEEDBOX_SFTP_PASSWORD,
SEEDBOX_SFTP_KEY_FILE,
TRACKER_SFTP_HOST,
TRACKER_SFTP_PORT,
TRACKER_SFTP_USERNAME,
TRACKER_SFTP_PASSWORD,
TRACKER_SFTP_KEY_FILE,
VALKEY_PORT, VALKEY_PORT,
QBT_HOST, QBT_HOST,
QBT_USERNAME, QBT_USERNAME,
@ -96,7 +107,7 @@ const env = (() => {
MUX_SIGNING_KEY_PRIVATE_KEY, MUX_SIGNING_KEY_PRIVATE_KEY,
PATREON_CREATOR_ACCESS_TOKEN, PATREON_CREATOR_ACCESS_TOKEN,
VIBEUI_DIR, VIBEUI_DIR,
APP_DIR, WORKER_DIR,
AWS_BUCKET, AWS_BUCKET,
AWS_ACCESS_KEY_ID, AWS_ACCESS_KEY_ID,
AWS_SECRET_ACCESS_KEY, AWS_SECRET_ACCESS_KEY,
@ -115,6 +126,12 @@ const env = (() => {
SEEDBOX_SFTP_PORT, SEEDBOX_SFTP_PORT,
SEEDBOX_SFTP_USERNAME, SEEDBOX_SFTP_USERNAME,
SEEDBOX_SFTP_PASSWORD, SEEDBOX_SFTP_PASSWORD,
SEEDBOX_SFTP_KEY_FILE,
TRACKER_SFTP_HOST,
TRACKER_SFTP_PORT,
TRACKER_SFTP_USERNAME,
TRACKER_SFTP_PASSWORD,
TRACKER_SFTP_KEY_FILE,
VALKEY_PORT, VALKEY_PORT,
QBT_HOST, QBT_HOST,
QBT_USERNAME, QBT_USERNAME,

View File

@ -1,17 +1,19 @@
{ {
"name": "worker", "name": "worker",
"version": "0.1.0", "version": "0.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "worker", "name": "worker",
"version": "0.1.0", "version": "0.2.0",
"dependencies": { "dependencies": {
"@bull-board/express": "^6.14.0", "@bull-board/express": "^6.14.0",
"@mux/mux-node": "^12.8.0", "@mux/mux-node": "^12.8.0",
"@types/express": "^5.0.5", "@types/express": "^5.0.5",
"@types/fs-extra": "^11.0.4",
"@types/js-yaml": "^4.0.9", "@types/js-yaml": "^4.0.9",
"@types/magnet-uri": "^5.1.5",
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"@types/semver": "^7.7.1", "@types/semver": "^7.7.1",
"@types/ssh2": "^1.15.5", "@types/ssh2": "^1.15.5",
@ -20,6 +22,7 @@
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"fs-extra": "^11.3.2", "fs-extra": "^11.3.2",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"magnet-uri": "^7.0.7",
"nano-spawn": "^2.0.0", "nano-spawn": "^2.0.0",
"nanoid": "^5.1.6", "nanoid": "^5.1.6",
"onnxruntime-web": "^1.23.2", "onnxruntime-web": "^1.23.2",
@ -1586,6 +1589,18 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@thaunknown/thirty-two": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@thaunknown/thirty-two/-/thirty-two-1.0.5.tgz",
"integrity": "sha512-Q53KyCXweV1CS62EfqtPDqfpksn5keQ59PGqzzkK+g8Vif1jB4inoBCcs/BUSdsqddhE3G+2Fn+4RX3S6RqT0A==",
"license": "MIT",
"dependencies": {
"uint8-util": "^2.2.5"
},
"engines": {
"node": ">=0.2.6"
}
},
"node_modules/@tootallnate/quickjs-emscripten": { "node_modules/@tootallnate/quickjs-emscripten": {
"version": "0.23.0", "version": "0.23.0",
"resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz",
@ -1668,6 +1683,16 @@
"@types/send": "*" "@types/send": "*"
} }
}, },
"node_modules/@types/fs-extra": {
"version": "11.0.4",
"resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-11.0.4.tgz",
"integrity": "sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==",
"license": "MIT",
"dependencies": {
"@types/jsonfile": "*",
"@types/node": "*"
}
},
"node_modules/@types/http-errors": { "node_modules/@types/http-errors": {
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
@ -1680,6 +1705,24 @@
"integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/jsonfile": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/@types/jsonfile/-/jsonfile-6.1.4.tgz",
"integrity": "sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/magnet-uri": {
"version": "5.1.5",
"resolved": "https://registry.npmjs.org/@types/magnet-uri/-/magnet-uri-5.1.5.tgz",
"integrity": "sha512-SbBjlb1KGe38VfjRR+mwqztJd/4skhdKkRbIzPDhTy7IAeEAPZWIVSEkZw00Qr4ZZOGR3/ATJ20WWPBfrKHGdA==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/mime": { "node_modules/@types/mime": {
"version": "1.3.5", "version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
@ -2191,6 +2234,15 @@
"bare-path": "^3.0.0" "bare-path": "^3.0.0"
} }
}, },
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/basic-ftp": { "node_modules/basic-ftp": {
"version": "5.0.5", "version": "5.0.5",
"resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.0.5.tgz", "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.0.5.tgz",
@ -2209,6 +2261,15 @@
"tweetnacl": "^0.14.3" "tweetnacl": "^0.14.3"
} }
}, },
"node_modules/bep53-range": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/bep53-range/-/bep53-range-2.0.0.tgz",
"integrity": "sha512-sMm2sV5PRs0YOVk0LTKtjuIprVzxgTQUsrGX/7Yph2Rm4FO2Fqqtq7hNjsOB5xezM4v4+5rljCgK++UeQJZguA==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/body-parser": { "node_modules/body-parser": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz",
@ -3754,6 +3815,34 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@jridgewell/sourcemap-codec": "^1.5.5"
} }
}, },
"node_modules/magnet-uri": {
"version": "7.0.7",
"resolved": "https://registry.npmjs.org/magnet-uri/-/magnet-uri-7.0.7.tgz",
"integrity": "sha512-z/+dB2NQsXaDuxVBjoPLpZT8ePaacUmoontoFheRBl++nALHYs4qV9MmhTur9e4SaMbkCR/uPX43UMzEOoeyaw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"@thaunknown/thirty-two": "^1.0.5",
"bep53-range": "^2.0.0",
"uint8-util": "^2.2.5"
},
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@ -5274,6 +5363,15 @@
"node": ">=14.17" "node": ">=14.17"
} }
}, },
"node_modules/uint8-util": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/uint8-util/-/uint8-util-2.2.5.tgz",
"integrity": "sha512-/QxVQD7CttWpVUKVPz9znO+3Dd4BdTSnFQ7pv/4drVhC9m4BaL2LFHTkJn6EsYoxT79VDq/2Gg8L0H22PrzyMw==",
"license": "MIT",
"dependencies": {
"base64-arraybuffer": "^1.0.2"
}
},
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "7.16.0", "version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",

View File

@ -11,7 +11,9 @@
"@bull-board/express": "^6.14.0", "@bull-board/express": "^6.14.0",
"@mux/mux-node": "^12.8.0", "@mux/mux-node": "^12.8.0",
"@types/express": "^5.0.5", "@types/express": "^5.0.5",
"@types/fs-extra": "^11.0.4",
"@types/js-yaml": "^4.0.9", "@types/js-yaml": "^4.0.9",
"@types/magnet-uri": "^5.1.5",
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"@types/semver": "^7.7.1", "@types/semver": "^7.7.1",
"@types/ssh2": "^1.15.5", "@types/ssh2": "^1.15.5",
@ -20,6 +22,7 @@
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"fs-extra": "^11.3.2", "fs-extra": "^11.3.2",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"magnet-uri": "^7.0.7",
"nano-spawn": "^2.0.0", "nano-spawn": "^2.0.0",
"nanoid": "^5.1.6", "nanoid": "^5.1.6",
"onnxruntime-web": "^1.23.2", "onnxruntime-web": "^1.23.2",

View File

@ -1,3 +1,5 @@
import env from '../.config/env.ts';
import { version } from '../package.json';
import { createBullBoard } from '@bull-board/api'; import { createBullBoard } from '@bull-board/api';
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter'; import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
import { ExpressAdapter } from '@bull-board/express'; import { ExpressAdapter } from '@bull-board/express';
@ -5,13 +7,12 @@ import express, { type Request, type Response } from 'express';
import { generalQueue } from './queues/generalQueue.ts'; import { generalQueue } from './queues/generalQueue.ts';
import { gpuQueue } from './queues/gpuQueue.ts'; import { gpuQueue } from './queues/gpuQueue.ts';
import { highPriorityQueue } from './queues/highPriorityQueue.ts'; import { highPriorityQueue } from './queues/highPriorityQueue.ts';
import env from '../.config/env.ts';
import { version } from '../package.json';
import { downloadQueue } from './queues/downloadQueue.ts'; import { downloadQueue } from './queues/downloadQueue.ts';
import { cacheQueue } from './queues/cacheQueue.ts'; import { cacheQueue } from './queues/cacheQueue.ts';
import { muxQueue } from './queues/muxQueue.ts'; import { muxQueue } from './queues/muxQueue.ts';
import { b2Queue } from './queues/b2Queue.ts'; import { b2Queue } from './queues/b2Queue.ts';
const run = async () => { const run = async () => {
@ -77,6 +78,9 @@ const run = async () => {
<li><a href="/task?name=createTorrent&vodId=1234">Task: createTorrent</a></li> <li><a href="/task?name=createTorrent&vodId=1234">Task: createTorrent</a></li>
<li><a href="/task?name=createMuxAsset&vodId=">Task: createMuxAsset</a></li> <li><a href="/task?name=createMuxAsset&vodId=">Task: createMuxAsset</a></li>
<li><a href="/task?name=cacheGet&vodId=1234">Task: cacheGet</a></li> <li><a href="/task?name=cacheGet&vodId=1234">Task: cacheGet</a></li>
<li><a href="/task?name=updateTrackerWhitelist">Task: updateTrackerWhitelist</a></li>
<li><a href="/task?name=createFunscript&vodId=1234">Task: createFunscript</a></li>
<li><a href="/task?name=createHlsPlaylist&vodId=1234">Task: createHlsPlaylist</a></li>
</ul> </ul>
`) `)
}) })
@ -103,6 +107,15 @@ const run = async () => {
case 'download': case 'download':
await downloadQueue.add(name, data); await downloadQueue.add(name, data);
break; break;
case 'createFunscript':
await gpuQueue.add(name, data);
break;
case 'createHlsPlaylist':
await gpuQueue.add(name, data);
break;
case 'updateTrackerWhitelist':
await highPriorityQueue.add(name, data);
break;
default: default:
await highPriorityQueue.add(name, data); await highPriorityQueue.add(name, data);
break; break;

View File

@ -0,0 +1,16 @@
import { test, expect, describe, vi } from 'vitest';
import { decode } from 'magnet-uri';
describe('announceTorrent integration', () => {
test("magnet-uri", () => {
const fixture = 'magnet:?xt=urn:btih:ca2b3ba127c17e689cc50b0f2190d0dd4b40d981&xt=urn:btmh:12207892c2345ea1e0460dc642219b76bc60f303569b6f9dbc911d1af78dd7301f3d&dn=2025-11-16-undefined-jwz52dszvnagplf.mp4&xl=13629592283&tr=udp%3A%2F%2Ftracker.future.porn%3A6969%2Fannounce';
const parsed = decode(fixture);
console.log(parsed)
expect(parsed).toHaveProperty('infoHash', 'ca2b3ba127c17e689cc50b0f2190d0dd4b40d981');
expect(parsed).toHaveProperty('infoHashV2', '7892c2345ea1e0460dc642219b76bc60f303569b6f9dbc911d1af78dd7301f3d');
});
})

View File

@ -0,0 +1,56 @@
import { Job } from "bullmq";
import { getPocketBaseClient } from "../util/pocketbase";
import { Vod } from "../types";
import { Client, ConnectConfig, SFTPWrapper } from 'ssh2';
import { opentrackerSSHClient } from "../util/sftp";
import magnet from 'magnet-uri';
interface Payload {
vodId: string;
}
function assertPayload(payload: any): asserts payload is Payload {
if (typeof payload !== "object" || !payload) throw new Error("invalid payload-- was not an object.");
if (typeof payload.vodId !== "string") throw new Error("invalid payload-- was missing vodId");
}
/**
*
* announceTorrent
*
* We use ssh to write a whitelist.txt file to our opentracker server
*
*
* @todo we aren't using this yet. Right now we are only bulk updating the whitelist at the top of the hour.
* As we get more torrents, we will want to implement announcing a single torrent by patching the whitelist using this method
*
*/
export default async function announceTorrent(job: Job) {
throw new Error('@todo please implement');
assertPayload(job.data);
const pb = await getPocketBaseClient();
const vodId = job.data.vodId;
const vod = await pb.collection('vods').getOne(vodId);
const magnetLink = vod.magnetLink
if (!magnetLink) throw new Error(`vod ${vodId} has no magnetLink!`);
const parsed = magnet.decode(magnetLink)
job.log('dn=' + parsed.dn) // "Leaves of Grass by Walt Whitman.epub"
job.log('infoHash=' + parsed.infoHash) // "d2474e86c95b19b8bcfdb92bc12c9d44667cfa36"
const whitelist = ''
const localWhitelistPath = '@todo';
const remoteWhitelistPath = '/etc/opentracker/whitelist.txt';
await opentrackerSSHClient.uploadFileAs(localWhitelistPath, remoteWhitelistPath);
}

View File

@ -2,43 +2,72 @@ import { Job } from "bullmq";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import env from "../../.config/env"; import env from "../../.config/env";
import { subDays } from "date-fns";
const retainmentDayCount = 2; const retainmentDayCount = 365;
/** /**
* cacheCleanup * Recursively delete files older than retainmentDayCount days
*
* Deletes files in the cache directory that are older than retainmentDayCount days
*/ */
export default async function cacheCleanup(job: Job) { export default async function cacheCleanup(job: Job) {
const cacheDir = join(env.CACHE_ROOT, 'worker'); const cacheDir = join(env.CACHE_ROOT, 'worker');
let cleanedCount = 0; let cleanedCount = 0;
try { const cutoffDate = subDays(new Date(), retainmentDayCount);
// read all files in the cache directory const cutoffMs = cutoffDate.getTime();
const files = await fs.readdir(cacheDir);
const now = Date.now(); job.log(`Starting recursive cache cleanup in directory: ${cacheDir}`);
const retainMs = retainmentDayCount * 24 * 60 * 60 * 1000; // days → ms job.log(`Deleting files older than: ${cutoffDate.toISOString()} (${cutoffMs} ms)`);
async function cleanDir(dir: string) {
let files: string[];
try {
files = await fs.readdir(dir);
} catch (err) {
job.log(`Failed to read directory ${dir}: ${(err as Error).message}`);
return;
}
for (const file of files) { for (const file of files) {
const filePath = join(cacheDir, file); const filePath = join(dir, file);
try { try {
const stat = await fs.stat(filePath); const stat = await fs.stat(filePath);
// only delete files older than retainment
if (now - stat.mtimeMs > retainMs) { if (stat.isDirectory()) {
await fs.unlink(filePath); job.log(`Entering directory: ${filePath}`);
cleanedCount++; await cleanDir(filePath); // recurse into subdirectory
} else {
job.log(`Checking file: ${filePath}, last access: ${new Date(stat.atimeMs).toISOString()}`);
if (stat.atimeMs < cutoffMs) {
await fs.unlink(filePath);
cleanedCount++;
job.log(`Deleted file: ${filePath}`);
} else {
job.log(`Keeping file: ${filePath}`);
}
} }
} catch (err) { } catch (err) {
// skip errors per-file, but log them job.log(`Failed to check/delete ${filePath}: ${(err as Error).message}`);
job.log(`failed to check/delete ${file}: ${(err as Error).message}`);
} }
} }
job.log(`cleaned ${cleanedCount} stale cache files`); // Optionally remove empty directories
try {
const remaining = await fs.readdir(dir);
if (remaining.length === 0 && dir !== cacheDir) {
await fs.rmdir(dir);
job.log(`Deleted empty directory: ${dir}`);
}
} catch (err) {
job.log(`Failed to delete directory ${dir}: ${(err as Error).message}`);
}
}
try {
await cleanDir(cacheDir);
job.log(`Cache cleanup complete. Deleted ${cleanedCount} files`);
} catch (err) { } catch (err) {
job.log(`cache cleanup failed: ${(err as Error).message}`); job.log(`Cache cleanup failed: ${(err as Error).message}`);
throw err; // allow BullMQ to handle retry/failure throw err;
} }
} }

View File

@ -1,39 +1,104 @@
import { getOrDownloadAsset } from "../util/cache.ts"; import env from "../../.config/env.ts";
import env from "../../env.ts";
import { getS3Client, uploadFile } from "../util/s3.ts";
import { inference } from "../util/vibeui.ts"; import { inference } from "../util/vibeui.ts";
import { buildFunscript } from "../util/funscripts.ts"; import { buildFunscript } from "../util/funscripts.ts";
import { generateS3Path } from "../util/formatters.ts"; import { generateS3Path } from "../util/formatters.ts";
import { type Job } from "bullmq"; import { cacheQueue, cacheQueueEvents } from "../queues/cacheQueue.ts";
import { getPocketBaseClient } from '../util/pocketbase'; import { Job } from "bullmq";
import { basename } from "node:path";
import { getPocketBaseClient } from "../util/pocketbase";
import Client, { RecordModel } from "pocketbase";
import { Vod } from "../types";
import { readFile } from "node:fs/promises";
import { getS3KeyTarget } from '../util/b2.ts';
import spawn from "nano-spawn";
interface Payload { interface Payload {
vodId: string; vodId: string;
} }
interface Funscripts { vibratePath: string, thrustPath: string }
function assertPayload(payload: any): asserts payload is Payload { function assertPayload(payload: any): asserts payload is Payload {
if (typeof payload !== "object" || !payload) if (typeof payload !== "object" || !payload) throw new Error("invalid payload-- was not an object.");
throw new Error("invalid payload-- was not an object."); if (typeof payload.vodId !== "string") throw new Error("invalid payload-- was missing vodId");
if (typeof payload.vodId !== "string")
throw new Error("invalid payload-- was missing vodId");
} }
async function getVod(vodId: string) {
// return prisma.vod.findFirstOrThrow({
// where: { id: vodId }, /**
// include: { vtubers: true }, * createFunscript
// }); */
// funscriptVibrate: funscriptKeys.vibrateKey, export async function createFunscript(job: Job) {
// funscriptThrust: funscriptKeys.thrustKey, assertPayload(job.data);
const vodId = job.data.vodId;
const pb = await getPocketBaseClient(); const pb = await getPocketBaseClient();
const vod = await pb.collection('vods').getOne(vodId, { const vod = await pb.collection('vods').getOne(vodId, { expand: 'vtubers' });
expand: 'vtubers'
}); job.log(`createFunscript running on vodId ${vodId}`);
return vod;
ensureVodReady(job, vod);
const cachePath = await getVodFromCache(job, vodId);
job.log(`Running inference on video...`);
const predictionOutputPath = await inference(cachePath);
const funscripts = await buildFunscripts(
job,
predictionOutputPath,
cachePath
);
await uploadFunscripts(job, pb, vod, funscripts);
} }
function ensureVodReady(vod: Awaited<ReturnType<typeof getVod>>) { async function uploadFunscript(job: Job, vod: RecordModel, assetPath: string): Promise<string> {
if (!job) throw new Error('job missing');
if (!vod) throw new Error('vod missing');
if (!assetPath) throw new Error('vod missing');
const s3KeyTarget = getS3KeyTarget(vod.collectionId, vod.id, assetPath, null);
job.log(`uploading funscript ${assetPath} to ${s3KeyTarget}`);
await spawn('b2', ['file', 'upload', env.AWS_BUCKET, assetPath, s3KeyTarget]);
return s3KeyTarget;
}
/**
*
* uploadFunscripts
*
* Update the record in the database
*
*/
async function uploadFunscripts(job: Job, pb: Client, vod: RecordModel, funscripts: Funscripts) {
const { vibratePath, thrustPath } = funscripts;
const funscriptVibrate = await uploadFunscript(job, vod, vibratePath);
const funscriptThrust = await uploadFunscript(job, vod, thrustPath);
const data = {
funscriptVibrate,
funscriptThrust,
}
await pb.collection('vods').update(vod.id, data);
}
async function getVodFromCache(job: Job, vodId: string): Promise<string> {
job.log('getting vod from cache...');
const cacheGetJob = await cacheQueue.add(
'cacheGet',
{ vodId },
{ jobId: `cacheGet-${vodId}` }
);
job.log('waiting up to 5 hours for download to finish...');
const result = (await cacheGetJob.waitUntilFinished(cacheQueueEvents, 1000 * 60 * 60 * 5));
return result.cachePath;
}
function ensureVodReady(job: Job, vod: RecordModel) {
if (vod.funscriptVibrate && vod.funscriptThrust) { if (vod.funscriptVibrate && vod.funscriptThrust) {
job.log(`Doing nothing-- vod ${vod.id} already has funscripts.`); job.log(`Doing nothing-- vod ${vod.id} already has funscripts.`);
return false; return false;
@ -48,28 +113,16 @@ function ensureVodReady(vod: Awaited<ReturnType<typeof getVod>>) {
return true; return true;
} }
async function downloadVideo(sourceVideo: string) {
const s3Client = getS3Client();
const videoFilePath = await getOrDownloadAsset(
s3Client,
env.S3_BUCKET,
sourceVideo
);
job.log(`Downloaded video to ${videoFilePath}`);
return { s3Client, videoFilePath };
}
async function runInference(videoFilePath: string) { async function runInference(videoFilePath: string) {
job.log(`Running inference on video...`);
const predictionOutputPath = await inference(videoFilePath); const predictionOutputPath = await inference(videoFilePath);
job.log(`Prediction output at ${predictionOutputPath}`);
return predictionOutputPath; return predictionOutputPath;
} }
async function buildFunscripts( async function buildFunscripts(
job: Job,
predictionOutputPath: string, predictionOutputPath: string,
videoFilePath: string videoFilePath: string
) { ): Promise<Funscripts> {
job.log(`Building funscripts...`); job.log(`Building funscripts...`);
const vibratePath = await buildFunscript( const vibratePath = await buildFunscript(
predictionOutputPath, predictionOutputPath,
@ -87,95 +140,97 @@ async function buildFunscripts(
return { vibratePath, thrustPath }; return { vibratePath, thrustPath };
} }
async function uploadFunscripts( // async function uploadFunscripts(
s3Client: ReturnType<typeof getS3Client>, // job: Job,
slug: string, // s3Client: ReturnType<typeof getS3Client>,
streamDate: Date, // slug: string,
vodId: string, // streamDate: Date,
funscripts: { vibratePath: string; thrustPath: string } // vodId: string,
) { // funscripts: { vibratePath: string; thrustPath: string }
const vibrateKey = generateS3Path( // ) {
slug, // const vibrateKey = generateS3Path(
streamDate, // slug,
vodId, // streamDate,
`funscripts/vibrate.funscript` // vodId,
); // `funscripts/vibrate.funscript`
const vibrateUrl = await uploadFile( // );
s3Client, // const vibrateUrl = await uploadFile(
env.S3_BUCKET, // s3Client,
vibrateKey, // env.S3_BUCKET,
funscripts.vibratePath, // vibrateKey,
"application/json" // funscripts.vibratePath,
); // "application/json"
// );
const thrustKey = generateS3Path( // const thrustKey = generateS3Path(
slug, // slug,
streamDate, // streamDate,
vodId, // vodId,
`funscripts/thrust.funscript` // `funscripts/thrust.funscript`
); // );
const thrustUrl = await uploadFile( // const thrustUrl = await uploadFile(
s3Client, // s3Client,
env.S3_BUCKET, // env.S3_BUCKET,
thrustKey, // thrustKey,
funscripts.thrustPath, // funscripts.thrustPath,
"application/json" // "application/json"
); // );
job.log(`Uploaded funscriptVibrate to S3: ${vibrateUrl}`); // job.log(`Uploaded funscriptVibrate to S3: ${vibrateUrl}`);
job.log(`Uploaded funscriptThrust to S3: ${thrustUrl}`); // job.log(`Uploaded funscriptThrust to S3: ${thrustUrl}`);
return { vibrateKey, thrustKey }; // return { vibrateKey, thrustKey };
} // }
async function saveToDatabase( // async function saveToDatabase(
vodId: string, // job: Job,
funscriptKeys: { vibrateKey: string; thrustKey: string } // vodId: string,
) { // funscriptKeys: { vibrateKey: string; thrustKey: string }
const pb = await getPocketBaseClient(); // ) {
// const pb = await getPocketBaseClient();
await pb.collection('users').update(vodId, { // await pb.collection('users').update(vodId, {
funscriptVibrate: funscriptKeys.vibrateKey, // funscriptVibrate: funscriptKeys.vibrateKey,
funscriptThrust: funscriptKeys.thrustKey, // funscriptThrust: funscriptKeys.thrustKey,
}); // });
job.log(`Funscripts saved to database for vod ${vodId}`); // job.log(`Funscripts saved to database for vod ${vodId}`);
} // }
export async function createFunscriptTask(job: Job) { // export async function createFunscriptTask(job: Job) {
const pb = await getPocketBaseClient(); // const pb = await getPocketBaseClient();
job.log(`the job '${job.name}' is running`); // job.log(`the job '${job.name}' is running`);
const { vodId } = job.data.vodId; // const { vodId } = job.data.vodId;
job.log(`createFunscript called with vodId=${vodId}`); // job.log(`createFunscript called with vodId=${vodId}`);
const vod = await getVod(vodId); // const vod = await getVod(vodId);
if (!ensureVodReady(vod)) return; // if (!ensureVodReady(vod)) return;
const { s3Client, videoFilePath } = await downloadVideo(vod.sourceVideo!); // const { s3Client, videoFilePath } = await downloadVideo(vod.sourceVideo!);
const predictionOutputPath = await runInference(videoFilePath); // const predictionOutputPath = await runInference(videoFilePath);
const slug = vod.vtubers[0].slug; // const slug = vod.vtubers[0].slug;
if (!slug) // if (!slug)
throw new Error(`vod.vtubers[0].slug for vod ${vod.id} was falsy.`); // throw new Error(`vod.vtubers[0].slug for vod ${vod.id} was falsy.`);
const funscripts = await buildFunscripts( // const funscripts = await buildFunscripts(
predictionOutputPath, // predictionOutputPath,
videoFilePath // videoFilePath
); // );
const funscriptKeys = await uploadFunscripts( // const funscriptKeys = await uploadFunscripts(
s3Client, // s3Client,
slug, // slug,
vod.streamDate, // vod.streamDate,
vod.id, // vod.id,
funscripts // funscripts
); // );
await saveToDatabase(vodId, funscriptKeys); // await saveToDatabase(vodId, funscriptKeys);
}; // };

View File

@ -1,26 +1,23 @@
import type { Task, Helpers } from "graphile-worker";
import { PrismaClient } from "../../generated/prisma"; import env from "../../.config/env.ts";
import { withAccelerate } from "@prisma/extension-accelerate";
import { getOrDownloadAsset } from "../utils/cache";
import { env } from "../config/env";
import { S3Client } from "@aws-sdk/client-s3";
import { getS3Client, uploadFile } from "../utils/s3";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { basename, join, dirname } from "node:path"; import { basename, join } from "node:path";
import { mkdirp } from "fs-extra"; import { mkdirp } from "fs-extra";
import { listFilesRecursive } from "../utils/filesystem"; import { listFilesRecursive } from "../util/fsExtra.ts";
import { getMimeType } from "../utils/mimetype"; import spawn from "nano-spawn";
import { getNanoSpawn } from "../utils/nanoSpawn"; import { Job } from "bullmq";
import logger from "../utils/logger"; import { getSourceVideo } from "../util/cache.ts";
import { getPocketBaseClient } from "../util/pocketbase";
const prisma = new PrismaClient().$extends(withAccelerate()); import PocketBase, { RecordModel } from 'pocketbase';
import { readFile } from "node:fs/promises";
import { getS3KeyTarget } from "../util/b2.ts";
interface Payload { interface Payload {
vodId: string; vodId: string;
} }
interface HlsVariant { interface HlsVariant {
videoPath: string; videoPath: string;
resolution: string; // e.g. "1920x1080" resolution: string; // e.g. "1920x1080"
@ -29,31 +26,11 @@ interface HlsVariant {
} }
// // Create a fragmented MP4 for use with fMP4 HLS
// async function createFragmentedMp4(helpers: Helpers, inputFilePath: string) {
// const outputFilePath = join(env.CACHE_ROOT, `${nanoid()}.mp4`) export async function createVariants(job: Job, inputFilePath: string): Promise<HlsVariant[]> {
// await spawn('ffmpeg', [
// '-i', inputFilePath,
// '-c', 'copy',
// '-f', 'mp4',
// '-movflags', 'frag_keyframe+empty_moov',
// outputFilePath
// ], {
// stdout: 'inherit',
// stderr: 'inherit',
// })
// return outputFilePath
// }
export async function createVariants(helpers: Helpers, inputFilePath: string): Promise<HlsVariant[]> {
const workdir = join(env.CACHE_ROOT, nanoid()); const workdir = join(env.CACHE_ROOT, nanoid());
await mkdirp(workdir); await mkdirp(workdir);
const baseName = basename(inputFilePath, '.mp4'); const baseName = basename(inputFilePath, '.mp4');
const spawn = await getNanoSpawn()
const resolutions = [ const resolutions = [
{ width: 1920, height: 1080, bitrate: 4000000, name: '1080p' }, // 4Mbps { width: 1920, height: 1080, bitrate: 4000000, name: '1080p' }, // 4Mbps
@ -96,13 +73,14 @@ export async function createVariants(helpers: Helpers, inputFilePath: string): P
} }
export async function packageHls( export async function packageHls(
helpers: Helpers, job: Job,
variants: HlsVariant[], variants: HlsVariant[],
outputDir: string outputDir: string
): Promise<string> { ): Promise<string> {
const args: string[] = []; const args: string[] = [];
const spawn = await getNanoSpawn(); job.log(`packageHls called with ${variants.length} variants. ${Object.entries(variants).map(([i, variant]) => `${i}: ${variant.videoPath} ${variant.resolution}`).join(', ')}`);
// Sort by bandwidth (highest first) // Sort by bandwidth (highest first)
variants.sort((a, b) => b.bandwidth - a.bandwidth); variants.sort((a, b) => b.bandwidth - a.bandwidth);
@ -130,15 +108,33 @@ export async function packageHls(
args.push("--generate_static_live_mpd"); args.push("--generate_static_live_mpd");
args.push("--segment_duration=2"); args.push("--segment_duration=2");
await spawn("packager", args, { await spawn("packager", args);
stdout: "inherit",
stderr: "inherit",
});
return masterPlaylist; return masterPlaylist;
} }
/**
*
* uploadHlsAsset
*
* Save the playlist .m3u8 to S3.
* Then update the vod record in Pocketbase with the s3 key target path.
*
* example: `pbc_144770472/j8cgd0qqjbzyfi3/hls/master_j8cgd0qqjbzyfi3.m3u8`
*
* these files are later made available to users via our `/api/hls` route
* which creates 302 redirects with BunnyCDN token signed URLs to the CDN asssets
*/
export async function uploadHlsAsset(job: Job, pb: PocketBase, vod: RecordModel, assetPath: string): Promise<void> {
const vodId = vod.id;
const s3KeyTarget = getS3KeyTarget(vod.collectionId, vodId, assetPath, 'hls');
job.log(`uploading hls asset ${assetPath} to ${s3KeyTarget}`);
await spawn('b2', ['file', 'upload', env.AWS_BUCKET, assetPath, s3KeyTarget]);
await pb.collection('vods').update(vodId, { hlsPlaylist: s3KeyTarget });
}
function assertPayload(payload: any): asserts payload is Payload { function assertPayload(payload: any): asserts payload is Payload {
@ -147,19 +143,19 @@ function assertPayload(payload: any): asserts payload is Payload {
} }
export default async function createHlsPlaylist(payload: any, helpers: Helpers) {
assertPayload(payload)
const { vodId } = payload export async function createHlsPlaylist(job: Job) {
const vod = await prisma.vod.findFirstOrThrow({ assertPayload(job.data)
where: { const { vodId } = job.data;
id: vodId
} const pb = await getPocketBaseClient();
}) const vod = await pb.collection('vods').getOne(vodId);
// * [x] load vod
// * [x] exit if video.hlsPlaylist already defined // * [x] exit if video.hlsPlaylist already defined
if (vod.hlsPlaylist) { if (vod.hlsPlaylist) {
logger.info(`Doing nothing-- vod ${vodId} already has a hlsPlaylist.`) job.log(`Doing nothing-- vod ${vodId} already has a hlsPlaylist.`)
return; // Exit the function early return; // Exit the function early
} }
@ -167,54 +163,46 @@ export default async function createHlsPlaylist(payload: any, helpers: Helpers)
throw new Error(`Failed to create hlsPlaylist-- vod ${vodId} is missing a sourceVideo.`); throw new Error(`Failed to create hlsPlaylist-- vod ${vodId} is missing a sourceVideo.`);
} }
logger.info(`Creating HLS Playlist.`) job.log(`Creating HLS Playlist.`)
const s3Client = getS3Client()
const taskId = nanoid() const taskId = nanoid()
const workDirPath = join(env.CACHE_ROOT, taskId) const workDirPath = join(env.CACHE_ROOT, 'worker', taskId)
const packageDirPath = join(workDirPath, 'package', 'hls') const packageDirPath = join(workDirPath, 'package', 'hls')
await mkdirp(packageDirPath) await mkdirp(packageDirPath)
logger.info("download source video from pull-thru cache") job.log("download source video from pull-thru cache")
const videoFilePath = await getOrDownloadAsset(s3Client, env.S3_BUCKET, vod.sourceVideo) const videoFilePath = await getSourceVideo(job);
logger.info(`videoFilePath=${videoFilePath}`)
logger.info("create ABR variants")
const variants = await createVariants(helpers, videoFilePath)
logger.info('variants as follows')
logger.info(JSON.stringify(variants))
logger.info("run shaka packager") job.log(`videoFilePath=${videoFilePath}`)
const masterPlaylistPath = await packageHls(helpers, variants, packageDirPath)
logger.debug(`masterPlaylistPath=${masterPlaylistPath}`) job.log("create ABR variants")
const variants = await createVariants(job, videoFilePath)
job.log('variants as follows')
job.log(JSON.stringify(variants))
logger.info('uploading assets') job.log("run shaka packager")
const masterPlaylistPath = await packageHls(job, variants, packageDirPath)
job.log(`masterPlaylistPath=${masterPlaylistPath}`)
job.log('uploading assets')
let assets = await listFilesRecursive(workDirPath) let assets = await listFilesRecursive(workDirPath)
logger.info('assets as follows') job.log('assets as follows')
logger.info(JSON.stringify(assets)) job.log(JSON.stringify(assets))
for (let i = 0; i < assets.length; i++) { for (let i = 0; i < assets.length; i++) {
const asset = assets[i] const asset = assets[i];
const s3Key = `package/${taskId}/hls/${basename(asset)}` // const s3Key = `package/${taskId}/hls/${basename(asset)}`;
const mimetype = getMimeType(asset) // const mimetype = mime.lookup(asset);
await uploadFile(s3Client, env.S3_BUCKET, s3Key, asset, mimetype) const s3KeyTarget = getS3KeyTarget(vod.collectionId, vod.id, asset, 'hls');
job.log(`uploading asset ${asset} to ${s3KeyTarget}`);
await uploadHlsAsset(job, pb, vod, asset);
}; };
// await spawn('b2', ['file', 'upload', env.AWS_BUCKET, asset, s3KeyTarget]);
logger.info("generate thumbnail s3 key") job.log("generate thumbnail s3 key");
const s3Key = `package/${taskId}/hls/master.m3u8` await uploadHlsAsset(job, pb, vod, masterPlaylistPath);
await job.log('Finished creating HLS playlist');
// * [x] upload assets to s3
await uploadFile(s3Client, env.S3_BUCKET, s3Key, masterPlaylistPath, 'application/vnd.apple.mpegurl')
// await uploadFile(s3Client, env.S3_BUCKET, s3Key, masterPlaylistPath, 'application/vnd.apple.mpegurl')
// * [x] update vod record
await prisma.vod.update({
where: { id: vodId },
data: { hlsPlaylist: s3Key }
});
// * [x] done
} }

View File

@ -20,12 +20,13 @@
import { sshClient } from "../util/sftp"; import { seedboxSSHClient } from "../util/sftp";
import { qbtClient, QBTorrentInfo } from "../util/qbittorrent"; import { qbtClient, QBTorrentInfo } from "../util/qbittorrent";
import { Job } from "bullmq"; import { Job } from "bullmq";
import { getPocketBaseClient } from "../util/pocketbase"; import { getPocketBaseClient } from "../util/pocketbase";
import { basename } from 'node:path'; import { basename } from 'node:path';
import { cacheQueue, cacheQueueEvents } from "../queues/cacheQueue"; import { cacheQueue, cacheQueueEvents } from "../queues/cacheQueue";
import { highPriorityQueue } from "../queues/highPriorityQueue";
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
@ -163,7 +164,7 @@ async function uploadTorrentToSeedbox(job: Job, videoFilePath: string, torrentFi
job.log(`Uploading ${videoFilePath} to seedbox...`); job.log(`Uploading ${videoFilePath} to seedbox...`);
let lastLog = 0; // timestamp in ms let lastLog = 0; // timestamp in ms
await sshClient.uploadFile(videoFilePath, './data', async ({ percent }) => { await seedboxSSHClient.uploadFileToDir(videoFilePath, './data', async ({ percent }) => {
const now = Date.now(); const now = Date.now();
if (now - lastLog >= 1_000) { // 10 seconds if (now - lastLog >= 1_000) { // 10 seconds
lastLog = now; lastLog = now;
@ -172,7 +173,7 @@ async function uploadTorrentToSeedbox(job: Job, videoFilePath: string, torrentFi
}); });
job.log(`Uploading ${torrentFilePath} to seedbox...`); job.log(`Uploading ${torrentFilePath} to seedbox...`);
await sshClient.uploadFile(torrentFilePath, './watch'); await seedboxSSHClient.uploadFileToDir(torrentFilePath, './watch');
} }
@ -231,12 +232,17 @@ export async function createTorrent(job: Job) {
const formData = new FormData(); const formData = new FormData();
const torrentBuffer = await readFile(torrentFilePath) const torrentBuffer = await readFile(torrentFilePath)
formData.append('torrent', new Blob([torrentBuffer]), basename(torrentFilePath)); formData.append('torrent', new Blob([torrentBuffer as any]), basename(torrentFilePath));
formData.append('magnetLink', magnetLink); formData.append('magnetLink', magnetLink);
await pb.collection('vods').update(vod.id, formData); await pb.collection('vods').update(vod.id, formData);
// fire off an announce job so opentracker gets to know about this torrent
await highPriorityQueue.add(
'announceTorrent',
{ vodId },
{ jobId: `announceTorrent-${vodId}` }
);

View File

@ -5,11 +5,13 @@ import { generalQueue } from "../queues/generalQueue.ts";
import { muxQueue } from "../queues/muxQueue.ts"; import { muxQueue } from "../queues/muxQueue.ts";
import { b2Queue } from "../queues/b2Queue.ts"; import { b2Queue } from "../queues/b2Queue.ts";
import { shuffle } from "../util/random.ts"; import { shuffle } from "../util/random.ts";
import { gpuQueue } from "../queues/gpuQueue.ts";
const queues: Record<string, Queue> = { const queues: Record<string, Queue> = {
generalQueue: generalQueue, generalQueue: generalQueue,
muxQueue: muxQueue, muxQueue: muxQueue,
b2Queue: b2Queue, b2Queue: b2Queue,
gpuQueue: gpuQueue,
}; };
type VodJobConfig = { type VodJobConfig = {
@ -31,7 +33,7 @@ async function handleMissing(job: Job, pb: Client, config: VodJobConfig) {
// @todo figure out a better way to handle permafailed vod processing tasks that isn't a ticking timebomb. // @todo figure out a better way to handle permafailed vod processing tasks that isn't a ticking timebomb.
const results = await pb.collection('vods').getList(1, 3, { const results = await pb.collection('vods').getList(1, 3, {
filter: config.filter, filter: config.filter,
sort: '-created', sort: '-created', // newest first
}); });
const vods = results.items; const vods = results.items;
@ -53,6 +55,8 @@ async function handleMissing(job: Job, pb: Client, config: VodJobConfig) {
const attempts = 3; const attempts = 3;
const queue = queues[config.queueName]; const queue = queues[config.queueName];
if (!queue?.name) throw new Error(`failed to get a valid queue: ${config.queueName}. Did you forget to add the queue to the findWork queue map at the top of the module?`);
await queue.add(config.processorName, { vodId }, { jobId, attempts }); await queue.add(config.processorName, { vodId }, { jobId, attempts });
} }
@ -124,6 +128,17 @@ async function handleMissing(job: Job, pb: Client, config: VodJobConfig) {
export async function handleMissingFunscripts(job: Job, pb: Client) {
return handleMissing(job, pb, {
filter: "sourceVideo != '' && (funscriptVibrate = '' || funscriptThrust = '')",
queueName: 'gpuQueue',
processorName: 'createFunscript',
logMessage: (id) => `findWork found ${id} in need of a funscript.`
});
}
export async function handleMissingTorrent(job: Job, pb: Client) { export async function handleMissingTorrent(job: Job, pb: Client) {
return handleMissing(job, pb, { return handleMissing(job, pb, {
filter: "sourceVideo != '' && magnetLink = ''", filter: "sourceVideo != '' && magnetLink = ''",
@ -195,9 +210,9 @@ export async function findWork(job: Job) {
await handleMissingSourceVideo(job, pb); await handleMissingSourceVideo(job, pb);
await handleMissingMuxAsset(job, pb); await handleMissingMuxAsset(job, pb);
await handleMissingThumbnail(job, pb); await handleMissingThumbnail(job, pb);
await handleMissingFunscripts(job, pb);
// findMissingThumbnail
// findMissingAudioAnalysis // findMissingAudioAnalysis
// ... etc. // ... etc.

View File

@ -0,0 +1,111 @@
import { Job } from "bullmq";
import { getPocketBaseClient } from "../util/pocketbase";
import Client, { type RecordModel } from "pocketbase";
import { Vod } from "../types";
import { writeFile } from "fs/promises";
import { decode } from "magnet-uri";
import { nanoid } from "nanoid";
import env from "../../.config/env";
import { join } from "path";
import { opentrackerSSHClient } from '../util/sftp.ts';
import spawn from 'nano-spawn';
import retry from "../util/retry.ts";
interface Payload { }
function assertPayload(payload: any): asserts payload is Payload {
if (typeof payload !== "object" || !payload) throw new Error("invalid payload-- was not an object.");
}
/**
* getApplicableVods
*
*/
async function getApplicableVods(pb: Client) {
const vods = await pb.collection('vods').getFullList({
filter: "magnetLink != ''"
})
return vods;
}
async function generateWhitelist(job: Job, vods: RecordModel[]) {
let whitelistLines = [];
for (let i = 0; i < vods.length; i++) {
const vod = vods[i];
const magnetLink = vod.magnetLink;
let parsed;
try {
parsed = decode(magnetLink);
} catch (err) {
await job.log(`Failed to parse magnet link for VOD ${vod.id}: ${err}`);
continue;
}
const infoHash = parsed?.infoHash;
// @ts-ignore I think @types/magnet-uri is missing infoHashV2 property
const infoHashV2 = parsed?.infoHashV2;
if (!infoHash) {
throw new Error(`vod ${vod.id} is missing infoHash`);
}
if (!infoHashV2) {
throw new Error(`vod ${vod.id} is missing infoHashV2`);
}
whitelistLines.push(infoHash);
whitelistLines.push(infoHashV2);
}
await job.log(`whitelist contains ${whitelistLines.length} lines.`);
const whitelistTmpFile = join(env.CACHE_ROOT, 'worker', `tracker-whitelist-${nanoid()}.txt`);
await job.log('write whitelist file to ' + whitelistTmpFile);
await writeFile(whitelistTmpFile, whitelistLines.join('\n') + '\n', { encoding: 'utf-8' });
return whitelistTmpFile;
}
async function uploadWhitelist(job: Job, whitelistTmpFile: string) {
await job.updateProgress(0);
return retry(
async () => {
await opentrackerSSHClient.uploadFileAs(
whitelistTmpFile,
'/etc/opentracker/whitelist.txt',
async ({ transferred, total, percent }) => {
await job.updateProgress(percent / 100); // updateProgress expects 0..1
}
);
},
3,
3000
);
}
export async function updateTrackerWhitelist(job: Job) {
assertPayload(job.data);
const pb = await getPocketBaseClient();
const vods = await getApplicableVods(pb);
await job.log(`There are ${vods.length} vods with magnetLink that we will use to populate the opentracker whitelist.`);
const whitelistTmpFile = await generateWhitelist(job, vods);
await job.log('uploading whitelist to tracker...');
await uploadWhitelist(job, whitelistTmpFile);
await job.log('Reloading opentracker');
await spawn('systemctl', ['--host=tracker', 'reload', 'opentracker.service']);
await job.log('All done.');
}

View File

@ -5,4 +5,3 @@ export const downloadQueueEvents = new QueueEvents("downloadQueue", {
connection connection
}); });
await downloadQueue.setGlobalConcurrency(1);

View File

@ -13,3 +13,15 @@ await highPriorityQueue.upsertJobScheduler(
opts: {} opts: {}
}, },
) )
await highPriorityQueue.upsertJobScheduler(
'updateTrackerWhitelist-recurring',
{
pattern: '0 * * * *'
},
{
name: 'updateTrackerWhitelist',
data: {},
opts: {}
}
)

View File

@ -1,7 +1,28 @@
import spawn from 'nano-spawn'; import spawn from 'nano-spawn';
import env from '../../.config/env'; import env from '../../.config/env';
import { basename } from 'node:path';
export async function b2Download(fromS3Key: string, tmpFilePath: string): Promise<string> { export async function b2Download(fromS3Key: string, tmpFilePath: string): Promise<string> {
await spawn('b2', ['file', 'download', `b2://${env.AWS_BUCKET}/${fromS3Key}`, tmpFilePath]); await spawn('b2', ['file', 'download', `b2://${env.AWS_BUCKET}/${fromS3Key}`, tmpFilePath]);
return fromS3Key; return fromS3Key;
} }
/**
* We put the hls package under `/:collection/:id/hls/` so we can use Bunny CDN's token singing
* to allow token access to all the files within that folder
* @see https://support.bunny.net/hc/en-us/articles/360016055099-How-to-sign-URLs-for-BunnyCDN-Token-Authentication
*/
export function getS3KeyTarget(collectionId: string, vodId: string, filename: string, subFolder: string | null) {
if (!filename) throw new Error('third param filename was missing');
if (!vodId) throw new Error('second param vodId was missing');
if (!collectionId) throw new Error('first param collectionId was missing');
let s3KeyTarget: string;
let name = basename(filename);
if (subFolder) {
s3KeyTarget = `${collectionId}/${vodId}/${subFolder}/${name}`;
} else {
s3KeyTarget = `${collectionId}/${vodId}/${name}`;
}
return s3KeyTarget
}

View File

@ -1,30 +1,58 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { signUrl } from "./bunnyCDN.ts"; import { signUrl } from "./bunnyCDN.ts";
import env from "../../.config/env.ts";
describe("signUrl", () => { describe("signUrl unit", () => {
it("generates a correct signed BunnyCDN URL", () => { it("generates a correct signed BunnyCDN URL", () => {
const securityKey = "my-secret"; const securityKey = "my-secret";
const baseUrl = "https://cdn.example.com"; const url = "https://cdn.example.com/videos/test.mp4?width=500&quality=80";
const path = "/videos/test.mp4";
const rawQuery = "width=500&quality=80";
const expires = 1732600000; const expires = 1732600000;
const signed = signUrl(securityKey, baseUrl, path, rawQuery, expires); const signed = signUrl(securityKey, url, expires);
// token part is deterministic but long, so let's just check it contains required parts // token part is deterministic but long, so let's just check it contains required parts
expect(signed).toContain(baseUrl + path);
expect(signed).toContain("token="); expect(signed).toContain("token=");
expect(signed).toContain("quality=80&width=500"); expect(signed).toContain("quality=80");
expect(signed).toContain("width=500");
expect(signed).toContain(`expires=${expires}`); expect(signed).toContain(`expires=${expires}`);
}); });
it("throws if baseUrl ends with slash", () => { it("throws if baseUrl ends with slash", () => {
expect(() => signUrl("k", "https://example.com/", "/file.jpg", "", 123)) expect(() => signUrl("k", "https://example.com/file.jpg/", 123))
.toThrow(/must not end with a slash/); .toThrow(/must not end with a slash/);
}); });
it("prepends slash if path does not start with one", () => { it("prepends slash if path does not start with one", () => {
const out = signUrl("k", "https://x", "file.jpg", "", 1); const out = signUrl("k", "https://x/file.jpg", 1);
expect(out.startsWith("https://x/file.jpg")).toBe(true); expect(out.startsWith("https://x/file.jpg")).toBe(true);
}); });
it('allows a token_path arg', () => {
const url = signUrl("k", "https://example.com/vods/abcd/hls/master_abcd.m3u8", 123, "/vods/abcd/hls/")
expect(url).toMatch(/token=[A-Za-z0-9\-_]+/);
expect(url).toMatch(/expires=123/);
expect(url).toContain(`token_path=${encodeURIComponent('/vods/abcd/hls/')}`);
});
}); });
describe("signUrl integration", () => {
const expires = Math.floor(Date.now() / 1000) + 3 * 60 * 60; // 3h from now
it('should generate a URL that returns HTTP 200', async () => {
const path = '/pbc_144770472/j8cgd0qqjbzyfi3/2025_11_29_test_j8cgd0qqjbzyfi3_thumb_fv49sz62ro.png'
const inputUrl = `${env.BUNNY_ZONE_URL}${path}`;
const signedUrl = signUrl(env.BUNNY_TOKEN_KEY, inputUrl, expires);
const res = await fetch(signedUrl);
expect(res.status).toBeOneOf([200, 201]);
});
it('should generate a token_path URL that returns HTTP 200', async () => {
const pathAllowed = '/pbc_144770472/j8cgd0qqjbzyfi3/hls/';
const path = pathAllowed + 'master.m3u8';
const url = signUrl(env.BUNNY_TOKEN_KEY, `${env.BUNNY_ZONE_URL}${path}`, expires, pathAllowed);
console.log(url);
const res = await fetch(url);
expect(res.status).toBeOneOf([200, 201]);
});
})

View File

@ -1,36 +1,53 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import { escape } from 'node:querystring';
/** /**
* Generate a signed BunnyCDN URL. * Generate a signed BunnyCDN URL.
* *
* @see https://support.bunny.net/hc/en-us/articles/360016055099-How-to-sign-URLs-for-BunnyCDN-Token-Authentication * @see https://support.bunny.net/hc/en-us/articles/360016055099-How-to-sign-URLs-for-BunnyCDN-Token-Authentication
*/ */
export function signUrl(securityKey: string, baseUrl: string, path: string, rawQuery = "", expires: number) { export function signUrl(
if (!path) throw new Error('signUrl requires a path argument, but it was falsy.'); securityKey: string,
if (!path.startsWith('/')) path = '/' + path; url: string,
if (baseUrl.endsWith('/')) throw new Error(`baseUrl must not end with a slash. got baseUrl=${baseUrl}`); expires: number,
pathAllowed: string = '',
) {
if (url.endsWith('/')) throw new Error('url must not end with a slash.');
// Build parameter string (sort keys alphabetically) let parsedURL = new URL(url);
let parameterData = ""; let params = (new URL(parsedURL)).searchParams;
if (rawQuery) { let signaturePath = '';
const params = rawQuery let parameterData = '';
.split("&") let parameterDataUrl = '';
.map(p => p.split("="))
.filter(([key]) => key && key !== "token" && key !== "expires")
.sort(([a], [b]) => a.localeCompare(b));
if (params.length) { if (pathAllowed) {
parameterData = params.map(([k, v]) => `${k}=${v}`).join("&"); signaturePath = pathAllowed;
} params.set('token_path', signaturePath);
} else {
signaturePath = decodeURIComponent(parsedURL.pathname);
} }
// Build hashable base params.sort();
const hashableBase = securityKey + path + expires + parameterData; if (Array.from(params).length > 0) {
// console.log(`hashableBase`, hashableBase) params.forEach(function (value, key) {
if (value == "") {
return;
}
if (parameterData.length > 0) {
parameterData += "&";
}
parameterData += key + "=" + value;
parameterDataUrl += "&" + key + "=" + escape(value);
});
}
const hashableBase = securityKey + signaturePath + expires + parameterData;
// const hashableBase = securityKey + path + expires + parameterData;
console.log(`hashableBase`, hashableBase)
// Compute token using your $security.sha256 workflow
const tokenH = crypto.createHash("sha256").update(hashableBase).digest("hex"); const tokenH = crypto.createHash("sha256").update(hashableBase).digest("hex");
const token = Buffer.from(tokenH, "hex") const token = Buffer.from(tokenH, "hex")
@ -41,9 +58,7 @@ export function signUrl(securityKey: string, baseUrl: string, path: string, rawQ
.replace(/=/g, ""); .replace(/=/g, "");
// Build final signed URL // Build final signed URL
let tokenUrl = baseUrl + path + "?token=" + token; return `${parsedURL.protocol}//${parsedURL.host}${parsedURL.pathname}?token=${token}${parameterDataUrl}&expires=${expires}`;
if (parameterData) tokenUrl += "&" + parameterData;
tokenUrl += "&expires=" + expires;
return tokenUrl;
} }

View File

@ -0,0 +1,21 @@
import { Job } from "bullmq";
import { cacheQueue, cacheQueueEvents } from "../queues/cacheQueue";
type CachePath = string;
export async function getSourceVideo(job: Job, timeout: number = 1000 * 60 * 60 * 5): Promise<CachePath> {
const vodId = job.data.vodId;
await job.log(`First we need to get the vod from cache...`);
const cacheGetJob = await cacheQueue.add(
'cacheGet',
{ vodId },
{ jobId: `cacheGet-${vodId}` }
);
const results = (await cacheGetJob.waitUntilFinished(cacheQueueEvents, timeout));
await job.log(`cacheGet results: ${JSON.stringify(results)}`);
const { cachePath } = results;
return cachePath
}

View File

@ -0,0 +1,29 @@
import { writeFile, readdir } from "fs/promises";
import { isAbsolute } from "path";
import { join } from "path";
export async function writeJson(outputPath: string, json: any) {
if (!outputPath) throw new Error('writeJson needs an outputPath as first arg');
if (!json) throw new Error('writeJson needs json object as second arg');
if (!isAbsolute(outputPath)) throw new Error('first arg to writeJson must be an absolute path');
if (outputPath === '/') throw new Error('first arg outputPath cannot be /');
await writeFile(outputPath, JSON.stringify(json));
}
export async function listFilesRecursive(dir: string): Promise<string[]> {
const entries = await readdir(dir, { withFileTypes: true });
const files: string[] = [];
for (const entry of entries) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
const subFiles = await listFilesRecursive(fullPath);
files.push(...subFiles);
} else if (entry.isFile()) {
files.push(fullPath);
}
}
return files;
}

View File

@ -1,11 +1,12 @@
// src/utils/funscripts.ts // src/utils/funscripts.ts
import { join } from "node:path"; import { join } from "node:path";
import { writeJson } from "fs-extra"; import env from "../../.config/env.ts";
import { env } from "../config/env";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { loadDataYaml, loadVideoMetadata, processLabelFiles } from "./vibeui"; import { loadDataYaml, loadVideoMetadata, processLabelFiles } from "./vibeui";
import logger from "./logger"; import { writeJson } from "./fsExtra.ts";
import { mkdirp } from "fs-extra";
export interface FunscriptAction { export interface FunscriptAction {
at: number; at: number;
@ -168,7 +169,7 @@ export function generateActions(
export async function writeFunscript(outputPath: string, actions: FunscriptAction[]) { export async function writeFunscript(outputPath: string, actions: FunscriptAction[]) {
const funscript: Funscript = { version: '1.0', actions }; const funscript: Funscript = { version: '1.0', actions };
await writeJson(outputPath, funscript); await writeJson(outputPath, funscript);
logger.debug(`Funscript generated: ${outputPath} (${actions.length} actions)`); console.log(`Funscript generated: ${outputPath} (${actions.length} actions)`);
} }
/** /**
@ -182,7 +183,9 @@ export async function buildFunscript(
if (!type) throw new Error("buildFunscript requires type: 'vibrate' or 'thrust'"); if (!type) throw new Error("buildFunscript requires type: 'vibrate' or 'thrust'");
const labelDir = join(predictionOutput, 'labels'); const labelDir = join(predictionOutput, 'labels');
const outputPath = join(process.env.CACHE_ROOT ?? '/tmp', `${nanoid()}.funscript`); const workDir = join(env.CACHE_ROOT, 'worker', nanoid());
const outputPath = join(workDir, `${type}.funscript`);
await mkdirp(workDir);
try { try {
const data = await loadDataYaml(join(env.VIBEUI_DIR, 'data.yaml')); const data = await loadDataYaml(join(env.VIBEUI_DIR, 'data.yaml'));
@ -195,7 +198,7 @@ export async function buildFunscript(
return outputPath; return outputPath;
} catch (error) { } catch (error) {
logger.error(`Error generating Funscript: ${error instanceof Error ? error.message : 'Unknown error'}`); console.error(`Error generating Funscript: ${error instanceof Error ? error.message : 'Unknown error'}`);
throw error; throw error;
} }
} }

View File

@ -22,13 +22,14 @@ import { nanoid } from 'nanoid';
import semverParse from 'semver/functions/parse'; import semverParse from 'semver/functions/parse';
import { type SemVer } from 'semver'; import { type SemVer } from 'semver';
import retry from "./retry"; import retry from "./retry";
import { Job } from "bullmq";
interface QBittorrentClientOptions { interface QBittorrentClientOptions {
host?: string; host?: string;
port?: number; port?: number;
username?: string; username?: string;
password?: string; password?: string;
job?: Job;
} }
@ -123,6 +124,7 @@ export class QBittorrentClient {
private readonly username: string; private readonly username: string;
private readonly password: string; private readonly password: string;
private readonly baseUrl: string; private readonly baseUrl: string;
private readonly job: Job | undefined;
private sidCookie: string | null = null; private sidCookie: string | null = null;
constructor(options: QBittorrentClientOptions = {}) { constructor(options: QBittorrentClientOptions = {}) {
@ -140,7 +142,7 @@ export class QBittorrentClient {
password: env.QBT_PASSWORD!, password: env.QBT_PASSWORD!,
}; };
const { host, port, username, password } = { const { host, port, username, password, job } = {
...defaults, ...defaults,
...envOptions, ...envOptions,
...options, ...options,
@ -149,10 +151,20 @@ export class QBittorrentClient {
this.port = port; this.port = port;
this.username = username; this.username = username;
this.password = password; this.password = password;
this.job = job;
this.baseUrl = `http://${this.host}:${this.port}`; this.baseUrl = `http://${this.host}:${this.port}`;
} }
log(msg: string): void {
if (this.job) {
this.job.log(msg);
} else {
console.warn('A Job instance was not passed to qbittorrent constructor so we are logging to console.');
console.log(msg);
}
}
/** /**
* idempotently login to qBittorrent. * idempotently login to qBittorrent.
* *
@ -160,7 +172,7 @@ export class QBittorrentClient {
*/ */
async connect(): Promise<void> { async connect(): Promise<void> {
if (!this.sidCookie) { if (!this.sidCookie) {
console.log(`Connecting to qBittorrent at ${this.baseUrl}`); this.log(`Connecting to qBittorrent at ${this.baseUrl}`);
await this.__login(); await this.__login();
} }
} }
@ -199,7 +211,7 @@ export class QBittorrentClient {
* Then use the returned SID cookie for subsequent requests. * Then use the returned SID cookie for subsequent requests.
*/ */
private async __login(): Promise<void> { private async __login(): Promise<void> {
console.log(`login() begin. using username=${this.username}, password=${this.password} env=${env.NODE_ENV}`); this.log(`login() begin. using username=${this.username}, password=${this.password} env=${env.NODE_ENV}`);
const response = await fetch(`${this.baseUrl}/api/v2/auth/login`, { const response = await fetch(`${this.baseUrl}/api/v2/auth/login`, {
method: "POST", method: "POST",
headers: { headers: {
@ -216,25 +228,25 @@ export class QBittorrentClient {
if (!response.ok) { if (!response.ok) {
const msg = `Login request failed (${response.status} ${response.statusText}). body=${responseBody}`; const msg = `Login request failed (${response.status} ${response.statusText}). body=${responseBody}`;
console.error(msg); this.log(msg);
throw new Error(msg); throw new Error(msg);
} }
console.log(`Login response: status=${response.status} ${response.statusText}`); this.log(`Login response: status=${response.status} ${response.statusText}`);
console.log(`Headers: ${JSON.stringify([...response.headers.entries()])}`); this.log(`Headers: ${JSON.stringify([...response.headers.entries()])}`);
// Extract SID cookie // Extract SID cookie
const setCookie = response.headers.get("set-cookie"); const setCookie = response.headers.get("set-cookie");
if (!setCookie) { if (!setCookie) {
const msg = `Login failed: No SID cookie was returned. status=${response.status} ${response.statusText}. body=${responseBody}`; const msg = `Login failed: No SID cookie was returned. status=${response.status} ${response.statusText}. body=${responseBody}`;
console.error(msg); this.log(msg);
throw new Error(msg); throw new Error(msg);
} }
this.sidCookie = setCookie; this.sidCookie = setCookie;
console.log(`sidCookie=${this.sidCookie}`); this.log(`sidCookie=${this.sidCookie}`);
console.log("Successfully logged into qBittorrent."); this.log("Successfully logged into qBittorrent.");
} }
@ -252,10 +264,10 @@ export class QBittorrentClient {
private async addTorrentCreationTask(sourcePath: string): Promise<string> { private async addTorrentCreationTask(sourcePath: string): Promise<string> {
const torrentFilePath = join(tmpdir(), `${nanoid()}.torrent`); const torrentFilePath = join(tmpdir(), `${nanoid()}.torrent`);
const url = `${this.baseUrl}/api/v2/torrentcreator/addTask`; const url = `${this.baseUrl}/api/v2/torrentcreator/addTask`;
console.log(`addTorrentCreationTask using sourcePath=${sourcePath}, url=${url}`); this.log(`addTorrentCreationTask using sourcePath=${sourcePath}, url=${url}`);
console.log(`addTorrent using sourcePath=${sourcePath}`) this.log(`addTorrent using sourcePath=${sourcePath}`)
if (!this.sidCookie) { if (!this.sidCookie) {
throw new Error("Not connected: SID cookie missing"); throw new Error("Not connected: SID cookie missing");
@ -293,7 +305,7 @@ export class QBittorrentClient {
throw new Error(`addTorrentCreationTask failed: ${res.status} ${res.statusText} ${body}`); throw new Error(`addTorrentCreationTask failed: ${res.status} ${res.statusText} ${body}`);
} }
console.log('addTorrent success.'); this.log('addTorrent success.');
@ -309,14 +321,14 @@ export class QBittorrentClient {
} }
const data = JSON.parse(text); const data = JSON.parse(text);
console.log({ addTaskResponse: data }); this.log(JSON.stringify({ addTaskResponse: data }));
return data.taskID; return data.taskID;
} }
private async pollTorrentStatus(taskId: string): Promise<TorrentCreatorTaskStatus> { private async pollTorrentStatus(taskId: string): Promise<TorrentCreatorTaskStatus> {
while (true) { while (true) {
console.log(`Polling torrent creation taskID=${taskId}`); this.log(`Polling torrent creation taskID=${taskId}`);
const res = await fetch( const res = await fetch(
`${this.baseUrl}/api/v2/torrentcreator/status?taskId=${taskId}`, `${this.baseUrl}/api/v2/torrentcreator/status?taskId=${taskId}`,
@ -326,12 +338,12 @@ export class QBittorrentClient {
if (!res.ok) { if (!res.ok) {
throw new Error(`status failed: ${res.status} ${res.statusText}`); throw new Error(`status failed: ${res.status} ${res.statusText}`);
} }
console.log('the request to poll for torrent status was successful.') this.log('the request to poll for torrent status was successful.')
const statusMap = (await res.json()) as TorrentCreatorTaskStatusMap; const statusMap = (await res.json()) as TorrentCreatorTaskStatusMap;
console.log({ statusMap: statusMap }) this.log(JSON.stringify({ statusMap: statusMap }))
const task = Object.values(statusMap).find((t) => t.taskID === taskId); const task = Object.values(statusMap).find((t) => t.taskID === taskId);
console.log({ task: task }) this.log(JSON.stringify({ task: task }))
@ -339,13 +351,13 @@ export class QBittorrentClient {
throw new Error(`Task ${taskId} not found in status response`); throw new Error(`Task ${taskId} not found in status response`);
} }
console.log(` Torrent creator task status=${task.status}`); this.log(` Torrent creator task status=${task.status}`);
switch (task.status) { switch (task.status) {
case "Failed": case "Failed":
const msg = `Torrent creation failed: ${task.errorMessage}`; const msg = `Torrent creation failed: ${task.errorMessage}`;
console.error(msg); this.log(msg);
console.log('here is the task that failed', task); this.log('here is the task that failed', task);
throw new Error(msg); throw new Error(msg);
case "Finished": case "Finished":
return task; return task;
@ -417,9 +429,9 @@ export class QBittorrentClient {
}); });
if (!torrentsRes.ok) { if (!torrentsRes.ok) {
console.error('__getTorrentInfos failed to fetch() torrent info.'); this.log('__getTorrentInfos failed to fetch() torrent info.');
const body = await torrentsRes.text(); const body = await torrentsRes.text();
console.error(`${torrentsRes.status} ${torrentsRes.statusText} ${body}`); this.log(`${torrentsRes.status} ${torrentsRes.statusText} ${body}`);
} }
const torrents = await torrentsRes.json() as Array<{ hash: string; name: string }>; const torrents = await torrentsRes.json() as Array<{ hash: string; name: string }>;
@ -432,7 +444,7 @@ export class QBittorrentClient {
} }
async getInfoHashV2(torrentName: string): Promise<string> { async getInfoHashV2(torrentName: string): Promise<string> {
console.log(`getInfoHashV2 using torrentName=${torrentName}`) this.log(`getInfoHashV2 using torrentName=${torrentName}`)
const torrent = await this.getTorrentInfos(torrentName); const torrent = await this.getTorrentInfos(torrentName);
@ -456,7 +468,7 @@ export class QBittorrentClient {
*/ */
private async __addTorrent(localFilePath: string): Promise<void> { private async __addTorrent(localFilePath: string): Promise<void> {
console.log(`__addTorrent using localFilePath=${localFilePath}`) this.log(`__addTorrent using localFilePath=${localFilePath}`)
if (!this.sidCookie) { if (!this.sidCookie) {
throw new Error("Not connected. (SID cookie missing.)"); throw new Error("Not connected. (SID cookie missing.)");
@ -487,7 +499,7 @@ export class QBittorrentClient {
throw new Error(`__addTorrent failed: ${res.status} ${res.statusText} ${body}`); throw new Error(`__addTorrent failed: ${res.status} ${res.statusText} ${body}`);
} }
console.log('__addTorrent success.'); this.log('__addTorrent success.');
} }
/* /*
@ -498,7 +510,7 @@ export class QBittorrentClient {
*/ */
async deleteTorrent(id: string): Promise<void> { async deleteTorrent(id: string): Promise<void> {
await this.connect(); await this.connect();
console.log(`Deleting torrent ${id}...`); this.log(`Deleting torrent ${id}...`);
if (!this.sidCookie) { if (!this.sidCookie) {
throw new Error('Not logged in. sidCookie missing.'); throw new Error('Not logged in. sidCookie missing.');
@ -515,14 +527,14 @@ export class QBittorrentClient {
} else { } else {
// Not a hash → treat as name → look up hash // Not a hash → treat as name → look up hash
const info = await this.getTorrentInfos(id); const info = await this.getTorrentInfos(id);
console.log('info', info); this.log('info', info);
hashToDelete = info.hash; hashToDelete = info.hash;
} }
console.log(`deleting ${id} (${hashToDelete})`); this.log(`deleting ${id} (${hashToDelete})`);
await this.__deleteTorrent(hashToDelete); await this.__deleteTorrent(hashToDelete);
console.log(`deleteTorrent success for: ${hashToDelete}`); this.log(`deleteTorrent success for: ${hashToDelete}`);
} }
/** /**
@ -533,7 +545,7 @@ export class QBittorrentClient {
if (!hashes) throw new Error('__deleteTorrent hashes arg missing'); if (!hashes) throw new Error('__deleteTorrent hashes arg missing');
if (!this.sidCookie) throw new Error('__deleteTorrent missing sidCookie'); if (!this.sidCookie) throw new Error('__deleteTorrent missing sidCookie');
console.log(`deleting hashes`, hashes) this.log(`deleting hashes`, hashes)
const body = new URLSearchParams({ const body = new URLSearchParams({
hashes, hashes,
@ -559,7 +571,7 @@ export class QBittorrentClient {
* @deprecated use getTorrentInfos instead * @deprecated use getTorrentInfos instead
*/ */
async getMagnetLink(fileName: string): Promise<string> { async getMagnetLink(fileName: string): Promise<string> {
console.log(`getMagnetLink using fileName=${fileName}`) this.log(`getMagnetLink using fileName=${fileName}`)
// qBittorrent does NOT return infoHash directly here // qBittorrent does NOT return infoHash directly here
// we have to get it by querying the torrents list // we have to get it by querying the torrents list
@ -573,7 +585,7 @@ export class QBittorrentClient {
} }
async createTorrent(localFilePath: string): Promise<{ torrentFilePath: string; magnetLink: string, info: QBTorrentInfo }> { async createTorrent(localFilePath: string): Promise<{ torrentFilePath: string; magnetLink: string, info: QBTorrentInfo }> {
console.log(`Creating torrent from file: ${localFilePath}`); this.log(`Creating torrent from file: ${localFilePath}`);
await this.connect(); await this.connect();
if (!this.sidCookie) { if (!this.sidCookie) {
@ -582,7 +594,7 @@ export class QBittorrentClient {
// 1. start task // 1. start task
const taskId = await this.addTorrentCreationTask(localFilePath); const taskId = await this.addTorrentCreationTask(localFilePath);
console.log(`Created torrent task ${taskId}`); this.log(`Created torrent task ${taskId}`);
// 2. poll until finished // 2. poll until finished
await this.pollTorrentStatus(taskId); await this.pollTorrentStatus(taskId);
@ -597,7 +609,7 @@ export class QBittorrentClient {
await this.__addTorrent(torrentFilePath); await this.__addTorrent(torrentFilePath);
// 5. Get magnet link // 5. Get magnet link
console.log('lets get the torrent infos'); this.log('lets get the torrent infos');
const info = await this.getTorrentInfos(basename(localFilePath)) const info = await this.getTorrentInfos(basename(localFilePath))
const magnetLink = info.magnet_uri; const magnetLink = info.magnet_uri;

View File

@ -1,6 +1,6 @@
import { test, describe, expect } from 'vitest'; import { test, describe, expect } from 'vitest';
import { join } from "node:path"; import { join } from "node:path";
import { sshClient } from './sftp.ts'; import { seedboxSSHClient } from './sftp.ts';
const fixturesDir = join(import.meta.dirname, '..', 'fixtures'); const fixturesDir = join(import.meta.dirname, '..', 'fixtures');
const remoteUploadDir = "/upload" const remoteUploadDir = "/upload"
@ -11,13 +11,13 @@ describe('sftp integration', () => {
let filePath = join(fixturesDir, 'pizza.avif'); let filePath = join(fixturesDir, 'pizza.avif');
await expect( await expect(
sshClient.uploadFile(filePath, remoteUploadDir) seedboxSSHClient.uploadFileToDir(filePath, remoteUploadDir)
).resolves.toBeUndefined(); ).resolves.toBeUndefined();
}); });
test("uploadFile rejects on missing local file", async () => { test("uploadFile rejects on missing local file", async () => {
await expect( await expect(
sshClient.uploadFile("/does/not/exist.jpg", remoteUploadDir) seedboxSSHClient.uploadFileToDir("/does/not/exist.jpg", remoteUploadDir)
).rejects.toThrow(); ).rejects.toThrow();
}); });
}) })

View File

@ -2,13 +2,13 @@
import { Client, ConnectConfig, SFTPWrapper } from 'ssh2'; import { Client, ConnectConfig, SFTPWrapper } from 'ssh2';
import path from 'path'; import path from 'path';
import env from '../../.config/env'; import env from '../../.config/env';
import { readFileSync } from 'fs';
interface SSHClientOptions { interface SSHClientOptions {
host: string; host: string;
port?: number; port?: number;
username: string; username: string;
password?: string; privateKey: string | Buffer;
privateKey?: Buffer;
} }
export class SSHClient { export class SSHClient {
@ -18,6 +18,12 @@ export class SSHClient {
constructor(private options: SSHClientOptions) { } constructor(private options: SSHClientOptions) { }
static getSFTPPrivateKey(keyFile: string) {
if (!keyFile) throw new Error('no keyFile passed to getSFTPPrivateKey');
console.log(`we are loading keyFile=${keyFile}`);
return readFileSync(keyFile);
}
async connect(): Promise<void> { async connect(): Promise<void> {
if (this.connected) return; if (this.connected) return;
@ -29,7 +35,6 @@ export class SSHClient {
host: this.options.host, host: this.options.host,
port: this.options.port || 22, port: this.options.port || 22,
username: this.options.username, username: this.options.username,
password: this.options.password,
privateKey: this.options.privateKey, privateKey: this.options.privateKey,
} as ConnectConfig); } as ConnectConfig);
}); });
@ -86,7 +91,7 @@ export class SSHClient {
* @returns {Promise<void>} * @returns {Promise<void>}
* @throws {Error} If the upload fails. * @throws {Error} If the upload fails.
*/ */
async uploadFile( async uploadFileToDir(
localFilePath: string, localFilePath: string,
remoteDir: string, remoteDir: string,
onProgress?: (info: { transferred: number; total: number; percent: number }) => void onProgress?: (info: { transferred: number; total: number; percent: number }) => void
@ -105,7 +110,7 @@ export class SSHClient {
console.log(`fileName=${fileName}`) console.log(`fileName=${fileName}`)
const remoteFilePath = path.posix.join(remoteDir, fileName); const remoteFilePath = path.posix.join(remoteDir, fileName);
console.log(`remoteFilePath=${remoteFilePath}`) console.log(`remoteFilePath=${remoteFilePath}`);
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
sftp.fastPut( sftp.fastPut(
@ -126,6 +131,43 @@ export class SSHClient {
}); });
} }
/**
* Uploads a local file to a specific path on the remote server via SFTP.
* @param {string} localFilePath - Path to the local file.
* @param {string} remoteFullPath - Full remote file path including filename.
* @param {(info: { transferred: number; total: number; percent: number }) => void} [onProgress] - Optional progress callback.
* @returns {Promise<void>}
* @throws {Error} If the upload fails.
*/
async uploadFileAs(
localFilePath: string,
remoteFullPath: string,
onProgress?: (info: { transferred: number; total: number; percent: number }) => void
): Promise<void> {
console.log(`Uploading localFilePath=${localFilePath} to remoteFullPath=${remoteFullPath}...`);
await this.connect();
const sftp = await this.getSFTP();
await new Promise<void>((resolve, reject) => {
sftp.fastPut(
localFilePath,
remoteFullPath,
{
step: (transferred, chunk, total) => {
const percent = (transferred / total) * 100;
if (onProgress) {
onProgress({ transferred, total, percent });
}
}
},
(err) => (err ? reject(err) : resolve())
);
});
}
/** /**
* Downloads a file from the remote server via SFTP. * Downloads a file from the remote server via SFTP.
* @param {string} remoteFilePath - Path to the file on the remote server. * @param {string} remoteFilePath - Path to the file on the remote server.
@ -194,9 +236,17 @@ export class SSHClient {
/** /**
* Preconfigured SSHClient instance using environment-defined credentials. * Preconfigured SSHClient instance using environment-defined credentials.
*/ */
export const sshClient = new SSHClient({ export const seedboxSSHClient = new SSHClient({
host: env.SEEDBOX_SFTP_HOST, host: env.SEEDBOX_SFTP_HOST,
port: parseInt(env.SEEDBOX_SFTP_PORT), port: parseInt(env.SEEDBOX_SFTP_PORT),
username: env.SEEDBOX_SFTP_USERNAME, username: env.SEEDBOX_SFTP_USERNAME,
password: env.SEEDBOX_SFTP_PASSWORD, privateKey: SSHClient.getSFTPPrivateKey(env.SEEDBOX_SFTP_KEY_FILE),
});
export const opentrackerSSHClient = new SSHClient({
host: env.TRACKER_SFTP_HOST,
port: Number(env.TRACKER_SFTP_PORT),
username: env.TRACKER_SFTP_USERNAME,
privateKey: SSHClient.getSFTPPrivateKey(env.TRACKER_SFTP_KEY_FILE),
}); });

View File

@ -3,7 +3,7 @@ import { join } from "node:path";
import { readFile, writeFile, readdir, mkdir } from 'node:fs/promises'; import { readFile, writeFile, readdir, mkdir } from 'node:fs/promises';
import yaml from 'js-yaml'; import yaml from 'js-yaml';
import spawn from 'nano-spawn'; import spawn from 'nano-spawn';
import env from '../../env'; import env from '../../.config/env.ts';
import sharp from 'sharp'; import sharp from 'sharp';
import { Tensor, InferenceSession } from "onnxruntime-web"; import { Tensor, InferenceSession } from "onnxruntime-web";
@ -131,14 +131,20 @@ export async function loadDataYaml(yamlPath: string): Promise<DataYaml> {
*/ */
export async function inference(videoFilePath: string): Promise<string> { export async function inference(videoFilePath: string): Promise<string> {
const modelPath = join(env.VIBEUI_DIR, 'vibeui.pt') const modelPath = join(env.VIBEUI_DIR, "vibeui/runs/detect/vibeui/weights/best.pt");
// Generate a unique name based on video name + UUID const uniqueName = nanoid();
const uniqueName = nanoid()
const customProjectDir = 'vibeui/runs' // or any custom folder
const outputPath = join(env.APP_DIR, customProjectDir, uniqueName)
await spawn('yolo', [ // absolute path
const customProjectDir = join(env.VIBEUI_DIR, "vibeui/runs");
// correct output path
const outputPath = join(customProjectDir, uniqueName);
console.log(`vibeui inference with modelPath=${modelPath} and uniqueName=${uniqueName} and cwd=${env.VIBEUI_DIR} and outputPath=${outputPath}`);
const yoloPath = join(env.VIBEUI_DIR, '.venv/bin/yolo')
const proc = await spawn(yoloPath, [
'predict', 'predict',
`model=${modelPath}`, `model=${modelPath}`,
`source=${videoFilePath}`, `source=${videoFilePath}`,
@ -148,9 +154,12 @@ export async function inference(videoFilePath: string): Promise<string> {
`project=${customProjectDir}`, `project=${customProjectDir}`,
`name=${uniqueName}`, `name=${uniqueName}`,
], { ], {
cwd: env.APP_DIR, cwd: env.VIBEUI_DIR,
stdio: 'inherit', });
})
console.log(`yolo stdout: ${proc.stdout}`);
console.log(`yolo stderr: ${proc.stderr}`);
return outputPath // contains labels/ folder and predictions return outputPath // contains labels/ folder and predictions
} }
@ -207,14 +216,14 @@ export async function ffprobe(videoPath: string): Promise<{ fps: number; frames:
*/ */
export async function loadVideoMetadata(videoPath: string) { export async function loadVideoMetadata(videoPath: string) {
const { fps, frames: totalFrames } = await ffprobe(videoPath); const { fps, frames: totalFrames } = await ffprobe(videoPath);
// job.log(`Video metadata: fps=${fps}, frames=${totalFrames}`); // console.log(`Video metadata: fps=${fps}, frames=${totalFrames}`);
return { fps, totalFrames }; return { fps, totalFrames };
} }
export async function processLabelFiles(labelDir: string, data: DataYaml): Promise<Detection[]> { export async function processLabelFiles(labelDir: string, data: DataYaml): Promise<Detection[]> {
const labelFiles = (await readdir(labelDir)).filter(file => file.endsWith('.txt')); const labelFiles = (await readdir(labelDir)).filter(file => file.endsWith('.txt'));
job.log(`[processLabelFiles] Found label files: ${labelFiles.length}`); console.log(`[processLabelFiles] Found label files: ${labelFiles.length}`);
if (labelFiles.length === 0) job.log(`⚠️⚠️⚠️ no label files found! this should normally NOT happen unless the video contained no lovense overlay. ⚠️⚠️⚠️`); if (labelFiles.length === 0) console.log(`⚠️⚠️⚠️ no label files found! this should normally NOT happen unless the video contained no lovense overlay. ⚠️⚠️⚠️`);
const detections: Map<number, Detection[]> = new Map(); const detections: Map<number, Detection[]> = new Map();
const names = data.names; const names = data.names;
@ -222,7 +231,7 @@ export async function processLabelFiles(labelDir: string, data: DataYaml): Promi
for (const file of labelFiles) { for (const file of labelFiles) {
const match = file.match(/(\d+)\.txt$/); const match = file.match(/(\d+)\.txt$/);
if (!match) { if (!match) {
job.log(`[processLabelFiles] Skipping invalid filename: ${file}`); console.log(`[processLabelFiles] Skipping invalid filename: ${file}`);
continue; continue;
} }
if (!match[1]) { if (!match[1]) {
@ -231,7 +240,7 @@ export async function processLabelFiles(labelDir: string, data: DataYaml): Promi
const frameIndex = parseInt(match[1], 10); const frameIndex = parseInt(match[1], 10);
if (isNaN(frameIndex)) { if (isNaN(frameIndex)) {
job.log(`[processLabelFiles] Skipping invalid frame index: ${file}`); console.log(`[processLabelFiles] Skipping invalid frame index: ${file}`);
continue; continue;
} }
@ -263,10 +272,10 @@ export async function processLabelFiles(labelDir: string, data: DataYaml): Promi
if (maxConfidence > 0 && selectedClassIndex !== -1) { if (maxConfidence > 0 && selectedClassIndex !== -1) {
const className = names[selectedClassIndex.toString()]; const className = names[selectedClassIndex.toString()];
if (className) { if (className) {
job.log(`[processLabelFiles] Frame ${frameIndex}: detected class "${className}" with confidence ${maxConfidence}`); console.log(`[processLabelFiles] Frame ${frameIndex}: detected class "${className}" with confidence ${maxConfidence}`);
frameDetections.push({ startFrame: frameIndex, endFrame: frameIndex, className }); frameDetections.push({ startFrame: frameIndex, endFrame: frameIndex, className });
} else { } else {
job.log(`[processLabelFiles] Frame ${frameIndex}: class index ${selectedClassIndex} has no name`); console.log(`[processLabelFiles] Frame ${frameIndex}: class index ${selectedClassIndex} has no name`);
} }
} }
@ -292,9 +301,9 @@ export async function processLabelFiles(labelDir: string, data: DataYaml): Promi
if (currentDetection) detectionSegments.push(currentDetection); if (currentDetection) detectionSegments.push(currentDetection);
job.log(`[processLabelFiles] Total detection segments: ${detectionSegments.length}`); console.log(`[processLabelFiles] Total detection segments: ${detectionSegments.length}`);
for (const segment of detectionSegments) { for (const segment of detectionSegments) {
job.log(` - Class "${segment.className}": frames ${segment.startFrame}${segment.endFrame}`); console.log(` - Class "${segment.className}": frames ${segment.startFrame}${segment.endFrame}`);
} }
return detectionSegments; return detectionSegments;

View File

@ -17,7 +17,7 @@ new Worker(
throw new Error(`${workerName} Unknown job name: ${job.name}`); throw new Error(`${workerName} Unknown job name: ${job.name}`);
} }
}, },
{ connection } { connection, concurrency: 1 }
); );
console.log(`${workerName} is running...`); console.log(`${workerName} is running...`);

View File

@ -1,14 +1,20 @@
// gpuWorker // gpuWorker
import { Worker } from 'bullmq'; import { Worker } from 'bullmq';
import { connection } from '../../.config/bullmq.config.ts'; import { connection } from '../../.config/bullmq.config.ts';
import { createFunscript } from '../processors/createFunscript.ts';
import { createHlsPlaylist } from '../processors/createHlsPlaylist.ts';
new Worker( new Worker(
'gpuQueue', 'gpuQueue',
async (job) => { async (job) => {
console.log('gpuWorker. we got a job on the gpuQueue.', job.data, job.name); console.log('gpuWorker. we got a job on the gpuQueue.', job.data, job.name);
switch (job.name) { switch (job.name) {
// @todo implement case 'createFunscript':
return await createFunscript(job);
case 'createHlsPlaylist':
return await createHlsPlaylist(job);
default: default:
throw new Error(`gpuWorker Unknown job name: ${job.name}`); throw new Error(`gpuWorker Unknown job name: ${job.name}`);

View File

@ -6,6 +6,7 @@ import { syncronizePatreon } from '../processors/syncronizePatreon.ts'
import { getAnnounceUrlDetails } from '../processors/getAnnounceUrlDetails.ts' import { getAnnounceUrlDetails } from '../processors/getAnnounceUrlDetails.ts'
import { createTorrent } from '../processors/createTorrent.ts'; import { createTorrent } from '../processors/createTorrent.ts';
import { copyV1VideoToV3 } from '../processors/copyV1VideoToV3.ts'; import { copyV1VideoToV3 } from '../processors/copyV1VideoToV3.ts';
import { updateTrackerWhitelist } from '../processors/updateTrackerWhitelist.ts';
new Worker( new Worker(
'highPriorityQueue', 'highPriorityQueue',
@ -26,6 +27,9 @@ new Worker(
case 'copyV1VideoToV3': case 'copyV1VideoToV3':
await copyV1VideoToV3(job); await copyV1VideoToV3(job);
break; break;
case 'updateTrackerWhitelist':
await updateTrackerWhitelist(job);
break;
default: default:
throw new Error(`Unknown job name: ${job.name}`); throw new Error(`Unknown job name: ${job.name}`);
} }

View File

@ -2,7 +2,7 @@
loginctl enable-linger loginctl enable-linger
sudo cp worker.service /etc/systemd/user/worker.service sudo cp worker.service /etc/systemd/user/worker.service
sudo cp qbittorrent-nox.service /etc/systemd/user/worker.service sudo cp qbittorrent-nox.service /etc/systemd/user/qbittorrent-nox.service
systemctl --user daemon-reload systemctl --user daemon-reload