package main import ( "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/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 JackettTorrent struct { Seeders string 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() 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 := ` ` 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 } 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) }) 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) } }