Compare commits

..

14 Commits

15 changed files with 548 additions and 142 deletions

View File

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

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

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

@@ -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
}
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 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
} }

View File

@@ -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
}

View File

@@ -33,6 +33,7 @@ type Item struct {
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 {

View File

@@ -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)
} }

View File

@@ -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 = () => {

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

View File

@@ -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 "";
}
};

View 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 || ""}`} />
);
};

View 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] || "";
};

View File

@@ -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",
}; };