init
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/sqlite.db
|
||||
.env
|
||||
24
go.mod
Normal file
24
go.mod
Normal file
@@ -0,0 +1,24 @@
|
||||
module api
|
||||
|
||||
go 1.25.6
|
||||
|
||||
require (
|
||||
github.com/PuerkitoBio/goquery v1.11.0
|
||||
github.com/autobrr/go-qbittorrent v1.14.0
|
||||
github.com/jackpal/bencode-go v1.0.2
|
||||
github.com/jmoiron/sqlx v1.4.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/kylesanderson/go-jackett v0.0.0-20251103073025-88ab5d10a082
|
||||
github.com/mattn/go-sqlite3 v1.14.33
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Masterminds/semver v1.5.0 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||
github.com/avast/retry-go v3.0.0+incompatible // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/stretchr/testify v1.11.1 // indirect
|
||||
github.com/zeebo/bencode v1.0.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
|
||||
golang.org/x/net v0.49.0 // indirect
|
||||
)
|
||||
108
go.sum
Normal file
108
go.sum
Normal file
@@ -0,0 +1,108 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww=
|
||||
github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y=
|
||||
github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
|
||||
github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
|
||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||
github.com/autobrr/go-qbittorrent v1.14.0 h1:H+/HWDmNUSwkWCTwgK8+f7vzIdXCJLWj6MZlGs8+WZE=
|
||||
github.com/autobrr/go-qbittorrent v1.14.0/go.mod h1:N+sISEJr1hM+AQiTD7pnsilgBcfGzIQsjwoEjWWvnng=
|
||||
github.com/avast/retry-go v3.0.0+incompatible h1:4SOWQ7Qs+oroOTQOYnAHqelpCO0biHSxpiH9JdtuBj0=
|
||||
github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/jackpal/bencode-go v1.0.2 h1:LcCNfZ344u0LpBPOZNjpCLps/wUOuN4r87Fy9+5yU8g=
|
||||
github.com/jackpal/bencode-go v1.0.2/go.mod h1:6jI9mUjO3GQbZti3JizEfxTzRfWOM8oBBcwbwlTfceI=
|
||||
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
|
||||
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/kylesanderson/go-jackett v0.0.0-20251103073025-88ab5d10a082 h1:4dvzW0EB2DDyw/Qa6ga6Ny4xDfubmbHc5JOVO0G7hFg=
|
||||
github.com/kylesanderson/go-jackett v0.0.0-20251103073025-88ab5d10a082/go.mod h1:o805kiTZcYvSoF1ImxwxvU+VOmK/kvRVRLI49VHXORs=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
|
||||
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/zeebo/bencode v1.0.0 h1:zgop0Wu1nu4IexAZeCZ5qbsjU4O1vMrfCrVgUjbHVuA=
|
||||
github.com/zeebo/bencode v1.0.0/go.mod h1:Ct7CkrWIQuLWAy9M3atFHYq4kG9Ao/SsY5cdtCXmp9Y=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
417
main.go
Normal file
417
main.go
Normal file
@@ -0,0 +1,417 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/autobrr/go-qbittorrent"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/kylesanderson/go-jackett"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
//go:embed web/dist/*
|
||||
var webOutput embed.FS
|
||||
|
||||
var (
|
||||
hostname = flag.String("H", "localhost", "server http address")
|
||||
port = flag.Int("p", 5000, "server http port")
|
||||
)
|
||||
|
||||
func sendJSON(w http.ResponseWriter, data any, status int) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(data); err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
}
|
||||
}
|
||||
|
||||
type Torrent struct {
|
||||
ID int64 `json:"id" db:"id"`
|
||||
Title string `json:"title" db:"title"`
|
||||
Guid string `json:"guid" db:"guid"`
|
||||
Indexer string `json:"indexer" db:"indexer"`
|
||||
Pubdate time.Time `json:"pubdate" db:"pubdate"`
|
||||
Size int `json:"size" db:"size"`
|
||||
DownloadURL string `json:"downloadUrl" db:"download_url"`
|
||||
Seeders int `json:"seeders" db:"seeders"`
|
||||
Peers int `json:"peers" db:"peers"`
|
||||
Category int `json:"category" db:"category"`
|
||||
Hash *string `json:"hash" db:"hash"`
|
||||
Downloaded bool `json:"downloaded"`
|
||||
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
||||
ItemID int `json:"itemId" db:"item_id"`
|
||||
}
|
||||
|
||||
type Item struct {
|
||||
ID int64 `json:"id" db:"id"`
|
||||
Query string `json:"query" db:"query"`
|
||||
Category int `json:"category" db:"category"`
|
||||
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
||||
Torrents []Torrent `json:"torrents,omitempty"`
|
||||
}
|
||||
|
||||
func getItems(db *sqlx.DB) ([]*Item, error) {
|
||||
rows, err := db.Queryx("SELECT * FROM items")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("couldn't query db: %v", err)
|
||||
}
|
||||
|
||||
items := []*Item{}
|
||||
for rows.Next() {
|
||||
item := &Item{}
|
||||
if err := rows.StructScan(&item); err != nil {
|
||||
continue
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func getItemTorrents(db *sqlx.DB, itemId int64) ([]*Torrent, error) {
|
||||
rows, err := db.Queryx("SELECT * FROM torrents WHERE item_id = ?", itemId)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("couldn't query db: %v", err)
|
||||
}
|
||||
|
||||
torrents := []*Torrent{}
|
||||
for rows.Next() {
|
||||
torrent := &Torrent{}
|
||||
if err := rows.StructScan(&torrent); err != nil {
|
||||
continue
|
||||
}
|
||||
torrents = append(torrents, torrent)
|
||||
}
|
||||
|
||||
return torrents, nil
|
||||
}
|
||||
|
||||
func getTorrentById(db *sqlx.DB, torrentId int64) (Torrent, error) {
|
||||
var torrent Torrent
|
||||
|
||||
row := db.QueryRowx("SELECT * FROM torrents WHERE id = ?", torrentId)
|
||||
if err := row.StructScan(&torrent); err != nil {
|
||||
return torrent, fmt.Errorf("couldn't query torrent: %v", err)
|
||||
}
|
||||
|
||||
return torrent, nil
|
||||
}
|
||||
|
||||
type JackettTorrent struct {
|
||||
Seeders string
|
||||
Peers string
|
||||
}
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
godotenv.Load()
|
||||
|
||||
jackettHost := os.Getenv("JACKETT_HOST")
|
||||
jackettApiKey := os.Getenv("JACKETT_API_KEY")
|
||||
if jackettHost == "" || jackettApiKey == "" {
|
||||
log.Fatal("no JACKETT_HOST or JACKETT_API_KEY env found")
|
||||
}
|
||||
|
||||
jackettClient := jackett.NewClient(jackett.Config{
|
||||
Host: jackettHost,
|
||||
APIKey: jackettApiKey,
|
||||
})
|
||||
|
||||
qbittorrentHost := os.Getenv("QBITTORRENT_HOST")
|
||||
qbittorrentUsername := os.Getenv("QBITTORRENT_USERNAME")
|
||||
qbittorrentPassword := os.Getenv("QBITTORRENT_PASSWORD")
|
||||
if qbittorrentHost == "" || qbittorrentUsername == "" || qbittorrentPassword == "" {
|
||||
log.Fatal("no QBITTORRENT_HOST, QBITTORRENT_USERNAME or QBITTORRENT_PASSWORD env found")
|
||||
}
|
||||
|
||||
torrentClient := qbittorrent.NewClient(qbittorrent.Config{
|
||||
Host: qbittorrentHost,
|
||||
Username: qbittorrentUsername,
|
||||
Password: qbittorrentPassword,
|
||||
})
|
||||
|
||||
dbPath := os.Getenv("DB_PATH")
|
||||
if dbPath == "" {
|
||||
dbPath = "./sqlite.db"
|
||||
}
|
||||
|
||||
db, err := sqlx.Connect("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to connect to db: %v\n", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
db.MustExec(`CREATE TABLE IF NOT EXISTS items (
|
||||
id integer primary key,
|
||||
query varchar not null,
|
||||
category integer not null,
|
||||
created_at datetime default CURRENT_TIMESTAMP
|
||||
)`)
|
||||
|
||||
db.MustExec(`CREATE TABLE IF NOT EXISTS torrents (
|
||||
id integer primary key,
|
||||
title varchar not null,
|
||||
guid varchar not null unique,
|
||||
indexer varchar not null,
|
||||
pubdate datetime not null,
|
||||
size integer not null,
|
||||
download_url varchar,
|
||||
seeders integer not null,
|
||||
peers integer not null,
|
||||
category integer not null,
|
||||
hash varchar,
|
||||
created_at datetime default CURRENT_TIMESTAMP,
|
||||
item_id integer not null,
|
||||
FOREIGN KEY (item_id) REFERENCES users(id)
|
||||
)`)
|
||||
|
||||
go func(db *sqlx.DB) {
|
||||
for {
|
||||
items, _ := getItems(db)
|
||||
for _, item := range items {
|
||||
results, err := jackettClient.TVSearch(jackett.TVSearchOptions{
|
||||
Query: item.Query,
|
||||
})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, torrent := range results.Channel.Item {
|
||||
size, _ := strconv.Atoi(torrent.Size)
|
||||
category, _ := strconv.Atoi(torrent.Category[0])
|
||||
pubDate, _ := time.Parse(time.RFC1123Z, torrent.PubDate)
|
||||
|
||||
seeders := 0
|
||||
peers := 0
|
||||
for _, attr := range torrent.Attr {
|
||||
if attr.Name == "seeders" {
|
||||
seeders, _ = strconv.Atoi(attr.Value)
|
||||
}
|
||||
if attr.Name == "peers" {
|
||||
peers, _ = strconv.Atoi(attr.Value)
|
||||
}
|
||||
}
|
||||
|
||||
_, err := db.NamedExec("INSERT INTO torrents (title, guid, indexer, pubdate, size, download_url, seeders, peers, category, item_id) VALUES (:title, :guid, :indexer, :pubdate, :size, :download_url, :seeders, :peers, :category, :item_id)", map[string]any{
|
||||
"title": torrent.Title,
|
||||
"guid": torrent.Guid,
|
||||
"indexer": torrent.Jackettindexer.ID,
|
||||
"pubdate": pubDate,
|
||||
"size": size,
|
||||
"download_url": torrent.Link,
|
||||
"seeders": seeders,
|
||||
"peers": peers,
|
||||
"category": category,
|
||||
"item_id": item.ID,
|
||||
})
|
||||
if err != nil {
|
||||
db.NamedExec("UPDATE torrents SET seeders = :seeders, peers = :peers WHERE id = :id", map[string]any{
|
||||
"seeders": seeders,
|
||||
"peers": peers,
|
||||
"id": item.ID,
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
time.Sleep(10 * time.Second)
|
||||
}
|
||||
}(db)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path
|
||||
if path == "/" {
|
||||
path = "/index.html"
|
||||
}
|
||||
|
||||
filePath := "web/dist" + path
|
||||
|
||||
_, err := webOutput.Open(filePath)
|
||||
if err != nil {
|
||||
filePath = "web/dist/index.html"
|
||||
}
|
||||
|
||||
http.ServeFileFS(w, r, webOutput, filePath)
|
||||
fileName := r.URL.Path
|
||||
if fileName == "/" {
|
||||
fileName = "/web/index.html"
|
||||
}
|
||||
})
|
||||
|
||||
mux.HandleFunc("POST /api/items", func(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Query string
|
||||
Category int
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
|
||||
if body.Query == "" {
|
||||
http.Error(w, "invalid request, no query provided", 400)
|
||||
return
|
||||
}
|
||||
|
||||
if body.Category == 0 {
|
||||
body.Category = 5000
|
||||
}
|
||||
|
||||
res, err := db.NamedExec("INSERT INTO items (query, category) VALUES (:query, :category)", map[string]any{
|
||||
"query": body.Query,
|
||||
"category": body.Category,
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
|
||||
sendJSON(w, struct {
|
||||
Id int64 `json:"id"`
|
||||
}{id}, 201)
|
||||
|
||||
})
|
||||
|
||||
mux.HandleFunc("GET /api/items", func(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := getItems(db)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 404)
|
||||
return
|
||||
}
|
||||
|
||||
sendJSON(w, items, 200)
|
||||
})
|
||||
|
||||
mux.HandleFunc("GET /api/items/{id}/torrents", func(w http.ResponseWriter, r *http.Request) {
|
||||
itemId, err := strconv.Atoi(r.PathValue("id"))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
|
||||
torrents, err := getItemTorrents(db, int64(itemId))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
|
||||
for _, torrent := range torrents {
|
||||
if torrent.Hash != nil {
|
||||
properties, err := torrentClient.GetTorrentProperties(*torrent.Hash)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
torrent.Downloaded = properties.CompletionDate > -1
|
||||
}
|
||||
}
|
||||
|
||||
sendJSON(w, torrents, 200)
|
||||
})
|
||||
|
||||
mux.HandleFunc("POST /api/torrents/{id}/download", func(w http.ResponseWriter, r *http.Request) {
|
||||
torrentId, err := strconv.Atoi(r.PathValue("id"))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
|
||||
torrent, err := getTorrentById(db, int64(torrentId))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 404)
|
||||
return
|
||||
}
|
||||
|
||||
downloadUrl := torrent.DownloadURL
|
||||
if torrent.Indexer == "limetorrents" {
|
||||
downloadUrl, err = getLimeTorrentsDownloadUrl(torrent.Guid)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := http.Get(downloadUrl)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
|
||||
infoHash, err := getTorrentInfoHash(data)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := db.NamedExec("UPDATE torrents SET hash = :hash WHERE id = :id", map[string]any{
|
||||
"hash": infoHash,
|
||||
"id": torrent.ID,
|
||||
}); err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
|
||||
if err := torrentClient.AddTorrentFromMemory(data, map[string]string{}); err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
|
||||
sendJSON(w, struct {
|
||||
Ok bool `json:"ok"`
|
||||
}{true}, 200)
|
||||
})
|
||||
|
||||
mux.HandleFunc("DELETE /api/items/{id}", func(w http.ResponseWriter, r *http.Request) {
|
||||
itemId, err := strconv.Atoi(r.PathValue("id"))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := db.Exec("DELETE FROM items WHERE id = ?", itemId); err != nil {
|
||||
http.Error(w, err.Error(), 404)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := db.Exec("DELETE FROM torrents WHERE item_id = ?", itemId); err != nil {
|
||||
http.Error(w, err.Error(), 404)
|
||||
return
|
||||
}
|
||||
|
||||
sendJSON(w, struct {
|
||||
Ok bool `json:"ok"`
|
||||
}{true}, 200)
|
||||
})
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", *hostname, *port)
|
||||
fmt.Printf("starting http server on http://%s\n", addr)
|
||||
if err := http.ListenAndServe(addr, mux); err != nil {
|
||||
log.Fatalf("failed to start http server: %v\n", err)
|
||||
}
|
||||
}
|
||||
18
metadata.go
Normal file
18
metadata.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/zeebo/bencode"
|
||||
)
|
||||
|
||||
type TorrentMetadata struct {
|
||||
Info bencode.RawMessage `bencode:"info"`
|
||||
}
|
||||
|
||||
func getTorrentInfoHash(torrent []byte) (string, error) {
|
||||
torrentMetadata := &TorrentMetadata{}
|
||||
if err := bencode.DecodeBytes(torrent, torrentMetadata); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return toSha1(torrentMetadata.Info), nil
|
||||
}
|
||||
47
utils.go
Normal file
47
utils.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha1"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
)
|
||||
|
||||
func getLimeTorrentsDownloadUrl(url string) (string, error) {
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("couldn't fetch html: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
doc, err := goquery.NewDocumentFromReader(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("couldn't create document from response: %v", err)
|
||||
}
|
||||
|
||||
href := ""
|
||||
doc.Find("a.csprite_dltorrent").Each(func(i int, s *goquery.Selection) {
|
||||
h, hasHref := s.Attr("href")
|
||||
if !hasHref {
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasPrefix(h, "https://") {
|
||||
href = h
|
||||
}
|
||||
})
|
||||
|
||||
if href == "" {
|
||||
return "", fmt.Errorf("couldn't find download url")
|
||||
}
|
||||
|
||||
return href, nil
|
||||
}
|
||||
|
||||
func toSha1(b []byte) string {
|
||||
hash := sha1.New()
|
||||
hash.Write(b)
|
||||
return fmt.Sprintf("%x", hash.Sum(nil))
|
||||
}
|
||||
24
web/.gitignore
vendored
Normal file
24
web/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
23
web/eslint.config.js
Normal file
23
web/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
13
web/index.html
Normal file
13
web/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>web</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
37
web/package.json
Normal file
37
web/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "web",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port 3000 --host",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tanstack/react-query": "^5.90.20",
|
||||
"dayjs": "^1.11.19",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hook-form": "^7.71.1",
|
||||
"tailwindcss": "^4.1.18"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"vite": "^7.2.4"
|
||||
}
|
||||
}
|
||||
2465
web/pnpm-lock.yaml
generated
Normal file
2465
web/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
web/src/App.tsx
Normal file
19
web/src/App.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { useItemsQuery } from "./api/useItemsQuery";
|
||||
import { CreateItemForm } from "./components/CreateItemForm";
|
||||
import { Item } from "./components/Item";
|
||||
|
||||
export default function App() {
|
||||
const { data: items } = useItemsQuery();
|
||||
|
||||
return (
|
||||
<div className="max-w-[1440px] mx-auto w-full py-2">
|
||||
<h1 className="text-2xl font-semibold my-2">torrent queue</h1>
|
||||
<CreateItemForm />
|
||||
<div className="mt-4 flex flex-col gap-3">
|
||||
{items?.map((item) => (
|
||||
<Item key={item.id} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
web/src/api/useCreateItemMutation.ts
Normal file
22
web/src/api/useCreateItemMutation.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
|
||||
export type CreateItemData = {
|
||||
query: string;
|
||||
category: number;
|
||||
};
|
||||
|
||||
export const useCreateItemMutation = () => {
|
||||
return useMutation({
|
||||
mutationFn: async (data: CreateItemData) => {
|
||||
const resp = await fetch("/api/items", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
const rData = await resp.json();
|
||||
return rData;
|
||||
},
|
||||
});
|
||||
};
|
||||
13
web/src/api/useDeleteItemMutation.ts
Normal file
13
web/src/api/useDeleteItemMutation.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
|
||||
export const useDeleteItemMutation = () => {
|
||||
return useMutation({
|
||||
mutationFn: async ({ torrentId }: { torrentId: number }) => {
|
||||
const resp = await fetch(`/api/items/${torrentId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
const data = await resp.json();
|
||||
return data;
|
||||
},
|
||||
});
|
||||
};
|
||||
13
web/src/api/useDownloadTorrentMutation.ts
Normal file
13
web/src/api/useDownloadTorrentMutation.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
|
||||
export const useDownloadTorrentMutation = () => {
|
||||
return useMutation({
|
||||
mutationFn: async ({ torrentId }: { torrentId: number }) => {
|
||||
const resp = await fetch(`/api/torrents/${torrentId}/download`, {
|
||||
method: "POST",
|
||||
});
|
||||
const data = await resp.json();
|
||||
return data;
|
||||
},
|
||||
});
|
||||
};
|
||||
29
web/src/api/useItemTorrentsQuery.ts
Normal file
29
web/src/api/useItemTorrentsQuery.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
export type ItemTorrent = {
|
||||
id: number;
|
||||
title: string;
|
||||
guid: string;
|
||||
indexer: string;
|
||||
pubdate: string;
|
||||
size: number;
|
||||
downloadUrl: string;
|
||||
seeders: number;
|
||||
peers: number;
|
||||
category: number;
|
||||
hash: string | null;
|
||||
downloaded: boolean;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export const useItemTorrentsQuery = (itemId: number, enabled = true) => {
|
||||
return useQuery({
|
||||
queryKey: ["items", itemId, "torrents"],
|
||||
enabled,
|
||||
queryFn: async () => {
|
||||
const resp = await fetch(`/api/items/${itemId}/torrents`);
|
||||
const data = await resp.json();
|
||||
return data as ItemTorrent[];
|
||||
},
|
||||
});
|
||||
};
|
||||
18
web/src/api/useItemsQuery.ts
Normal file
18
web/src/api/useItemsQuery.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
export type ItemDetails = {
|
||||
id: number;
|
||||
query: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export const useItemsQuery = () => {
|
||||
return useQuery({
|
||||
queryKey: ["items"],
|
||||
queryFn: async () => {
|
||||
const resp = await fetch("/api/items");
|
||||
const data = await resp.json();
|
||||
return data as ItemDetails[];
|
||||
},
|
||||
});
|
||||
};
|
||||
60
web/src/components/CreateItemForm.tsx
Normal file
60
web/src/components/CreateItemForm.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import {
|
||||
type CreateItemData,
|
||||
useCreateItemMutation,
|
||||
} from "../api/useCreateItemMutation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { categories } from "../lib/categories";
|
||||
|
||||
export const CreateItemForm = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const createItemMutation = useCreateItemMutation();
|
||||
|
||||
const form = useForm<CreateItemData>({
|
||||
defaultValues: {
|
||||
query: "",
|
||||
category: 5000,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = form.handleSubmit((data) => {
|
||||
createItemMutation.mutate(data, {
|
||||
onSuccess() {
|
||||
queryClient.invalidateQueries({ queryKey: ["items"] });
|
||||
},
|
||||
onSettled() {
|
||||
form.reset();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="query"
|
||||
className="w-full"
|
||||
{...form.register("query", {
|
||||
required: true,
|
||||
})}
|
||||
/>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="category"
|
||||
render={({ field }) => (
|
||||
<select
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value))}
|
||||
>
|
||||
{Object.entries(categories).map(([id, label]) => (
|
||||
<option key={id} value={id}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
/>
|
||||
<button type="submit">Add Item</button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
141
web/src/components/Item.tsx
Normal file
141
web/src/components/Item.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import { useState } from "react";
|
||||
import type { ItemDetails } from "../api/useItemsQuery";
|
||||
import { useItemTorrentsQuery } from "../api/useItemTorrentsQuery";
|
||||
import {
|
||||
CaretDownIcon,
|
||||
CaretUpIcon,
|
||||
CheckCircleIcon,
|
||||
DownloadSimpleIcon,
|
||||
TrashIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useDownloadTorrentMutation } from "../api/useDownloadTorrentMutation";
|
||||
import { useDeleteItemMutation } from "../api/useDeleteItemMutation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
export type ItemProps = {
|
||||
item: ItemDetails;
|
||||
};
|
||||
|
||||
export const Item = ({ item }: ItemProps) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { data: torrents } = useItemTorrentsQuery(item.id, open);
|
||||
|
||||
const deleteMutation = useDeleteItemMutation();
|
||||
const downloadMutation = useDownloadTorrentMutation();
|
||||
|
||||
const Icon = open ? CaretUpIcon : CaretDownIcon;
|
||||
|
||||
const handleDownloadTorrent = (torrentId: number) => {
|
||||
downloadMutation.mutate({ torrentId });
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!confirm("Do you want to delete this item?")) return;
|
||||
|
||||
deleteMutation.mutate(
|
||||
{
|
||||
torrentId: item.id,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
queryClient.invalidateQueries({ queryKey: ["items"] });
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-t border-b border-neutral-900">
|
||||
<div
|
||||
className="flex justify-between items-center py-1 group cursor-pointer"
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
>
|
||||
<span className="group-hover:text-neutral-500">{item.query}</span>
|
||||
<Icon size={24} />
|
||||
</div>
|
||||
{open && (
|
||||
<div>
|
||||
<div className="flex mb-2">
|
||||
<button
|
||||
className="cursor-pointer flex items-center gap-1 text-[#b00420]"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<TrashIcon size={20} /> Delete item
|
||||
</button>
|
||||
</div>
|
||||
{torrents && torrents.length > 0 ? (
|
||||
torrents?.map((torrent) => (
|
||||
<div
|
||||
key={torrent.id}
|
||||
className="flex justify-between items-center hover:bg-neutral-200"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>
|
||||
<a
|
||||
href={torrent.guid}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{torrent.title}
|
||||
</a>{" "}
|
||||
[{formatCategory(torrent.category)}] [{torrent.indexer}]
|
||||
</span>
|
||||
{torrent.downloaded && (
|
||||
<span title="Torrent files downloaded">
|
||||
<CheckCircleIcon size={20} color="green" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span>Seeds: {torrent.seeders}</span>
|
||||
<span>Peers: {torrent.peers}</span>
|
||||
<span>
|
||||
PubDate: {dayjs(torrent.pubdate).format("DD.MM.YYYY")} at{" "}
|
||||
{dayjs(torrent.pubdate).format("HH:mm")}
|
||||
</span>
|
||||
<button
|
||||
className="cursor-pointer"
|
||||
onClick={() => handleDownloadTorrent(torrent.id)}
|
||||
>
|
||||
<DownloadSimpleIcon size={24} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<span>No torrents yet</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const formatCategory = (category: number): string => {
|
||||
switch (category) {
|
||||
case 1000:
|
||||
return "Console";
|
||||
case 2000:
|
||||
return "Movies";
|
||||
case 3000:
|
||||
return "Audio";
|
||||
case 4000:
|
||||
return "PC";
|
||||
case 5000:
|
||||
return "TV";
|
||||
case 6000:
|
||||
return "XXX";
|
||||
case 7000:
|
||||
return "Books";
|
||||
case 8000:
|
||||
return "Other";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
1
web/src/index.css
Normal file
1
web/src/index.css
Normal file
@@ -0,0 +1 @@
|
||||
@import 'tailwindcss';
|
||||
10
web/src/lib/categories.ts
Normal file
10
web/src/lib/categories.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export const categories = {
|
||||
1000: "Console",
|
||||
2000: "Movies",
|
||||
3000: "Audio",
|
||||
4000: "PC",
|
||||
5000: "TV",
|
||||
6000: "XXX",
|
||||
7000: "Books",
|
||||
8000: "Other",
|
||||
};
|
||||
15
web/src/main.tsx
Normal file
15
web/src/main.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import App from "./App.tsx";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
28
web/tsconfig.app.json
Normal file
28
web/tsconfig.app.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
web/tsconfig.json
Normal file
7
web/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
web/tsconfig.node.json
Normal file
26
web/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
15
web/vite.config.ts
Normal file
15
web/vite.config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
tailwindcss(),
|
||||
react({
|
||||
babel: {
|
||||
plugins: [['babel-plugin-react-compiler']],
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
Reference in New Issue
Block a user