From 846b32f9b5bd48e694d6cc836a86662af637fe67 Mon Sep 17 00:00:00 2001 From: Daniil Tsivinsky Date: Sun, 15 Feb 2026 13:40:45 +0300 Subject: [PATCH] add refresh endpoint for item's torrents --- main.go | 131 ++++++++++++++++---------- model/items.go | 14 +++ web/src/api/useRefreshItemMutation.ts | 13 +++ web/src/components/Item.tsx | 31 ++++++ web/src/components/Loader.tsx | 7 ++ 5 files changed, 145 insertions(+), 51 deletions(-) create mode 100644 web/src/api/useRefreshItemMutation.ts create mode 100644 web/src/components/Loader.tsx diff --git a/main.go b/main.go index b152fda..c7c64bc 100644 --- a/main.go +++ b/main.go @@ -42,6 +42,62 @@ type JackettTorrent struct { Peers string } +func checkForNewTorrents(jackettClient *jackett.Client, db *sqlx.DB, m *model.Model, item *model.Item) error { + results, err := jackettClient.TVSearch(jackett.TVSearchOptions{ + Query: item.Query, + }) + if err != nil { + return fmt.Errorf("couldn't get to jackett api: %v\n", err) + } + + 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 + } + } + + 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() { flag.Parse() godotenv.Load() @@ -88,57 +144,8 @@ func main() { for { items, _ := m.GetItems() for _, item := range items { - results, err := jackettClient.TVSearch(jackett.TVSearchOptions{ - Query: item.Query, - }) - 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 - } - } - - if err := m.UpdateRefreshedAt(item.ID, time.Now()); err != nil { - log.Printf("couldn't update refreshed_at for %d: %v\n", item.ID, err) + if err := checkForNewTorrents(jackettClient, db, m, item); err != nil { + log.Printf("failed to check for new torrents: %v\n", err) continue } } @@ -206,7 +213,29 @@ func main() { sendJSON(w, struct { Id int64 `json:"id"` }{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) { diff --git a/model/items.go b/model/items.go index 616cb6f..ce86512 100644 --- a/model/items.go +++ b/model/items.go @@ -5,6 +5,20 @@ import ( "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) { rows, err := m.db.Queryx("SELECT * FROM items") if err != nil { diff --git a/web/src/api/useRefreshItemMutation.ts b/web/src/api/useRefreshItemMutation.ts new file mode 100644 index 0000000..dfc445b --- /dev/null +++ b/web/src/api/useRefreshItemMutation.ts @@ -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; + }, + }); +}; diff --git a/web/src/components/Item.tsx b/web/src/components/Item.tsx index f5b4139..1426fc4 100644 --- a/web/src/components/Item.tsx +++ b/web/src/components/Item.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import type { ItemDetails } from "../api/useItemsQuery"; import { useItemTorrentsQuery } from "../api/useItemTorrentsQuery"; import { + ArrowsClockwiseIcon, CaretDownIcon, CaretUpIcon, CheckCircleIcon, @@ -15,6 +16,8 @@ import dayjs from "dayjs"; import relativeTime from "dayjs/plugin/relativeTime"; import { humanFileSize } from "../utils/humanFileSize"; import { useDeleteTorrentMutation } from "../api/useDeleteTorrentMutation"; +import { useRefreshItemMutation } from "../api/useRefreshItemMutation"; +import { Loader } from "./Loader"; dayjs.extend(relativeTime); @@ -31,6 +34,7 @@ export const Item = ({ item }: ItemProps) => { const deleteMutation = useDeleteItemMutation(); const downloadMutation = useDownloadTorrentMutation(); + const refreshMutation = useRefreshItemMutation(); const deleteTorrentMutation = useDeleteTorrentMutation(); const Icon = open ? CaretUpIcon : CaretDownIcon; @@ -54,6 +58,21 @@ export const Item = ({ item }: ItemProps) => { ); }; + const handleRefresh = () => { + refreshMutation.mutate( + { + id: item.id, + }, + { + onSuccess() { + queryClient.invalidateQueries({ + queryKey: ["items", item.id, "torrents"], + }); + }, + }, + ); + }; + const handleDeleteTorrent = (torrentId: number) => { deleteTorrentMutation.mutate( { @@ -61,6 +80,7 @@ export const Item = ({ item }: ItemProps) => { }, { onSuccess() { + queryClient.invalidateQueries({ queryKey: ["items"] }); queryClient.invalidateQueries({ queryKey: ["items", item.id, "torrents"], }); @@ -92,6 +112,17 @@ export const Item = ({ item }: ItemProps) => { ? " (" + dayjs(item.refreshedAt).from(dayjs()) + ")" : null} +