439 lines
10 KiB
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)
|
|
}
|
|
}
|