This commit is contained in:
2026-02-02 23:00:03 +03:00
commit be8ea42808
27 changed files with 3598 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/sqlite.db
.env

3
Justfile Normal file
View File

@@ -0,0 +1,3 @@
dev:
cd web && pnpm build
go run .

24
go.mod Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

19
web/src/App.tsx Normal file
View 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>
);
}

View 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;
},
});
};

View 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;
},
});
};

View 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;
},
});
};

View 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[];
},
});
};

View 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[];
},
});
};

View 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
View 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
View File

@@ -0,0 +1 @@
@import 'tailwindcss';

10
web/src/lib/categories.ts Normal file
View 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
View 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
View 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
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
web/tsconfig.node.json Normal file
View 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
View 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']],
},
}),
],
})