Compare commits
14 Commits
f43519f7f6
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 1509f03681 | |||
| 5fda73a258 | |||
| b92a1216f0 | |||
| 7aa0e8fd30 | |||
| bedb50d524 | |||
| e293f68c4a | |||
| 67a9887058 | |||
| b5de8af9d1 | |||
| d231f9a79e | |||
| fa47c328cc | |||
| 846b32f9b5 | |||
| 873bfab4b9 | |||
| fd80929220 | |||
| a37ed0d902 |
6
Justfile
6
Justfile
@@ -1,3 +1,9 @@
|
|||||||
dev:
|
dev:
|
||||||
cd web && pnpm build
|
cd web && pnpm build
|
||||||
go run .
|
go run .
|
||||||
|
|
||||||
|
build:
|
||||||
|
docker build -t git.tsivinsky.com/tsivinsky/tvqueue:latest .
|
||||||
|
|
||||||
|
push:
|
||||||
|
docker push git.tsivinsky.com/tsivinsky/tvqueue:latest
|
||||||
|
|||||||
1
go.mod
1
go.mod
@@ -7,7 +7,6 @@ require (
|
|||||||
github.com/autobrr/go-qbittorrent v1.14.0
|
github.com/autobrr/go-qbittorrent v1.14.0
|
||||||
github.com/jmoiron/sqlx v1.4.0
|
github.com/jmoiron/sqlx v1.4.0
|
||||||
github.com/joho/godotenv v1.5.1
|
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
|
github.com/mattn/go-sqlite3 v1.14.33
|
||||||
github.com/zeebo/bencode v1.0.0
|
github.com/zeebo/bencode v1.0.0
|
||||||
)
|
)
|
||||||
|
|||||||
2
go.sum
2
go.sum
@@ -19,8 +19,6 @@ github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
|
|||||||
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
|
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 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
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 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
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.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
|
|||||||
20
jackett/client.go
Normal file
20
jackett/client.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
package jackett
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Host string
|
||||||
|
APIKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
conf Config
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClient(conf Config) *Client {
|
||||||
|
return &Client{
|
||||||
|
conf: conf,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) buildUrl(path string) string {
|
||||||
|
return c.conf.Host + path
|
||||||
|
}
|
||||||
91
jackett/search.go
Normal file
91
jackett/search.go
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
package jackett
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Response struct {
|
||||||
|
Results []Result `json:"results"`
|
||||||
|
Indexers []Indexer `json:"indexers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Result struct {
|
||||||
|
FirstSeen string `json:"FirstSeen"`
|
||||||
|
Tracker string `json:"Tracker"`
|
||||||
|
TrackerID string `json:"TrackerId"`
|
||||||
|
TrackerType string `json:"TrackerType"`
|
||||||
|
CategoryDesc string `json:"CategoryDesc"`
|
||||||
|
BlackholeLink string `json:"BlackholeLink"`
|
||||||
|
Title string `json:"Title"`
|
||||||
|
GUID string `json:"Guid"`
|
||||||
|
Link string `json:"Link"`
|
||||||
|
Details string `json:"Details"`
|
||||||
|
PublishDate string `json:"PublishDate"`
|
||||||
|
Category []int `json:"Category"`
|
||||||
|
Size int64 `json:"Size"`
|
||||||
|
Files any `json:"Files"`
|
||||||
|
Grabs int `json:"Grabs"`
|
||||||
|
Description string `json:"Description"`
|
||||||
|
RageID any `json:"RageID"`
|
||||||
|
TVDBID any `json:"TVDBId"`
|
||||||
|
Imdb any `json:"Imdb"`
|
||||||
|
TMDb any `json:"TMDb"`
|
||||||
|
DoubanID any `json:"DoubanId"`
|
||||||
|
Author string `json:"Author"`
|
||||||
|
BookTitle string `json:"BookTitle"`
|
||||||
|
Seeders int `json:"Seeders"`
|
||||||
|
Peers int `json:"Peers"`
|
||||||
|
Poster any `json:"Poster"`
|
||||||
|
InfoHash any `json:"InfoHash"`
|
||||||
|
MagnetURI any `json:"MagnetUri"`
|
||||||
|
MinimumRatio any `json:"MinimumRatio"`
|
||||||
|
MinimumSeedTime any `json:"MinimumSeedTime"`
|
||||||
|
DownloadVolumeFactor float64 `json:"DownloadVolumeFactor"`
|
||||||
|
UploadVolumeFactor float64 `json:"UploadVolumeFactor"`
|
||||||
|
Gain float64 `json:"Gain"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Indexer struct {
|
||||||
|
ID string `json:"ID"`
|
||||||
|
Name string `json:"Name"`
|
||||||
|
Status int `json:"Status"`
|
||||||
|
Results int `json:"Results"`
|
||||||
|
Error string `json:"Error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Search(q string, categories ...int) ([]Result, error) {
|
||||||
|
req, err := http.NewRequest("GET", c.buildUrl("/api/v2.0/indexers/all/results"), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
query := url.Values{}
|
||||||
|
query.Add("apikey", c.conf.APIKey)
|
||||||
|
query.Add("Query", q)
|
||||||
|
|
||||||
|
categoryList := []string{}
|
||||||
|
for _, c := range categories {
|
||||||
|
categoryList = append(categoryList, strconv.Itoa(c))
|
||||||
|
}
|
||||||
|
query.Add("Category[]", strings.Join(categoryList, ","))
|
||||||
|
|
||||||
|
req.URL.RawQuery = query.Encode()
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to send request: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
response := new(Response)
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(response); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Results, nil
|
||||||
|
}
|
||||||
189
main.go
189
main.go
@@ -1,12 +1,14 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"api/jackett"
|
||||||
"api/model"
|
"api/model"
|
||||||
"embed"
|
"embed"
|
||||||
_ "embed"
|
_ "embed"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html/template"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -17,7 +19,6 @@ import (
|
|||||||
"github.com/autobrr/go-qbittorrent"
|
"github.com/autobrr/go-qbittorrent"
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
"github.com/kylesanderson/go-jackett"
|
|
||||||
_ "github.com/mattn/go-sqlite3"
|
_ "github.com/mattn/go-sqlite3"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -42,6 +43,45 @@ type JackettTorrent struct {
|
|||||||
Peers string
|
Peers string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func checkForNewTorrents(jackettClient *jackett.Client, db *sqlx.DB, m *model.Model, item *model.Item) error {
|
||||||
|
results, err := jackettClient.Search(item.Query, item.Category)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("couldn't get to jackett api: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, torrent := range results {
|
||||||
|
guidTorrent, _ := m.GetTorrentByGuidAndItemId(torrent.GUID, item.ID)
|
||||||
|
if guidTorrent != nil {
|
||||||
|
// already have this exact one, for this item. ABORT!
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// this shit will duplicate. idk if it's ok or not, but fuck it. we ball
|
||||||
|
_, 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.TrackerID,
|
||||||
|
"pubdate": torrent.PublishDate,
|
||||||
|
"size": torrent.Size,
|
||||||
|
"download_url": torrent.Link,
|
||||||
|
"seeders": torrent.Seeders,
|
||||||
|
"peers": torrent.Peers,
|
||||||
|
"category": torrent.Category[0],
|
||||||
|
"item_id": item.ID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("couldn't add new torrent: %v\n", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.UpdateRefreshedAt(item.ID, time.Now()); err != nil {
|
||||||
|
return fmt.Errorf("couldn't update refreshed_at for %d: %v\n", item.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
godotenv.Load()
|
godotenv.Load()
|
||||||
@@ -88,57 +128,13 @@ func main() {
|
|||||||
for {
|
for {
|
||||||
items, _ := m.GetItems()
|
items, _ := m.GetItems()
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
results, err := jackettClient.TVSearch(jackett.TVSearchOptions{
|
if err := checkForNewTorrents(jackettClient, db, m, item); err != nil {
|
||||||
Query: item.Query,
|
log.Printf("failed to check for new torrents: %v\n", err)
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("couldn't get to jackett api: %v\n", err)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, torrent := range results.Channel.Item {
|
|
||||||
size := toIntOr(torrent.Size, 0)
|
|
||||||
category := toIntOr(torrent.Category[0], 5000)
|
|
||||||
pubDate, _ := time.Parse(time.RFC1123Z, torrent.PubDate)
|
|
||||||
|
|
||||||
seeders := 0
|
|
||||||
peers := 0
|
|
||||||
for _, attr := range torrent.Attr {
|
|
||||||
if attr.Name == "seeders" {
|
|
||||||
seeders = toIntOr(attr.Value, 0)
|
|
||||||
}
|
|
||||||
if attr.Name == "peers" {
|
|
||||||
peers = toIntOr(attr.Value, 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
guidTorrent, _ := m.GetTorrentByGuidAndItemId(torrent.Guid, item.ID)
|
|
||||||
if guidTorrent != nil {
|
|
||||||
// already have this exact one, for this item. ABORT!
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// this shit will duplicate. idk if it's ok or not, but fuck it. we ball
|
|
||||||
_, 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 {
|
|
||||||
log.Printf("couldn't add new torrent: %v\n", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
time.Sleep(10 * time.Second)
|
time.Sleep(1 * time.Hour)
|
||||||
}
|
}
|
||||||
}(m)
|
}(m)
|
||||||
|
|
||||||
@@ -164,6 +160,71 @@ func main() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /glance", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
type glanceTorrent struct {
|
||||||
|
Title string
|
||||||
|
}
|
||||||
|
|
||||||
|
type glanceItem struct {
|
||||||
|
Query string
|
||||||
|
Link string
|
||||||
|
Torrents []glanceTorrent
|
||||||
|
}
|
||||||
|
|
||||||
|
glanceItems := []glanceItem{}
|
||||||
|
|
||||||
|
items, err := m.GetItems()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
baseUrl := r.URL.Query().Get("base_url")
|
||||||
|
|
||||||
|
for _, item := range items {
|
||||||
|
gItem := glanceItem{
|
||||||
|
Query: item.Query,
|
||||||
|
Link: fmt.Sprintf("%s?item=%d", baseUrl, item.ID),
|
||||||
|
Torrents: []glanceTorrent{},
|
||||||
|
}
|
||||||
|
torrents, err := m.GetItemTorrents(item.ID)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, torrent := range torrents {
|
||||||
|
gItem.Torrents = append(gItem.Torrents, glanceTorrent{
|
||||||
|
Title: torrent.Title,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
glanceItems = append(glanceItems, gItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpl := `
|
||||||
|
<ul class="list list-gap-2">
|
||||||
|
{{range .Items}}
|
||||||
|
<li class="size-h5">[{{len .Torrents}}] <a href="{{.Link}}" target="_blank">{{.Query}}</a></li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
`
|
||||||
|
|
||||||
|
t, err := template.New("glance").Parse(tmpl)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Widget-Title", "tvqueue")
|
||||||
|
w.Header().Set("Widget-Content-Type", "html")
|
||||||
|
|
||||||
|
t.ExecuteTemplate(w, "glance", struct {
|
||||||
|
Items []glanceItem
|
||||||
|
}{
|
||||||
|
Items: glanceItems,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
mux.HandleFunc("POST /api/items", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("POST /api/items", func(w http.ResponseWriter, r *http.Request) {
|
||||||
var body struct {
|
var body struct {
|
||||||
Query string
|
Query string
|
||||||
@@ -201,7 +262,29 @@ func main() {
|
|||||||
sendJSON(w, struct {
|
sendJSON(w, struct {
|
||||||
Id int64 `json:"id"`
|
Id int64 `json:"id"`
|
||||||
}{id}, 201)
|
}{id}, 201)
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("POST /api/items/{id}/refresh", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.Atoi(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
item, err := m.GetItemById(int64(id))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("failed to get item by id: %v", err), 404)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := checkForNewTorrents(jackettClient, db, m, item); err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("failed to check for torrents: %v", err), 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sendJSON(w, struct {
|
||||||
|
Ok bool `json:"ok"`
|
||||||
|
}{true}, 200)
|
||||||
})
|
})
|
||||||
|
|
||||||
mux.HandleFunc("GET /api/items", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("GET /api/items", func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -290,7 +373,15 @@ func main() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := torrentClient.AddTorrentFromMemory(data, map[string]string{}); err != nil {
|
opts := make(map[string]string)
|
||||||
|
if category, ok := os.LookupEnv("QBITTORRENT_CATEGORY"); ok {
|
||||||
|
opts["category"] = category
|
||||||
|
}
|
||||||
|
if savePath, ok := os.LookupEnv("QBITTORRENT_SAVEPATH"); ok {
|
||||||
|
opts["savepath"] = savePath
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := torrentClient.AddTorrentFromMemory(data, opts); err != nil {
|
||||||
http.Error(w, err.Error(), 500)
|
http.Error(w, err.Error(), 500)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,23 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
import "fmt"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (m *Model) GetItemById(id int64) (*Item, error) {
|
||||||
|
row := m.db.QueryRowx("SELECT * FROM items WHERE id = ?", id)
|
||||||
|
if row.Err() != nil {
|
||||||
|
return nil, fmt.Errorf("failed to query db: %v", row.Err())
|
||||||
|
}
|
||||||
|
|
||||||
|
item := new(Item)
|
||||||
|
if err := row.StructScan(item); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to struct scan item: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return item, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Model) GetItems() ([]*Item, error) {
|
func (m *Model) GetItems() ([]*Item, error) {
|
||||||
rows, err := m.db.Queryx("SELECT * FROM items")
|
rows, err := m.db.Queryx("SELECT * FROM items")
|
||||||
@@ -19,3 +36,14 @@ func (m *Model) GetItems() ([]*Item, error) {
|
|||||||
|
|
||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Model) UpdateRefreshedAt(itemId int64, refreshedAt time.Time) error {
|
||||||
|
if _, err := m.db.NamedExec("UPDATE items SET refreshed_at = :refreshed_at WHERE id = :item_id", map[string]any{
|
||||||
|
"refreshed_at": refreshedAt,
|
||||||
|
"item_id": itemId,
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("failed to update refreshed_at field: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,11 +28,12 @@ type Torrent struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Item struct {
|
type Item struct {
|
||||||
ID int64 `json:"id" db:"id"`
|
ID int64 `json:"id" db:"id"`
|
||||||
Query string `json:"query" db:"query"`
|
Query string `json:"query" db:"query"`
|
||||||
Category int `json:"category" db:"category"`
|
Category int `json:"category" db:"category"`
|
||||||
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
||||||
Torrents []Torrent `json:"torrents,omitempty"`
|
Torrents []Torrent `json:"torrents,omitempty"`
|
||||||
|
RefreshedAt *time.Time `json:"refreshedAt" db:"refreshed_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) Init() {
|
func (m *Model) Init() {
|
||||||
@@ -59,6 +60,8 @@ func (m *Model) Init() {
|
|||||||
item_id integer not null,
|
item_id integer not null,
|
||||||
FOREIGN KEY (item_id) REFERENCES users(id)
|
FOREIGN KEY (item_id) REFERENCES users(id)
|
||||||
)`)
|
)`)
|
||||||
|
|
||||||
|
m.db.Exec(`ALTER TABLE items ADD COLUMN refreshed_at datetime default null`)
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(db *sqlx.DB) *Model {
|
func New(db *sqlx.DB) *Model {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ func (m *Model) GetTorrentById(torrentId int64) (*Torrent, error) {
|
|||||||
torrent := new(Torrent)
|
torrent := new(Torrent)
|
||||||
|
|
||||||
row := m.db.QueryRowx("SELECT * FROM torrents WHERE id = ?", torrentId)
|
row := m.db.QueryRowx("SELECT * FROM torrents WHERE id = ?", torrentId)
|
||||||
if err := row.StructScan(&torrent); err != nil {
|
if err := row.StructScan(torrent); err != nil {
|
||||||
return torrent, fmt.Errorf("couldn't query torrent: %v", err)
|
return torrent, fmt.Errorf("couldn't query torrent: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export type ItemDetails = {
|
|||||||
id: number;
|
id: number;
|
||||||
query: string;
|
query: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
refreshedAt: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useItemsQuery = () => {
|
export const useItemsQuery = () => {
|
||||||
|
|||||||
13
web/src/api/useRefreshItemMutation.ts
Normal file
13
web/src/api/useRefreshItemMutation.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
export const useRefreshItemMutation = () => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ id }: { id: number }) => {
|
||||||
|
const resp = await fetch(`/api/items/${id}/refresh`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,19 +1,21 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import type { ItemDetails } from "../api/useItemsQuery";
|
import type { ItemDetails } from "../api/useItemsQuery";
|
||||||
import { useItemTorrentsQuery } from "../api/useItemTorrentsQuery";
|
import { useItemTorrentsQuery } from "../api/useItemTorrentsQuery";
|
||||||
import {
|
import {
|
||||||
|
ArrowsClockwiseIcon,
|
||||||
CaretDownIcon,
|
CaretDownIcon,
|
||||||
CaretUpIcon,
|
CaretUpIcon,
|
||||||
CheckCircleIcon,
|
|
||||||
DownloadSimpleIcon,
|
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
import { useDownloadTorrentMutation } from "../api/useDownloadTorrentMutation";
|
|
||||||
import { useDeleteItemMutation } from "../api/useDeleteItemMutation";
|
import { useDeleteItemMutation } from "../api/useDeleteItemMutation";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { humanFileSize } from "../utils/humanFileSize";
|
import relativeTime from "dayjs/plugin/relativeTime";
|
||||||
import { useDeleteTorrentMutation } from "../api/useDeleteTorrentMutation";
|
import { useRefreshItemMutation } from "../api/useRefreshItemMutation";
|
||||||
|
import { Loader } from "./Loader";
|
||||||
|
import { Torrent } from "./Torrent";
|
||||||
|
|
||||||
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
export type ItemProps = {
|
export type ItemProps = {
|
||||||
item: ItemDetails;
|
item: ItemDetails;
|
||||||
@@ -27,14 +29,30 @@ export const Item = ({ item }: ItemProps) => {
|
|||||||
const { data: torrents } = useItemTorrentsQuery(item.id, open);
|
const { data: torrents } = useItemTorrentsQuery(item.id, open);
|
||||||
|
|
||||||
const deleteMutation = useDeleteItemMutation();
|
const deleteMutation = useDeleteItemMutation();
|
||||||
const downloadMutation = useDownloadTorrentMutation();
|
const refreshMutation = useRefreshItemMutation();
|
||||||
const deleteTorrentMutation = useDeleteTorrentMutation();
|
|
||||||
|
|
||||||
const Icon = open ? CaretUpIcon : CaretDownIcon;
|
const Icon = open ? CaretUpIcon : CaretDownIcon;
|
||||||
|
|
||||||
const handleDownloadTorrent = (torrentId: number) => {
|
const [search, setSearch] = useState("");
|
||||||
downloadMutation.mutate({ torrentId });
|
|
||||||
};
|
const filteredTorrents = useMemo(() => {
|
||||||
|
if (!search) return torrents;
|
||||||
|
return torrents?.filter((torrent) => {
|
||||||
|
const terms = search.split(" ");
|
||||||
|
const foundTerms = terms.filter((term) => torrent.title.includes(term));
|
||||||
|
return foundTerms.length === terms.length;
|
||||||
|
});
|
||||||
|
}, [search, torrents]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const itemId = params.get("item");
|
||||||
|
if (itemId && parseInt(itemId) === item.id) {
|
||||||
|
// fuck this stupid rule
|
||||||
|
// eslint-disable-next-line
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
if (!confirm("Do you want to delete this item?")) return;
|
if (!confirm("Do you want to delete this item?")) return;
|
||||||
@@ -51,10 +69,10 @@ export const Item = ({ item }: ItemProps) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteTorrent = (torrentId: number) => {
|
const handleRefresh = () => {
|
||||||
deleteTorrentMutation.mutate(
|
refreshMutation.mutate(
|
||||||
{
|
{
|
||||||
id: torrentId,
|
id: item.id,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
@@ -79,7 +97,36 @@ export const Item = ({ item }: ItemProps) => {
|
|||||||
</div>
|
</div>
|
||||||
{open && (
|
{open && (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex mb-2">
|
<div className="my-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search torrents..."
|
||||||
|
className="w-full outline-none py-1 px-2 rounded border-2 border-transparent focus:border-neutral-900"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<div>
|
||||||
|
Last Refresh:{" "}
|
||||||
|
{item.refreshedAt
|
||||||
|
? dayjs(item.refreshedAt).format("DD.MM.YYYY HH:mm")
|
||||||
|
: "never"}
|
||||||
|
{item.refreshedAt
|
||||||
|
? " (" + dayjs(item.refreshedAt).from(dayjs()) + ")"
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="cursor-pointer flex items-center gap-1"
|
||||||
|
onClick={handleRefresh}
|
||||||
|
>
|
||||||
|
{refreshMutation.isPending ? (
|
||||||
|
<Loader size={20} />
|
||||||
|
) : (
|
||||||
|
<ArrowsClockwiseIcon size={20} />
|
||||||
|
)}{" "}
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className="cursor-pointer flex items-center gap-1 text-[#b00420]"
|
className="cursor-pointer flex items-center gap-1 text-[#b00420]"
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
@@ -87,51 +134,9 @@ export const Item = ({ item }: ItemProps) => {
|
|||||||
<TrashIcon size={20} /> Delete item
|
<TrashIcon size={20} /> Delete item
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{torrents && torrents.length > 0 ? (
|
{filteredTorrents && filteredTorrents.length > 0 ? (
|
||||||
torrents?.map((torrent) => (
|
filteredTorrents.map((torrent) => (
|
||||||
<div
|
<Torrent key={torrent.id} itemId={item.id} torrent={torrent} />
|
||||||
key={torrent.id}
|
|
||||||
className="flex justify-between items-center hover:bg-neutral-200 group"
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
className="hidden group-hover:inline text-[#b00420] cursor-pointer"
|
|
||||||
onClick={() => handleDeleteTorrent(torrent.id)}
|
|
||||||
>
|
|
||||||
<TrashIcon size={16} />
|
|
||||||
</button>
|
|
||||||
</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>
|
|
||||||
<span>{humanFileSize(torrent.size)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<span>No torrents yet</span>
|
<span>No torrents yet</span>
|
||||||
@@ -141,26 +146,3 @@ export const Item = ({ item }: ItemProps) => {
|
|||||||
</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 "";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|||||||
7
web/src/components/Loader.tsx
Normal file
7
web/src/components/Loader.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { CircleNotchIcon, type IconProps } from "@phosphor-icons/react";
|
||||||
|
|
||||||
|
export const Loader = ({ className, ...props }: IconProps) => {
|
||||||
|
return (
|
||||||
|
<CircleNotchIcon {...props} className={`animate-spin ${className || ""}`} />
|
||||||
|
);
|
||||||
|
};
|
||||||
116
web/src/components/Torrent.tsx
Normal file
116
web/src/components/Torrent.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import type { ItemTorrent } from "../api/useItemTorrentsQuery";
|
||||||
|
import { categories } from "../lib/categories";
|
||||||
|
import {
|
||||||
|
ArrowSquareOutIcon,
|
||||||
|
CaretDownIcon,
|
||||||
|
CaretUpIcon,
|
||||||
|
CheckCircleIcon,
|
||||||
|
DownloadSimpleIcon,
|
||||||
|
TrashIcon,
|
||||||
|
} from "@phosphor-icons/react";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import { humanFileSize } from "../utils/humanFileSize";
|
||||||
|
import { useDeleteTorrentMutation } from "../api/useDeleteTorrentMutation";
|
||||||
|
import { useDownloadTorrentMutation } from "../api/useDownloadTorrentMutation";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
export type TorrentProps = {
|
||||||
|
itemId: number;
|
||||||
|
torrent: ItemTorrent;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Torrent = ({ itemId, torrent }: TorrentProps) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const downloadMutation = useDownloadTorrentMutation();
|
||||||
|
const deleteMutation = useDeleteTorrentMutation();
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const ChevronIcon = open ? CaretUpIcon : CaretDownIcon;
|
||||||
|
|
||||||
|
const handleDownload = () => {
|
||||||
|
downloadMutation.mutate({ torrentId: torrent.id });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
deleteMutation.mutate(
|
||||||
|
{
|
||||||
|
id: torrent.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["items"] });
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["items", itemId, "torrents"],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 hover:bg-neutral-200 cursor-pointer"
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => setOpen((prev) => !prev)}
|
||||||
|
>
|
||||||
|
<p>{torrent.title}</p>
|
||||||
|
{torrent.downloaded && (
|
||||||
|
<span title="Torrent files downloaded">
|
||||||
|
<CheckCircleIcon size={20} color="green" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="ml-auto flex items-center gap-1">
|
||||||
|
<span>[{formatCategory(torrent.category)}]</span>
|
||||||
|
<span>{humanFileSize(torrent.size)}</span>
|
||||||
|
<span>
|
||||||
|
<ChevronIcon size={20} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="p-2 bg-neutral-100 rounded-md text-[15px]">
|
||||||
|
<p>Indexer: {torrent.indexer}</p>
|
||||||
|
<p>Seeders: {torrent.seeders}</p>
|
||||||
|
<p>Peers: {torrent.peers}</p>
|
||||||
|
<p>
|
||||||
|
Published: {dayjs(torrent.pubdate).format("DD.MM.YYYY")} at{" "}
|
||||||
|
{dayjs(torrent.pubdate).format("HH:mm")}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<a
|
||||||
|
href={torrent.guid}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<ArrowSquareOutIcon size={18} /> Open
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="cursor-pointer flex items-center gap-1"
|
||||||
|
onClick={handleDownload}
|
||||||
|
>
|
||||||
|
<DownloadSimpleIcon size={18} /> Download
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-[#b00420] cursor-pointer flex items-center gap-1"
|
||||||
|
onClick={handleDelete}
|
||||||
|
>
|
||||||
|
<TrashIcon size={18} /> Delete torrent
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCategory = (category: number): string => {
|
||||||
|
return categories[category as keyof typeof categories] || "";
|
||||||
|
};
|
||||||
@@ -1,10 +1,61 @@
|
|||||||
export const categories = {
|
export const categories = {
|
||||||
1000: "Console",
|
1000: "Console",
|
||||||
|
1010: "Console/NDS",
|
||||||
|
1020: "Console/PSP",
|
||||||
|
1030: "Console/Wii",
|
||||||
|
1040: "Console/XBox",
|
||||||
|
1050: "Console/XBox 360",
|
||||||
|
1080: "Console/PS3",
|
||||||
|
1090: "Console/Other",
|
||||||
|
1110: "Console/3DS",
|
||||||
|
1120: "Console/PS Vita",
|
||||||
|
1180: "Console/PS4",
|
||||||
|
|
||||||
2000: "Movies",
|
2000: "Movies",
|
||||||
|
2010: "Movies/Foreign",
|
||||||
|
2020: "Movies/Other",
|
||||||
|
2030: "Movies/SD",
|
||||||
|
2040: "Movies/HD",
|
||||||
|
2045: "Movies/UHD",
|
||||||
|
2060: "Movies/3D",
|
||||||
|
2070: "Movies/DVD",
|
||||||
|
|
||||||
3000: "Audio",
|
3000: "Audio",
|
||||||
|
3010: "Audio/MP3",
|
||||||
|
3020: "Audio/Video",
|
||||||
|
3030: "Audio/Audiobook",
|
||||||
|
3040: "Audio/Lossless",
|
||||||
|
3050: "Audio/Other",
|
||||||
|
|
||||||
4000: "PC",
|
4000: "PC",
|
||||||
|
4010: "PC/0day",
|
||||||
|
4030: "PC/Mac",
|
||||||
|
4040: "PC/Mobile-Other",
|
||||||
|
4050: "PC/Games",
|
||||||
|
4060: "PC/Mobile-IOS",
|
||||||
|
4070: "PC/Mobile-Android",
|
||||||
|
|
||||||
5000: "TV",
|
5000: "TV",
|
||||||
|
5020: "TV/Foreign",
|
||||||
|
5030: "TV/SD",
|
||||||
|
5040: "TV/HD",
|
||||||
|
5045: "TV/UHD",
|
||||||
|
5050: "TV/Other",
|
||||||
|
5060: "TV/Sport",
|
||||||
|
5070: "TV/Anime",
|
||||||
|
5080: "TV/Documentary",
|
||||||
|
|
||||||
6000: "XXX",
|
6000: "XXX",
|
||||||
|
6010: "XXX/DVD",
|
||||||
|
6060: "XXX/ImageSet",
|
||||||
|
|
||||||
7000: "Books",
|
7000: "Books",
|
||||||
|
7010: "Books/Mags",
|
||||||
|
7020: "Books/EBook",
|
||||||
|
7030: "Books/Comics",
|
||||||
|
7040: "Books/Technical",
|
||||||
|
7050: "Books/Other",
|
||||||
|
|
||||||
8000: "Other",
|
8000: "Other",
|
||||||
|
8010: "Other/Misc",
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user