Files
tvqueue/main.go

420 lines
10 KiB
Go

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", "", "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 {
log.Printf("couldn't get to jackett api: %v\n", err)
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 {
log.Printf("couldn't get to qbittorrent api: %v\n", err)
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 %s\n", addr)
if err := http.ListenAndServe(addr, mux); err != nil {
log.Fatalf("failed to start http server: %v\n", err)
}
}