Initial commit: docker compose config
Release Docker multi arch / docker (push) Has been cancelled
Test Install Script / Test Script Syntax (push) Has been cancelled
Test Install Script / Test on almalinux-10 (default) (push) Has been cancelled
Test Install Script / Test on almalinux-10 (root) (push) Has been cancelled
Test Install Script / Test on almalinux-8 (default) (push) Has been cancelled
Test Install Script / Test on almalinux-8 (root) (push) Has been cancelled
Test Install Script / Test on almalinux-9 (default) (push) Has been cancelled
Test Install Script / Test on almalinux-9 (root) (push) Has been cancelled
Test Install Script / Test on amazonlinux-2 (default) (push) Has been cancelled
Test Install Script / Test on amazonlinux-2 (root) (push) Has been cancelled
Test Install Script / Test on debian-11 (default) (push) Has been cancelled
Test Install Script / Test on debian-11 (root) (push) Has been cancelled
Test Install Script / Test on debian-12 (default) (push) Has been cancelled
Test Install Script / Test on debian-12 (root) (push) Has been cancelled
Test Install Script / Test on debian-13 (default) (push) Has been cancelled
Test Install Script / Test on debian-13 (root) (push) Has been cancelled
Test Install Script / Test on fedora-latest (default) (push) Has been cancelled
Test Install Script / Test on fedora-latest (root) (push) Has been cancelled
Test Install Script / Test on rocky-10 (default) (push) Has been cancelled
Test Install Script / Test on rocky-10 (root) (push) Has been cancelled
Test Install Script / Test on rocky-8 (default) (push) Has been cancelled
Test Install Script / Test on rocky-8 (root) (push) Has been cancelled
Test Install Script / Test on rocky-9 (default) (push) Has been cancelled
Test Install Script / Test on rocky-9 (root) (push) Has been cancelled
Test Install Script / Test on ubuntu-22.04 (default) (push) Has been cancelled
Test Install Script / Test on ubuntu-22.04 (root) (push) Has been cancelled
Test Install Script / Test on ubuntu-24.04 (default) (push) Has been cancelled
Test Install Script / Test on ubuntu-24.04 (root) (push) Has been cancelled

This commit is contained in:
2026-05-30 12:07:11 +00:00
commit 616c6b1c62
381 changed files with 55145 additions and 0 deletions
+63
View File
@@ -0,0 +1,63 @@
package api
import (
"net/http"
"server/torr"
"github.com/gin-gonic/gin"
"github.com/pkg/errors"
)
// Action: get
type cacheReqJS struct {
requestI
Hash string `json:"hash,omitempty"`
}
// cache godoc
//
// @Summary Return cache stats
// @Description Return cache stats.
//
// @Tags API
//
// @Param request body cacheReqJS true "Cache stats request"
//
// @Produce json
// @Success 200 {object} state.CacheState "Cache stats"
// @Router /cache [post]
func cache(c *gin.Context) {
var req cacheReqJS
err := c.ShouldBindJSON(&req)
if err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}
c.Status(http.StatusBadRequest)
switch req.Action {
case "get":
{
getCache(req, c)
}
}
}
func getCache(req cacheReqJS, c *gin.Context) {
if req.Hash == "" {
c.AbortWithError(http.StatusBadRequest, errors.New("hash is empty"))
return
}
tor := torr.GetTorrent(req.Hash)
if tor != nil {
st := tor.CacheState()
if st == nil {
c.JSON(200, struct{}{})
} else {
c.JSON(200, st)
}
} else {
c.Status(http.StatusNotFound)
}
}
+64
View File
@@ -0,0 +1,64 @@
package api
import (
"fmt"
"io"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
)
type fileReader struct {
pos int64
size int64
io.ReadSeeker
}
func newFR(size int64) *fileReader {
return &fileReader{
pos: 0,
size: size,
}
}
func (f *fileReader) Read(p []byte) (n int, err error) {
f.pos = f.pos + int64(len(p))
return len(p), nil
}
func (f *fileReader) Seek(offset int64, whence int) (int64, error) {
switch whence {
case 0:
f.pos = offset
case 1:
f.pos += offset
case 2:
f.pos = f.size + offset
}
return f.pos, nil
}
// download godoc
//
// @Summary Generates test file of given size
// @Description Download the test file of given size (for speed testing purpose).
//
// @Tags API
//
// @Param size path string true "Test file size (in MB)"
//
// @Produce application/octet-stream
// @Success 200 {file} file
// @Router /download/{size} [get]
func download(c *gin.Context) {
szStr := c.Param("size")
sz, err := strconv.Atoi(szStr)
if err != nil {
c.Error(err)
return
}
http.ServeContent(c.Writer, c.Request, fmt.Sprintln(szStr)+"mb.bin", time.Now(), newFR(int64(sz*1024*1024)))
}
+48
View File
@@ -0,0 +1,48 @@
package api
import (
"errors"
"fmt"
"net/http"
"server/ffprobe"
sets "server/settings"
"github.com/gin-gonic/gin"
)
// ffp godoc
//
// @Summary Gather informations using ffprobe
// @Description Gather informations using ffprobe.
//
// @Tags API
//
// @Param hash path string true "Torrent hash"
// @Param id path string true "File index in torrent"
//
// @Produce json
// @Success 200 "Data returned from ffprobe"
// @Router /ffp/{hash}/{id} [get]
func ffp(c *gin.Context) {
hash := c.Param("hash")
indexStr := c.Param("id")
if hash == "" || indexStr == "" {
c.AbortWithError(http.StatusNotFound, errors.New("link should not be empty"))
return
}
link := "http://127.0.0.1:" + sets.Port + "/play/" + hash + "/" + indexStr
if sets.Ssl {
link = "https://127.0.0.1:" + sets.SslPort + "/play/" + hash + "/" + indexStr
}
data, err := ffprobe.ProbeUrl(link)
if err != nil {
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("error getting data: %v", err))
return
}
c.JSON(200, data)
}
+186
View File
@@ -0,0 +1,186 @@
package api
import (
"bytes"
"encoding/hex"
"fmt"
"net/http"
"net/url"
"path/filepath"
"sort"
"strings"
"time"
"github.com/anacrolix/missinggo/v2/httptoo"
sets "server/settings"
"server/torr"
"server/torr/state"
"server/utils"
"github.com/gin-gonic/gin"
"github.com/pkg/errors"
)
// allPlayList godoc
//
// @Summary Get a M3U playlist with all torrents
// @Description Retrieve all torrents and generates a bundled M3U playlist.
//
// @Tags API
//
// @Produce audio/x-mpegurl
// @Success 200 {file} file
// @Router /playlistall/all.m3u [get]
func allPlayList(c *gin.Context) {
torrs := torr.ListTorrent()
host := utils.GetScheme(c) + "://" + utils.GetHost(c)
list := "#EXTM3U\n"
hash := ""
// fn=file.m3u fix forkplayer bug with end .m3u in link
for _, tr := range torrs {
list += "#EXTINF:0"
if tr.Poster != "" {
list += " tvg-logo=\"" + tr.Poster + "\""
}
list += " type=\"playlist\"," + tr.Title + "\n"
list += host + "/stream/" + url.PathEscape(tr.Title) + ".m3u?link=" + tr.TorrentSpec.InfoHash.HexString() + "&m3u&fn=file.m3u\n"
hash += tr.Hash().HexString()
}
sendM3U(c, "all.m3u", hash, list)
}
// playList godoc
//
// @Summary Get HTTP link of torrent in M3U list
// @Description Get HTTP link of torrent in M3U list.
//
// @Tags API
//
// @Param hash query string true "Torrent hash"
// @Param fromlast query bool false "From last play file"
//
// @Produce audio/x-mpegurl
// @Success 200 {file} file
// @Router /playlist [get]
func playList(c *gin.Context) {
hash, _ := c.GetQuery("hash")
_, fromlast := c.GetQuery("fromlast")
if hash == "" {
c.AbortWithError(http.StatusBadRequest, errors.New("hash is empty"))
return
}
tor := torr.GetTorrent(hash)
if tor == nil {
c.AbortWithStatus(http.StatusNotFound)
return
}
if tor.Stat == state.TorrentInDB {
tor = torr.LoadTorrent(tor)
if tor == nil {
c.AbortWithError(http.StatusInternalServerError, errors.New("error get torrent info"))
return
}
}
host := utils.GetScheme(c) + "://" + utils.GetHost(c)
list := getM3uList(tor.Status(), host, fromlast)
list = "#EXTM3U\n" + list
name := strings.ReplaceAll(c.Param("fname"), `/`, "") // strip starting / from param
if name == "" {
name = tor.Name() + ".m3u"
} else if !strings.HasSuffix(strings.ToLower(name), ".m3u") && !strings.HasSuffix(strings.ToLower(name), ".m3u8") {
name += ".m3u"
}
sendM3U(c, name, tor.Hash().HexString(), list)
}
func sendM3U(c *gin.Context, name, hash string, m3u string) {
c.Header("Content-Type", "audio/x-mpegurl")
c.Header("Connection", "close")
if hash != "" {
etag := hex.EncodeToString([]byte(fmt.Sprintf("%s/%s", hash, name)))
c.Header("ETag", httptoo.EncodeQuotedString(etag))
}
if name == "" {
name = "playlist.m3u"
}
c.Header("Content-Disposition", `attachment; filename="`+name+`"`)
http.ServeContent(c.Writer, c.Request, name, time.Now(), bytes.NewReader([]byte(m3u)))
}
func getM3uList(tor *state.TorrentStatus, host string, fromLast bool) string {
m3u := ""
from := 0
if fromLast {
pos := searchLastPlayed(tor)
if pos != -1 {
from = pos
}
}
for i, f := range tor.FileStats {
if i >= from {
if utils.GetMimeType(f.Path) != "*/*" {
fn := filepath.Base(f.Path)
if fn == "" {
fn = f.Path
}
m3u += "#EXTINF:0," + fn + "\n"
fileNamesakes := findFileNamesakes(tor.FileStats, f) // find external media with same name (audio/subtiles tracks)
if fileNamesakes != nil {
m3u += "#EXTVLCOPT:input-slave=" // include VLC option for external media
for _, namesake := range fileNamesakes { // include play-links to external media, with # splitter
sname := filepath.Base(namesake.Path)
m3u += host + "/stream/" + url.PathEscape(sname) + "?link=" + tor.Hash + "&index=" + fmt.Sprint(namesake.Id) + "&play#"
}
m3u += "\n"
}
name := filepath.Base(f.Path)
m3u += host + "/stream/" + url.PathEscape(name) + "?link=" + tor.Hash + "&index=" + fmt.Sprint(f.Id) + "&play\n"
}
}
}
return m3u
}
func findFileNamesakes(files []*state.TorrentFileStat, file *state.TorrentFileStat) []*state.TorrentFileStat {
// find files with the same name in torrent
name := filepath.Base(strings.TrimSuffix(file.Path, filepath.Ext(file.Path)))
var namesakes []*state.TorrentFileStat
for _, f := range files {
if strings.Contains(f.Path, name) { // external tracks always include name of videofile
if f != file { // exclude itself
namesakes = append(namesakes, f)
}
}
}
return namesakes
}
func searchLastPlayed(tor *state.TorrentStatus) int {
viewed := sets.ListViewed(tor.Hash)
if len(viewed) == 0 {
return -1
}
sort.Slice(viewed, func(i, j int) bool {
return viewed[i].FileIndex > viewed[j].FileIndex
})
lastViewedIndex := viewed[0].FileIndex
for i, stat := range tor.FileStats {
if stat.Id == lastViewedIndex {
if i >= len(tor.FileStats) {
return -1
}
return i
}
}
return -1
}
+85
View File
@@ -0,0 +1,85 @@
package api
import (
"errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"server/torr"
"server/torr/state"
"server/web/api/utils"
)
// play godoc
//
// @Summary Play given torrent by infohash
// @Description Play given torrent referenced by infohash and file id.
//
// @Tags API
//
// @Param hash path string true "Torrent infohash"
// @Param id path string true "File index in torrent"
//
// @Produce application/octet-stream
// @Success 200 "Torrent data"
// @Router /play/{hash}/{id} [get]
func play(c *gin.Context) {
hash := c.Param("hash")
indexStr := c.Param("id")
notAuth := c.GetBool("auth_required") && c.GetString(gin.AuthUserKey) == ""
if hash == "" || indexStr == "" {
c.AbortWithError(http.StatusNotFound, errors.New("no infohash or file index in link"))
return
}
spec, err := utils.ParseLink(hash)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
tor := torr.GetTorrent(spec.InfoHash.HexString())
if tor == nil && notAuth {
c.Header("WWW-Authenticate", "Basic realm=Authorization Required")
c.AbortWithStatus(http.StatusUnauthorized)
return
}
if tor == nil {
c.AbortWithError(http.StatusInternalServerError, errors.New("error get torrent"))
return
}
if tor.Stat == state.TorrentInDB {
tor, err = torr.AddTorrent(spec, tor.Title, tor.Poster, tor.Data, tor.Category)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
}
if !tor.GotInfo() {
c.AbortWithError(http.StatusInternalServerError, errors.New("torrent connection timeout"))
return
}
// find file
index := -1
if len(tor.Files()) == 1 {
index = 1
} else {
ind, err := strconv.Atoi(indexStr)
if err == nil {
index = ind
}
}
if index == -1 { // if file index not set and play file exec
c.AbortWithError(http.StatusBadRequest, errors.New("file \"index\" is wrong"))
return
}
tor.Stream(index, c.Request, c.Writer)
}
+67
View File
@@ -0,0 +1,67 @@
package api
import (
config "server/settings"
"server/web/auth"
"github.com/gin-gonic/gin"
)
type requestI struct {
Action string `json:"action,omitempty"`
}
func SetupRoute(route gin.IRouter) {
authorized := route.Group("/", auth.CheckAuth())
authorized.GET("/shutdown", shutdown)
authorized.GET("/shutdown/*reason", shutdown)
authorized.POST("/settings", settings)
authorized.POST("/torznab/test", torznabTest)
authorized.POST("/torrents", torrents)
authorized.POST("/torrent/upload", torrentUpload)
authorized.POST("/cache", cache)
route.HEAD("/stream", stream)
route.GET("/stream", stream)
route.HEAD("/stream/*fname", stream)
route.GET("/stream/*fname", stream)
route.HEAD("/play/:hash/:id", play)
route.GET("/play/:hash/:id", play)
authorized.POST("/viewed", viewed)
authorized.GET("/playlistall/all.m3u", allPlayList)
route.GET("/playlist", playList)
route.GET("/playlist/*fname", playList)
authorized.GET("/download/:size", download)
if config.SearchWA {
route.GET("/search/*query", rutorSearch)
} else {
authorized.GET("/search/*query", rutorSearch)
}
if config.SearchWA {
route.GET("/torznab/search/*query", torznabSearch)
} else {
authorized.GET("/torznab/search/*query", torznabSearch)
}
// Add storage settings endpoints
authorized.GET("/storage/settings", GetStorageSettings)
authorized.POST("/storage/settings", UpdateStorageSettings)
// Add TMDB settings endpoint
authorized.GET("/tmdb/settings", tmdbSettings)
authorized.GET("/ffp/:hash/:id", ffp)
}
+38
View File
@@ -0,0 +1,38 @@
package api
import (
"net/http"
"net/url"
"github.com/gin-gonic/gin"
"server/rutor"
"server/rutor/models"
sets "server/settings"
)
// rutorSearch godoc
//
// @Summary Makes a rutor search
// @Description Makes a rutor search.
//
// @Tags API
//
// @Param query query string true "Rutor query"
//
// @Produce json
// @Success 200 {array} models.TorrentDetails "Rutor torrent search result(s)"
// @Router /search [get]
func rutorSearch(c *gin.Context) {
if !sets.BTsets.EnableRutorSearch {
c.JSON(http.StatusBadRequest, []string{})
return
}
query := c.Query("query")
query, _ = url.QueryUnescape(query)
list := rutor.Search(query)
if list == nil {
list = []*models.TorrentDetails{}
}
c.JSON(200, list)
}
+65
View File
@@ -0,0 +1,65 @@
package api
import (
"net/http"
"server/rutor"
"server/dlna"
"github.com/gin-gonic/gin"
"github.com/pkg/errors"
sets "server/settings"
"server/torr"
)
// Action: get, set, def
type setsReqJS struct {
requestI
Sets *sets.BTSets `json:"sets,omitempty"`
}
// settings godoc
//
// @Summary Get / Set server settings
// @Description Allow to get or set server settings.
//
// @Tags API
//
// @Param request body setsReqJS true "Settings request. Available params for action: get, set, def"
//
// @Accept json
// @Produce json
// @Success 200 {object} sets.BTSets "Settings JSON or nothing. Depends on what action has been asked."
// @Router /settings [post]
func settings(c *gin.Context) {
var req setsReqJS
err := c.ShouldBindJSON(&req)
if err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}
if req.Action == "get" {
c.JSON(200, sets.BTsets)
return
} else if req.Action == "set" {
torr.SetSettings(req.Sets)
dlna.Stop()
if req.Sets.EnableDLNA {
dlna.Start()
}
rutor.Stop()
rutor.Start()
c.Status(200)
return
} else if req.Action == "def" {
torr.SetDefSettings()
dlna.Stop()
rutor.Stop()
c.Status(200)
return
}
c.AbortWithError(http.StatusBadRequest, errors.New("action is empty"))
}
+33
View File
@@ -0,0 +1,33 @@
package api
import (
"net/http"
"strings"
"time"
sets "server/settings"
"server/torr"
"github.com/gin-gonic/gin"
)
// shutdown godoc
// @Summary Shuts down server
// @Description Gracefully shuts down server after 1 second.
//
// @Tags API
//
// @Success 200
// @Router /shutdown [get]
func shutdown(c *gin.Context) {
reasonStr := strings.ReplaceAll(c.Param("reason"), `/`, "")
if sets.ReadOnly && reasonStr == "" {
c.Status(http.StatusForbidden)
return
}
c.Status(200)
go func() {
time.Sleep(1000)
torr.Shutdown()
}()
}
+101
View File
@@ -0,0 +1,101 @@
package api
import (
"net/http"
sets "server/settings"
"github.com/gin-gonic/gin"
)
// GetStorageSettings godoc
// @Summary Get storage configuration settings
// @Description Retrieves the current storage preferences for settings and viewed history
// @Tags API
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Success 200 {object} map[string]interface{} "Storage preferences"
// @Failure 401 {object} map[string]string "Unauthorized"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /storage/settings [get]
func GetStorageSettings(c *gin.Context) {
prefs := sets.GetStoragePreferences()
c.JSON(http.StatusOK, prefs)
}
// UpdateStorageSettings godoc
// @Summary Update storage configuration settings
// @Description Updates the storage preferences for settings and viewed history. Requires application restart for changes to take effect.
// @Tags API
// @Accept json,x-www-form-urlencoded
// @Produce json
// @Security ApiKeyAuth
// @Param request body map[string]interface{} true "Storage preferences to update"
// @Param settings formData string false "Settings storage type" Enums(json,bbolt)
// @Param viewed formData string false "Viewed history storage type" Enums(json,bbolt)
// @Success 200 {object} map[string]string "Update successful"
// @Failure 400 {object} map[string]string "Invalid input data"
// @Failure 401 {object} map[string]string "Unauthorized"
// @Failure 403 {object} map[string]string "Read-only mode"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /storage/settings [post]
func UpdateStorageSettings(c *gin.Context) {
if sets.ReadOnly {
c.JSON(http.StatusForbidden, gin.H{"error": "Read-only mode"})
return
}
var prefs map[string]interface{}
// Check Content-Type to handle both JSON and form data
contentType := c.GetHeader("Content-Type")
if contentType == "application/x-www-form-urlencoded" {
// Handle form data
settings := c.PostForm("settings")
viewed := c.PostForm("viewed")
prefs = make(map[string]interface{})
if settings != "" {
prefs["settings"] = settings
}
if viewed != "" {
prefs["viewed"] = viewed
}
} else {
// Handle JSON (default)
if err := c.ShouldBindJSON(&prefs); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
}
// Validate preferences - only validate if provided
if settingsPref, ok := prefs["settings"].(string); ok && settingsPref != "" {
if settingsPref != "json" && settingsPref != "bbolt" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid settings storage value"})
return
}
}
if viewedPref, ok := prefs["viewed"].(string); ok && viewedPref != "" {
if viewedPref != "json" && viewedPref != "bbolt" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid viewed storage value"})
return
}
}
// Check if we have at least one value to update
if len(prefs) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "No preferences provided"})
return
}
if err := sets.SetStoragePreferences(prefs); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
+322
View File
@@ -0,0 +1,322 @@
package api
import (
"net/http"
"net/url"
"server/log"
"server/torrshash"
"strconv"
"strings"
"server/torr"
"server/torr/state"
utils2 "server/utils"
"server/web/api/utils"
"github.com/anacrolix/torrent"
"github.com/gin-gonic/gin"
"github.com/pkg/errors"
)
// get stat
// http://127.0.0.1:8090/stream/fname?link=...&stat
// get m3u
// http://127.0.0.1:8090/stream/fname?link=...&index=1&m3u
// http://127.0.0.1:8090/stream/fname?link=...&index=1&m3u&fromlast
// stream torrent
// http://127.0.0.1:8090/stream/fname?link=...&index=1&play
// http://127.0.0.1:8090/stream/fname?link=...&index=1&play&preload
// http://127.0.0.1:8090/stream/fname?link=...&index=1&play&save
// http://127.0.0.1:8090/stream/fname?link=...&index=1&play&save&title=...&poster=...
// only save
// http://127.0.0.1:8090/stream/fname?link=...&save&title=...&poster=...
// stream godoc
//
// @Summary Multi usage endpoint
// @Description Multi usage endpoint.
//
// @Tags API
//
// @Param link query string true "Magnet/hash/link to torrent"
// @Param index query string false "File index in torrent"
// @Param preload query string false "Should preload torrent"
// @Param stat query string false "Get statistics from torrent"
// @Param save query string false "Should save torrent"
// @Param m3u query string false "Get torrent as M3U playlist"
// @Param fromlast query string false "Get M3U from last played file"
// @Param play query string false "Start stream torrent"
// @Param title query string false "Set title of torrent"
// @Param poster query string false "Set poster link of torrent"
// @Param category query string false "Set category of torrent, used in web: movie, tv, music, other"
//
// @Produce application/octet-stream
// @Success 200 "Data returned according to query"
// @Router /stream [get]
func stream(c *gin.Context) {
link := c.Query("link")
indexStr := c.Query("index")
_, preload := c.GetQuery("preload")
_, stat := c.GetQuery("stat")
_, save := c.GetQuery("save")
_, m3u := c.GetQuery("m3u")
_, fromlast := c.GetQuery("fromlast")
_, play := c.GetQuery("play")
title := c.Query("title")
poster := c.Query("poster")
category := c.Query("category")
data := ""
notAuth := c.GetBool("auth_required") && c.GetString(gin.AuthUserKey) == ""
if notAuth {
err := utils.TestLink(link, !notAuth)
if err != nil {
log.TLogln("Wrong link:", err)
c.AbortWithError(http.StatusBadRequest, errors.New("wrong link"))
return
}
}
if notAuth && (play || m3u) {
streamNoAuth(c)
return
}
if notAuth {
c.Header("WWW-Authenticate", "Basic realm=Authorization Required")
c.AbortWithStatus(http.StatusUnauthorized)
return
}
if link == "" {
c.AbortWithError(http.StatusBadRequest, errors.New("link should not be empty"))
return
}
link, _ = url.QueryUnescape(link)
title, _ = url.QueryUnescape(title)
poster, _ = url.QueryUnescape(poster)
category, _ = url.QueryUnescape(category)
var spec *torrent.TorrentSpec
var torrsHash *torrshash.TorrsHash
var err error
if strings.HasPrefix(link, "torrs://") || (len(link) > 45 && torrshash.IsBase62(link)) {
spec, torrsHash, err = utils.ParseTorrsHash(link)
if err != nil {
log.TLogln("error parse torrshash:", err)
c.AbortWithError(http.StatusBadRequest, err)
return
}
if title == "" {
title = torrsHash.Title()
}
if poster == "" {
poster = torrsHash.Poster()
}
if category == "" {
category = torrsHash.Category()
}
} else {
spec, err = utils.ParseLink(link)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
}
tor := torr.GetTorrent(spec.InfoHash.HexString())
if tor != nil {
title = tor.Title
poster = tor.Poster
data = tor.Data
category = tor.Category
}
if tor == nil || tor.Stat == state.TorrentInDB {
tor, err = torr.AddTorrent(spec, title, poster, data, category)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
}
if !tor.GotInfo() {
c.AbortWithError(http.StatusInternalServerError, errors.New("torrent connection timeout"))
return
}
if tor.Title == "" {
tor.Title = tor.Name()
}
// save to db
if save {
torr.SaveTorrentToDB(tor)
c.Status(200) // only set status, not return
}
// find file
index := -1
if len(tor.Files()) == 1 {
index = 1
} else {
ind, err := strconv.Atoi(indexStr)
if err == nil {
index = ind
}
}
if index == -1 && play { // if file index not set and play file exec
c.AbortWithError(http.StatusBadRequest, errors.New("\"index\" is empty or wrong"))
return
}
// preload torrent
if preload {
torr.Preload(tor, index)
}
// return stat if query
if stat {
c.JSON(200, tor.Status())
return
} else
// return m3u if query
if m3u {
name := strings.ReplaceAll(c.Param("fname"), `/`, "") // strip starting / from param
if name == "" {
name = tor.Name() + ".m3u"
} else if !strings.HasSuffix(strings.ToLower(name), ".m3u") && !strings.HasSuffix(strings.ToLower(name), ".m3u8") {
name += ".m3u"
}
m3ulist := "#EXTM3U\n" + getM3uList(tor.Status(), utils2.GetScheme(c)+"://"+utils2.GetHost(c), fromlast)
sendM3U(c, name, tor.Hash().HexString(), m3ulist)
return
} else
// return play if query
if play {
tor.Stream(index, c.Request, c.Writer)
return
}
}
func streamNoAuth(c *gin.Context) {
link := c.Query("link")
indexStr := c.Query("index")
_, preload := c.GetQuery("preload")
_, m3u := c.GetQuery("m3u")
_, fromlast := c.GetQuery("fromlast")
_, play := c.GetQuery("play")
title := c.Query("title")
poster := c.Query("poster")
category := c.Query("category")
if link == "" {
c.AbortWithError(http.StatusBadRequest, errors.New("link should not be empty"))
return
}
link, _ = url.QueryUnescape(link)
title, _ = url.QueryUnescape(title)
poster, _ = url.QueryUnescape(poster)
category, _ = url.QueryUnescape(category)
var spec *torrent.TorrentSpec
var torrsHash *torrshash.TorrsHash
var err error
if strings.HasPrefix(link, "torrs://") || (len(link) > 45 && torrshash.IsBase62(link)) {
spec, torrsHash, err = utils.ParseTorrsHash(link)
if err != nil {
log.TLogln("error parse torrshash:", err)
c.AbortWithError(http.StatusBadRequest, err)
return
}
if title == "" {
title = torrsHash.Title()
}
if poster == "" {
poster = torrsHash.Poster()
}
if category == "" {
category = torrsHash.Category()
}
} else {
spec, err = utils.ParseLink(link)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
}
tor := torr.GetTorrent(spec.InfoHash.HexString())
if tor == nil {
c.Header("WWW-Authenticate", "Basic realm=Authorization Required")
c.AbortWithStatus(http.StatusUnauthorized)
return
}
if title == "" {
title = tor.Title
}
if poster == "" {
poster = tor.Poster
}
if category == "" {
category = tor.Category
}
data := tor.Data
if tor.Stat == state.TorrentInDB {
tor, err = torr.AddTorrent(spec, title, poster, data, category)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
}
if !tor.GotInfo() {
c.AbortWithError(http.StatusInternalServerError, errors.New("torrent connection timeout"))
return
}
// find file
index := -1
if len(tor.Files()) == 1 {
index = 1
} else {
ind, err := strconv.Atoi(indexStr)
if err == nil {
index = ind
}
}
if index == -1 && play { // if file index not set and play file exec
c.AbortWithError(http.StatusBadRequest, errors.New("\"index\" is empty or wrong"))
return
}
// preload torrent
if preload {
torr.Preload(tor, index)
}
// return m3u if query
if m3u {
name := strings.ReplaceAll(c.Param("fname"), `/`, "") // strip starting / from param
if name == "" {
name = tor.Name() + ".m3u"
} else if !strings.HasSuffix(strings.ToLower(name), ".m3u") && !strings.HasSuffix(strings.ToLower(name), ".m3u8") {
name += ".m3u"
}
m3ulist := "#EXTM3U\n" + getM3uList(tor.Status(), utils2.GetScheme(c)+"://"+utils2.GetHost(c), fromlast)
sendM3U(c, name, tor.Hash().HexString(), m3ulist)
return
} else
// return play if query
if play {
tor.Stream(index, c.Request, c.Writer)
return
}
c.Header("WWW-Authenticate", "Basic realm=Authorization Required")
c.AbortWithStatus(http.StatusUnauthorized)
}
+27
View File
@@ -0,0 +1,27 @@
package api
import (
"net/http"
"github.com/gin-gonic/gin"
sets "server/settings"
)
// tmdbSettings godoc
//
// @Summary Get TMDB settings
// @Description Get TMDB API configuration
//
// @Tags API
//
// @Produce json
// @Success 200 {object} sets.TMDBConfig "TMDB settings"
// @Router /tmdb/settings [get]
func tmdbSettings(c *gin.Context) {
if sets.BTsets == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Settings not initialized"})
return
}
c.JSON(200, sets.BTsets.TMDBSettings)
}
+229
View File
@@ -0,0 +1,229 @@
package api
import (
"net/http"
"server/torrshash"
"strings"
"server/dlna"
"server/log"
set "server/settings"
"server/torr"
"server/torr/state"
"server/web/api/utils"
"github.com/anacrolix/torrent"
"github.com/gin-gonic/gin"
"github.com/pkg/errors"
)
// Action: add, get, set, rem, list, drop
type torrReqJS struct {
requestI
Link string `json:"link,omitempty"`
Hash string `json:"hash,omitempty"`
Title string `json:"title,omitempty"`
Category string `json:"category,omitempty"`
Poster string `json:"poster,omitempty"`
Data string `json:"data,omitempty"`
SaveToDB bool `json:"save_to_db,omitempty"`
}
// torrents godoc
//
// @Summary Handle torrents informations
// @Description Allow to list, add, remove, get, set, drop, wipe torrents on server. The action depends of what has been asked.
//
// @Tags API
//
// @Param request body torrReqJS true "Torrent request. Available params for action: add, get, set, rem, list, drop, wipe. link required for add, hash required for get, set, rem, drop."
//
// @Accept json
// @Produce json
// @Success 200
// @Router /torrents [post]
func torrents(c *gin.Context) {
var req torrReqJS
err := c.ShouldBindJSON(&req)
if err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}
c.Status(http.StatusBadRequest)
switch req.Action {
case "add":
{
addTorrent(req, c)
}
case "get":
{
getTorrent(req, c)
}
case "set":
{
setTorrent(req, c)
}
case "rem":
{
remTorrent(req, c)
}
case "list":
{
listTorrents(c)
}
case "drop":
{
dropTorrent(req, c)
}
case "wipe":
{
wipeTorrents(c)
}
}
}
func addTorrent(req torrReqJS, c *gin.Context) {
if req.Link == "" {
c.AbortWithError(http.StatusBadRequest, errors.New("link is empty"))
return
}
log.TLogln("add torrent", req.Link)
req.Link = strings.ReplaceAll(req.Link, "&", "&")
var torrSpec *torrent.TorrentSpec
var torrsHash *torrshash.TorrsHash
var err error
if strings.HasPrefix(req.Link, "torrs://") {
torrSpec, torrsHash, err = utils.ParseTorrsHash(req.Link)
if err != nil {
log.TLogln("error parse torrshash:", err)
c.AbortWithError(http.StatusBadRequest, err)
return
}
if req.Title == "" {
req.Title = torrsHash.Title()
}
if req.Poster == "" {
req.Poster = torrsHash.Poster()
}
if req.Category == "" {
req.Category = torrsHash.Category()
}
} else {
torrSpec, err = utils.ParseLink(req.Link)
if err != nil {
log.TLogln("error parse link:", err)
c.AbortWithError(http.StatusBadRequest, err)
return
}
}
tor, err := torr.AddTorrent(torrSpec, req.Title, req.Poster, req.Data, req.Category)
if err != nil {
log.TLogln("error add torrent:", err)
c.AbortWithError(http.StatusInternalServerError, err)
return
}
go func() {
if !tor.GotInfo() {
log.TLogln("error add torrent:", "timeout connection get torrent info")
return
}
if tor.Title == "" {
tor.Title = torrSpec.DisplayName // prefer dn over name
tor.Title = strings.ReplaceAll(tor.Title, "rutor.info", "")
tor.Title = strings.ReplaceAll(tor.Title, "_", " ")
tor.Title = strings.Trim(tor.Title, " ")
if tor.Title == "" {
tor.Title = tor.Name()
}
}
if req.SaveToDB {
torr.SaveTorrentToDB(tor)
}
}()
if set.BTsets.EnableDLNA {
dlna.Stop()
dlna.Start()
}
c.JSON(200, tor.Status())
}
func getTorrent(req torrReqJS, c *gin.Context) {
if req.Hash == "" {
c.AbortWithError(http.StatusBadRequest, errors.New("hash is empty"))
return
}
tor := torr.GetTorrent(req.Hash)
if tor != nil {
st := tor.Status()
c.JSON(200, st)
} else {
c.Status(http.StatusNotFound)
}
}
func setTorrent(req torrReqJS, c *gin.Context) {
if req.Hash == "" {
c.AbortWithError(http.StatusBadRequest, errors.New("hash is empty"))
return
}
torr.SetTorrent(req.Hash, req.Title, req.Poster, req.Category, req.Data)
c.Status(200)
}
func remTorrent(req torrReqJS, c *gin.Context) {
if req.Hash == "" {
c.AbortWithError(http.StatusBadRequest, errors.New("hash is empty"))
return
}
torr.RemTorrent(req.Hash)
// TODO: remove
if set.BTsets.EnableDLNA {
dlna.Stop()
dlna.Start()
}
c.Status(200)
}
func listTorrents(c *gin.Context) {
list := torr.ListTorrent()
if len(list) == 0 {
c.JSON(200, []*state.TorrentStatus{})
return
}
var stats []*state.TorrentStatus
for _, tr := range list {
stats = append(stats, tr.Status())
}
c.JSON(200, stats)
}
func dropTorrent(req torrReqJS, c *gin.Context) {
if req.Hash == "" {
c.AbortWithError(http.StatusBadRequest, errors.New("hash is empty"))
return
}
torr.DropTorrent(req.Hash)
c.Status(200)
}
func wipeTorrents(c *gin.Context) {
torrents := torr.ListTorrent()
for _, t := range torrents {
torr.RemTorrent(t.TorrentSpec.InfoHash.HexString())
}
// TODO: remove (copied todo from remTorrent())
if set.BTsets.EnableDLNA {
dlna.Stop()
dlna.Start()
}
c.Status(200)
}
+64
View File
@@ -0,0 +1,64 @@
package api
import (
"net/http"
"net/url"
"strconv"
"github.com/gin-gonic/gin"
"server/rutor/models"
sets "server/settings"
"server/torznab"
)
// torznabSearch godoc
//
// @Summary Makes a torznab search
// @Description Makes a torznab search.
//
// @Tags API
//
// @Param query query string true "Torznab query"
//
// @Produce json
// @Success 200 {array} models.TorrentDetails "Torznab torrent search result(s)"
// @Router /torznab/search [get]
func torznabSearch(c *gin.Context) {
if !sets.BTsets.EnableTorznabSearch {
c.JSON(http.StatusBadRequest, []string{})
return
}
query := c.Query("query")
indexStr := c.DefaultQuery("index", "-1")
index := -1
if i, err := strconv.Atoi(indexStr); err == nil {
index = i
}
query, _ = url.QueryUnescape(query)
list := torznab.Search(query, index)
if list == nil {
list = []*models.TorrentDetails{}
}
c.JSON(200, list)
}
type torznabTestReq struct {
Host string `json:"host"`
Key string `json:"key"`
}
func torznabTest(c *gin.Context) {
var req torznabTestReq
if err := c.ShouldBindJSON(&req); err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}
if err := torznab.Test(req.Host, req.Key); err != nil {
c.JSON(200, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(200, gin.H{"success": true})
}
+114
View File
@@ -0,0 +1,114 @@
package api
import (
"mime/multipart"
"net/http"
"server/log"
set "server/settings"
"server/torr"
"server/torr/state"
"server/web/api/utils"
"github.com/gin-gonic/gin"
)
// torrentUpload godoc
//
// @Summary Add .torrent files
// @Description Supports multiple files. Returns array of statuses.
//
// @Tags API
//
// @Param file formData file true "Torrent file(s) to insert"
// @Param save formData string false "Save to DB"
// @Param title formData string false "Torrent title (single file only)"
// @Param category formData string false "Torrent category"
// @Param poster formData string false "Torrent poster (single file only)"
// @Param data formData string false "Torrent data"
//
// @Accept multipart/form-data
//
// @Produce json
// @Success 200 {array} state.TorrentStatus "Torrent statuses"
// @Router /torrent/upload [post]
func torrentUpload(c *gin.Context) {
form, err := c.MultipartForm()
if err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}
defer form.RemoveAll()
save := len(form.Value["save"]) > 0
title := ""
if len(form.Value["title"]) > 0 {
title = form.Value["title"][0]
}
category := ""
if len(form.Value["category"]) > 0 {
category = form.Value["category"][0]
}
poster := ""
if len(form.Value["poster"]) > 0 {
poster = form.Value["poster"][0]
}
data := ""
if len(form.Value["data"]) > 0 {
data = form.Value["data"][0]
}
var files []*multipart.FileHeader
for _, fh := range form.File {
files = append(files, fh...)
}
var stats []*state.TorrentStatus
for _, fh := range files {
log.TLogln("add .torrent", fh.Filename)
torrFile, err := fh.Open()
if err != nil {
log.TLogln("error upload torrent:", err)
continue
}
spec, err := utils.ParseFile(torrFile)
torrFile.Close()
if err != nil {
log.TLogln("error upload torrent:", err)
continue
}
tor, err := torr.AddTorrent(spec, title, poster, data, category)
if err != nil {
log.TLogln("error upload torrent:", err)
continue
}
if tor.Data != "" && set.BTsets.EnableDebug {
log.TLogln("torrent data:", tor.Data)
}
if tor.Category != "" && set.BTsets.EnableDebug {
log.TLogln("torrent category:", tor.Category)
}
go func(t *torr.Torrent) {
if !t.GotInfo() {
log.TLogln("error add torrent:", "torrent connection timeout")
return
}
if t.Title == "" {
t.Title = t.Name()
}
if save {
torr.SaveTorrentToDB(t)
}
}(tor)
stats = append(stats, tor.Status())
}
c.JSON(200, stats)
}
+183
View File
@@ -0,0 +1,183 @@
package utils
import (
"bytes"
"errors"
"fmt"
"mime/multipart"
"net/http"
"net/url"
"runtime"
"server/torrshash"
"strings"
"time"
"github.com/anacrolix/torrent"
"github.com/anacrolix/torrent/metainfo"
)
func ParseFromBytes(data []byte) (*torrent.TorrentSpec, error) {
minfo, err := metainfo.Load(bytes.NewReader(data))
if err != nil {
return nil, err
}
info, err := minfo.UnmarshalInfo()
if err != nil {
return nil, err
}
mag := minfo.Magnet(nil, &info)
return &torrent.TorrentSpec{
InfoBytes: minfo.InfoBytes,
Trackers: [][]string{mag.Trackers},
DisplayName: info.Name,
InfoHash: minfo.HashInfoBytes(),
}, nil
}
func ParseFile(file multipart.File) (*torrent.TorrentSpec, error) {
minfo, err := metainfo.Load(file)
if err != nil {
return nil, err
}
info, err := minfo.UnmarshalInfo()
if err != nil {
return nil, err
}
// mag := minfo.Magnet(info.Name, minfo.HashInfoBytes())
mag := minfo.Magnet(nil, &info)
return &torrent.TorrentSpec{
InfoBytes: minfo.InfoBytes,
Trackers: [][]string{mag.Trackers},
DisplayName: info.Name,
InfoHash: minfo.HashInfoBytes(),
}, nil
}
func ParseLink(link string) (*torrent.TorrentSpec, error) {
urlLink, err := url.Parse(link)
if err != nil {
return nil, err
}
switch strings.ToLower(urlLink.Scheme) {
case "magnet":
return fromMagnet(urlLink.String())
case "http", "https":
return fromHttp(urlLink.String())
case "":
return fromMagnet("magnet:?xt=urn:btih:" + urlLink.Path)
case "file":
return fromFile(urlLink.Path)
default:
err = fmt.Errorf("unknown scheme:", urlLink, urlLink.Scheme)
}
return nil, err
}
func fromMagnet(link string) (*torrent.TorrentSpec, error) {
mag, err := metainfo.ParseMagnetUri(link)
if err != nil {
return nil, err
}
var trackers [][]string
if len(mag.Trackers) > 0 {
trackers = [][]string{mag.Trackers}
}
return &torrent.TorrentSpec{
InfoBytes: nil,
Trackers: trackers,
DisplayName: mag.DisplayName,
InfoHash: mag.InfoHash,
}, nil
}
func ParseTorrsHash(token string) (*torrent.TorrentSpec, *torrshash.TorrsHash, error) {
if strings.HasPrefix(token, "torrs://") {
token = strings.TrimPrefix(token, "torrs://")
}
th, err := torrshash.Unpack(token)
if err != nil {
return nil, nil, err
}
var trackers [][]string
if len(th.Trackers()) > 0 {
trackers = [][]string{th.Trackers()}
}
return &torrent.TorrentSpec{
InfoBytes: nil,
Trackers: trackers,
DisplayName: th.Title(),
InfoHash: metainfo.NewHashFromHex(th.Hash),
}, th, nil
}
func fromHttp(link string) (*torrent.TorrentSpec, error) {
req, err := http.NewRequest("GET", link, nil)
if err != nil {
return nil, err
}
client := new(http.Client)
client.Timeout = time.Duration(time.Second * 60)
req.Header.Set("User-Agent", "DWL/1.1.1 (Torrent)")
resp, err := client.Do(req)
if er, ok := err.(*url.Error); ok {
if strings.HasPrefix(er.URL, "magnet:") {
return fromMagnet(er.URL)
}
}
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, errors.New(resp.Status)
}
minfo, err := metainfo.Load(resp.Body)
if err != nil {
return nil, err
}
info, err := minfo.UnmarshalInfo()
if err != nil {
return nil, err
}
// mag := minfo.Magnet(info.Name, minfo.HashInfoBytes())
mag := minfo.Magnet(nil, &info)
return &torrent.TorrentSpec{
InfoBytes: minfo.InfoBytes,
Trackers: [][]string{mag.Trackers},
DisplayName: info.Name,
InfoHash: minfo.HashInfoBytes(),
}, nil
}
func fromFile(path string) (*torrent.TorrentSpec, error) {
if runtime.GOOS == "windows" && strings.HasPrefix(path, "/") {
path = strings.TrimPrefix(path, "/")
}
minfo, err := metainfo.LoadFromFile(path)
if err != nil {
return nil, err
}
info, err := minfo.UnmarshalInfo()
if err != nil {
return nil, err
}
// mag := minfo.Magnet(info.Name, minfo.HashInfoBytes())
mag := minfo.Magnet(nil, &info)
return &torrent.TorrentSpec{
InfoBytes: minfo.InfoBytes,
Trackers: [][]string{mag.Trackers},
DisplayName: info.Name,
InfoHash: minfo.HashInfoBytes(),
}, nil
}
+27
View File
@@ -0,0 +1,27 @@
package utils
import (
"fmt"
"net/url"
)
func TestLink(link string, auth bool) error {
link, err := url.QueryUnescape(link)
if err != nil {
return err
}
ur, err := url.Parse(link)
if err != nil {
return err
}
if ur.Scheme == "magnet" || ur.Scheme == "torrs" || ur.Scheme == "" {
return nil
}
if !auth {
return fmt.Errorf("auth required")
}
return nil
}
+71
View File
@@ -0,0 +1,71 @@
package api
import (
"net/http"
sets "server/settings"
"github.com/gin-gonic/gin"
)
/*
file index starts from 1
*/
// Action: set, rem, list
type viewedReqJS struct {
requestI
*sets.Viewed
}
// viewed godoc
//
// @Summary Set / List / Remove viewed torrents
// @Description Allow to set, list or remove viewed torrents from server.
//
// @Tags API
//
// @Param request body viewedReqJS true "Viewed torrent request. Available params for action: set, rem, list"
//
// @Accept json
// @Produce json
// @Success 200 {array} sets.Viewed
// @Router /viewed [post]
func viewed(c *gin.Context) {
var req viewedReqJS
err := c.ShouldBindJSON(&req)
if err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}
switch req.Action {
case "set":
{
setViewed(req, c)
}
case "rem":
{
remViewed(req, c)
}
case "list":
{
listViewed(req, c)
}
}
}
func setViewed(req viewedReqJS, c *gin.Context) {
sets.SetViewed(req.Viewed)
c.Status(200)
}
func remViewed(req viewedReqJS, c *gin.Context) {
sets.RemViewed(req.Viewed)
c.Status(200)
}
func listViewed(req viewedReqJS, c *gin.Context) {
list := sets.ListViewed(req.Hash)
c.JSON(200, list)
}