Files
tvqueue/main.go

439 lines
10 KiB
Go

package main
import (
"api/jackett"
"api/model"
"embed"
_ "embed"
"encoding/json"
"flag"
"fmt"
"html/template"
"io"
"log"
"net/http"
"os"
"strconv"
"time"
"github.com/autobrr/go-qbittorrent"
"github.com/jmoiron/sqlx"
"github.com/joho/godotenv"
_ "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 JackettTorrent struct {
Seeders 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() {
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()
m := model.New(db)
m.Init()
go func(m *model.Model) {
for {
items, _ := m.GetItems()
for _, item := range items {
if err := checkForNewTorrents(jackettClient, db, m, item); err != nil {
log.Printf("failed to check for new torrents: %v\n", err)
continue
}
}
time.Sleep(1 * time.Hour)
}
}(m)
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("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) {
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("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) {
items, err := m.GetItems()
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 := m.GetItemTorrents(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 := m.GetTorrentById(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
}
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)
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)
})
mux.HandleFunc("DELETE /api/torrents/{id}", func(w http.ResponseWriter, r *http.Request) {
torrentId, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
http.Error(w, err.Error(), 500)
return
}
if err := m.DeleteTorrentById(int64(torrentId)); 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)
}
}