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
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:
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,493 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"server/torr/utils"
|
||||
|
||||
"github.com/alexflint/go-arg"
|
||||
"github.com/pkg/browser"
|
||||
|
||||
"server"
|
||||
"server/docs"
|
||||
"server/log"
|
||||
"server/settings"
|
||||
"server/torr"
|
||||
"server/version"
|
||||
)
|
||||
|
||||
type args struct {
|
||||
Port string `arg:"-p" help:"web server port (default 8090)"`
|
||||
IP string `arg:"-i" help:"web server addr (default empty)"`
|
||||
Ssl bool `help:"enables https"`
|
||||
SslPort string `help:"web server ssl port, If not set, will be set to default 8091 or taken from db(if stored previously). Accepted if --ssl enabled."`
|
||||
SslCert string `help:"path to ssl cert file. If not set, will be taken from db(if stored previously) or default self-signed certificate/key will be generated. Accepted if --ssl enabled."`
|
||||
SslKey string `help:"path to ssl key file. If not set, will be taken from db(if stored previously) or default self-signed certificate/key will be generated. Accepted if --ssl enabled."`
|
||||
Path string `arg:"-d" help:"database and config dir path"`
|
||||
LogPath string `arg:"-l" help:"server log file path"`
|
||||
WebLogPath string `arg:"-w" help:"web access log file path"`
|
||||
RDB bool `arg:"-r" help:"start in read-only DB mode"`
|
||||
HttpAuth bool `arg:"-a" help:"enable http auth on all requests"`
|
||||
DontKill bool `arg:"-k" help:"don't kill server on signal"`
|
||||
UI bool `arg:"-u" help:"open torrserver page in browser"`
|
||||
TorrentsDir string `arg:"-t" help:"autoload torrents from dir"`
|
||||
TorrentAddr string `help:"Torrent client address, like 127.0.0.1:1337 (default :PeersListenPort)"`
|
||||
PubIPv4 string `arg:"-4" help:"set public IPv4 addr"`
|
||||
PubIPv6 string `arg:"-6" help:"set public IPv6 addr"`
|
||||
SearchWA bool `arg:"-s" help:"search without auth"`
|
||||
MaxSize string `arg:"-m" help:"max allowed stream size (in Bytes)"`
|
||||
TGToken string `arg:"-T" help:"telegram bot token"`
|
||||
FusePath string `arg:"-f" help:"fuse mount path"`
|
||||
WebDAV bool `help:"web dav enable"`
|
||||
ProxyURL string `help:"proxy URL for BitTorrent traffic (http, socks4, socks5, socks5h), e.g. socks5://user:password@127.0.0.1:8080"`
|
||||
ProxyMode string `help:"proxy mode: tracker (only HTTP trackers, default), peers (only peer connections), or full (all traffic)"`
|
||||
ForceHTTPS bool `arg:"--force-https" help:"redirect all HTTP requests to HTTPS (requires --ssl)"`
|
||||
}
|
||||
|
||||
func (args) Version() string {
|
||||
return "TorrServer " + version.Version
|
||||
}
|
||||
|
||||
var params args
|
||||
|
||||
func main() {
|
||||
runtime.GOMAXPROCS(runtime.NumCPU())
|
||||
|
||||
arg.MustParse(¶ms)
|
||||
|
||||
if params.Path == "" {
|
||||
params.Path, _ = os.Getwd()
|
||||
}
|
||||
|
||||
if params.Port == "" {
|
||||
params.Port = "8090"
|
||||
}
|
||||
|
||||
settings.Path = params.Path
|
||||
settings.HttpAuth = params.HttpAuth
|
||||
log.Init(params.LogPath, params.WebLogPath)
|
||||
|
||||
fmt.Println("=========== START ===========")
|
||||
fmt.Println("TorrServer", version.Version+",", runtime.Version()+",", "CPU Num:", runtime.NumCPU())
|
||||
if params.HttpAuth {
|
||||
log.TLogln("Use HTTP Auth file", settings.Path+"/accs.db")
|
||||
}
|
||||
if params.RDB {
|
||||
log.TLogln("Running in Read-only DB mode!")
|
||||
}
|
||||
docs.SwaggerInfo.Version = version.Version
|
||||
|
||||
// Simple Usage:
|
||||
dnsResolve()
|
||||
|
||||
// Advanced Usage:
|
||||
// config := DNSConfig{
|
||||
// PrimaryServers: []string{"1.1.1.1:53", "8.8.8.8:53"},
|
||||
// Timeout: 3 * time.Second,
|
||||
// }
|
||||
// checker := NewDNSChecker(config)
|
||||
// // Perform DNS lookup with automatic fallback
|
||||
// addrs, err := checker.LookupHostWithFallback("themoviedb.org")
|
||||
// if err != nil {
|
||||
// log.TLogln("DNS lookup failed:", err)
|
||||
// } else {
|
||||
// fmt.Println("DNS resolved:", addrs)
|
||||
// }
|
||||
|
||||
Preconfig(params.DontKill)
|
||||
|
||||
if params.UI {
|
||||
go func() {
|
||||
time.Sleep(time.Second)
|
||||
if params.Ssl {
|
||||
browser.OpenURL("https://127.0.0.1:" + params.SslPort)
|
||||
} else {
|
||||
browser.OpenURL("http://127.0.0.1:" + params.Port)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
if params.TorrentAddr != "" {
|
||||
settings.TorAddr = params.TorrentAddr
|
||||
}
|
||||
|
||||
if params.PubIPv4 != "" {
|
||||
settings.PubIPv4 = params.PubIPv4
|
||||
}
|
||||
|
||||
if params.PubIPv6 != "" {
|
||||
settings.PubIPv6 = params.PubIPv6
|
||||
}
|
||||
|
||||
if params.TorrentsDir != "" {
|
||||
go watchTDir(params.TorrentsDir)
|
||||
}
|
||||
|
||||
if params.MaxSize != "" {
|
||||
maxSize, err := strconv.ParseInt(params.MaxSize, 10, 64)
|
||||
if err == nil {
|
||||
settings.MaxSize = maxSize
|
||||
}
|
||||
}
|
||||
|
||||
if params.ProxyURL != "" && params.ProxyMode == "" {
|
||||
params.ProxyMode = "tracker" // default
|
||||
}
|
||||
if params.ProxyMode != "" && params.ProxyMode != "tracker" && params.ProxyMode != "peers" && params.ProxyMode != "full" {
|
||||
log.TLogln("Invalid proxy mode, using default 'tracker'")
|
||||
params.ProxyMode = "tracker"
|
||||
}
|
||||
|
||||
settings.Args = &settings.ExecArgs{
|
||||
Port: params.Port,
|
||||
IP: params.IP,
|
||||
Ssl: params.Ssl,
|
||||
SslPort: params.SslPort,
|
||||
SslCert: params.SslCert,
|
||||
SslKey: params.SslKey,
|
||||
Path: params.Path,
|
||||
LogPath: params.LogPath,
|
||||
WebLogPath: params.WebLogPath,
|
||||
RDB: params.RDB,
|
||||
HttpAuth: params.HttpAuth,
|
||||
DontKill: params.DontKill,
|
||||
UI: params.UI,
|
||||
TorrentsDir: params.TorrentsDir,
|
||||
TorrentAddr: params.TorrentAddr,
|
||||
PubIPv4: params.PubIPv4,
|
||||
PubIPv6: params.PubIPv6,
|
||||
SearchWA: params.SearchWA,
|
||||
MaxSize: params.MaxSize,
|
||||
TGToken: params.TGToken,
|
||||
FusePath: params.FusePath,
|
||||
WebDAV: params.WebDAV,
|
||||
ProxyURL: params.ProxyURL,
|
||||
ProxyMode: params.ProxyMode,
|
||||
ForceHTTPS: params.ForceHTTPS,
|
||||
}
|
||||
|
||||
if params.ProxyURL != "" {
|
||||
log.TLogln("Proxy configured from CLI:", params.ProxyURL, "mode:", settings.Args.ProxyMode)
|
||||
}
|
||||
|
||||
if params.ForceHTTPS && !params.Ssl {
|
||||
log.TLogln("Error: --force-https requires --ssl")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
server.Start()
|
||||
log.TLogln(server.WaitServer())
|
||||
log.Close()
|
||||
time.Sleep(time.Second * 3)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
func watchTDir(dir string) {
|
||||
time.Sleep(5 * time.Second)
|
||||
path, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
path = dir
|
||||
}
|
||||
for {
|
||||
files, err := os.ReadDir(path)
|
||||
if err == nil {
|
||||
for _, file := range files {
|
||||
filename := filepath.Join(path, file.Name())
|
||||
if strings.ToLower(filepath.Ext(file.Name())) == ".torrent" {
|
||||
sp, err := utils.OpenTorrentFile(filename)
|
||||
if err == nil {
|
||||
tor, err := torr.AddTorrent(sp, "", "", "", "")
|
||||
if err == nil {
|
||||
if tor.GotInfo() {
|
||||
if tor.Title == "" {
|
||||
tor.Title = tor.Name()
|
||||
}
|
||||
torr.SaveTorrentToDB(tor)
|
||||
tor.Drop()
|
||||
os.Remove(filename)
|
||||
time.Sleep(time.Second)
|
||||
} else {
|
||||
log.TLogln("Error get info from torrent")
|
||||
}
|
||||
} else {
|
||||
log.TLogln("Error parse torrent file:", err)
|
||||
}
|
||||
} else {
|
||||
log.TLogln("Error parse file name:", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.TLogln("Error read dir:", err)
|
||||
}
|
||||
time.Sleep(time.Second * 5)
|
||||
}
|
||||
}
|
||||
|
||||
///////////
|
||||
/// DNS
|
||||
///
|
||||
|
||||
// DNSConfig holds DNS resolver configuration
|
||||
type DNSConfig struct {
|
||||
PrimaryServers []string
|
||||
FallbackServers []string
|
||||
Timeout time.Duration
|
||||
CacheDuration time.Duration
|
||||
}
|
||||
|
||||
// DefaultDNSConfig returns a sensible default configuration
|
||||
func DefaultDNSConfig() DNSConfig {
|
||||
return DNSConfig{
|
||||
PrimaryServers: []string{
|
||||
"8.8.8.8:53", // Google DNS
|
||||
"1.1.1.1:53", // CloudFlare DNS
|
||||
"9.9.9.9:53", // Quad9 DNS
|
||||
},
|
||||
FallbackServers: []string{
|
||||
"208.67.222.222:53", // OpenDNS
|
||||
"64.6.64.6:53", // Verisign
|
||||
},
|
||||
Timeout: 5 * time.Second,
|
||||
CacheDuration: 5 * time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
// DNSChecker manages DNS resolution with fallback support
|
||||
type DNSChecker struct {
|
||||
config DNSConfig
|
||||
customResolver *net.Resolver
|
||||
cache map[string][]string
|
||||
cacheTime map[string]time.Time
|
||||
mu sync.RWMutex
|
||||
useFallback bool
|
||||
}
|
||||
|
||||
// NewDNSChecker creates a new DNS checker instance
|
||||
func NewDNSChecker(config DNSConfig) *DNSChecker {
|
||||
if len(config.PrimaryServers) == 0 {
|
||||
config = DefaultDNSConfig()
|
||||
}
|
||||
|
||||
return &DNSChecker{
|
||||
config: config,
|
||||
cache: make(map[string][]string),
|
||||
cacheTime: make(map[string]time.Time),
|
||||
}
|
||||
}
|
||||
|
||||
// CheckAndResolve performs DNS check and returns a resolver
|
||||
func (d *DNSChecker) CheckAndResolve() *net.Resolver {
|
||||
// Test system DNS first
|
||||
if d.testSystemDNS() {
|
||||
log.TLogln("System DNS check passed")
|
||||
return net.DefaultResolver
|
||||
}
|
||||
|
||||
log.TLogln("System DNS check failed, using custom resolver")
|
||||
d.initCustomResolver()
|
||||
return d.customResolver
|
||||
}
|
||||
|
||||
// testSystemDNS checks if system DNS is working properly
|
||||
func (d *DNSChecker) testSystemDNS() bool {
|
||||
_, cancel := context.WithTimeout(context.Background(), d.config.Timeout)
|
||||
defer cancel()
|
||||
|
||||
addrs, err := net.LookupHost("themoviedb.org")
|
||||
if err != nil {
|
||||
log.TLogln("DNS lookup error:", err)
|
||||
return false
|
||||
}
|
||||
|
||||
if len(addrs) == 0 {
|
||||
log.TLogln("DNS lookup returned no addresses")
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for suspicious addresses (DNS hijacking/pollution)
|
||||
for _, addr := range addrs {
|
||||
if isSuspiciousAddress(addr) {
|
||||
log.TLogln("Suspicious DNS address detected:", addr)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// isSuspiciousAddress checks if an address indicates DNS issues
|
||||
func isSuspiciousAddress(addr string) bool {
|
||||
suspiciousPrefixes := []string{
|
||||
"127.0.0.1", // Localhost
|
||||
"0.0.0.0", // Invalid address
|
||||
"::1", // IPv6 localhost
|
||||
// "10.", // Private network
|
||||
"192.168.", // Private network
|
||||
"169.254.", // Link-local
|
||||
// "172.16.", "172.17.", "172.18.", "172.19.",
|
||||
// "172.20.", "172.21.", "172.22.", "172.23.",
|
||||
// "172.24.", "172.25.", "172.26.", "172.27.",
|
||||
// "172.28.", "172.29.", "172.30.", "172.31.", // Private network range
|
||||
}
|
||||
|
||||
for _, prefix := range suspiciousPrefixes {
|
||||
if strings.HasPrefix(addr, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// initCustomResolver creates a custom resolver with fallback support
|
||||
func (d *DNSChecker) initCustomResolver() {
|
||||
d.customResolver = &net.Resolver{
|
||||
PreferGo: true, // Use Go's DNS implementation
|
||||
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
dialer := &net.Dialer{
|
||||
Timeout: d.config.Timeout,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}
|
||||
|
||||
// Try primary servers first
|
||||
for _, dns := range d.config.PrimaryServers {
|
||||
conn, err := dialer.DialContext(ctx, network, dns)
|
||||
if err == nil {
|
||||
return conn, nil
|
||||
}
|
||||
log.TLogln("Failed to connect to DNS server", dns, ":", err)
|
||||
}
|
||||
|
||||
// Try fallback servers if primary fails
|
||||
for _, dns := range d.config.FallbackServers {
|
||||
conn, err := dialer.DialContext(ctx, network, dns)
|
||||
if err == nil {
|
||||
log.TLogln("Using fallback DNS server:", dns)
|
||||
return conn, nil
|
||||
}
|
||||
log.TLogln("Failed to connect to fallback DNS", dns, ":", err)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("all DNS servers failed")
|
||||
},
|
||||
}
|
||||
|
||||
d.useFallback = true
|
||||
}
|
||||
|
||||
// LookupHostWithFallback performs DNS lookup with automatic fallback
|
||||
func (d *DNSChecker) LookupHostWithFallback(host string) ([]string, error) {
|
||||
// Check cache first
|
||||
if addrs, ok := d.getFromCache(host); ok {
|
||||
return addrs, nil
|
||||
}
|
||||
|
||||
// Use appropriate resolver
|
||||
var resolver *net.Resolver
|
||||
if d.useFallback && d.customResolver != nil {
|
||||
resolver = d.customResolver
|
||||
} else {
|
||||
resolver = net.DefaultResolver
|
||||
}
|
||||
|
||||
// Perform lookup with timeout
|
||||
ctx, cancel := context.WithTimeout(context.Background(), d.config.Timeout)
|
||||
defer cancel()
|
||||
|
||||
addrs, err := resolver.LookupHost(ctx, host)
|
||||
if err != nil {
|
||||
// If using system DNS fails, try custom resolver
|
||||
if !d.useFallback && d.customResolver != nil {
|
||||
log.TLogln("System DNS failed, trying custom resolver")
|
||||
addrs, err = d.customResolver.LookupHost(ctx, host)
|
||||
}
|
||||
}
|
||||
|
||||
// Cache successful results
|
||||
if err == nil && len(addrs) > 0 {
|
||||
d.addToCache(host, addrs)
|
||||
}
|
||||
|
||||
return addrs, err
|
||||
}
|
||||
|
||||
// getFromCache retrieves DNS results from cache
|
||||
func (d *DNSChecker) getFromCache(host string) ([]string, bool) {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
|
||||
if addrs, ok := d.cache[host]; ok {
|
||||
if time.Since(d.cacheTime[host]) < d.config.CacheDuration {
|
||||
return addrs, true
|
||||
}
|
||||
// Expired, remove from cache
|
||||
delete(d.cache, host)
|
||||
delete(d.cacheTime, host)
|
||||
}
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// addToCache adds DNS results to cache
|
||||
func (d *DNSChecker) addToCache(host string, addrs []string) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
d.cache[host] = addrs
|
||||
d.cacheTime[host] = time.Now()
|
||||
}
|
||||
|
||||
// Simple usage function (backward compatible)
|
||||
func dnsResolve() {
|
||||
checker := NewDNSChecker(DefaultDNSConfig())
|
||||
resolver := checker.CheckAndResolve()
|
||||
|
||||
// Store the resolver for later use if needed
|
||||
net.DefaultResolver = resolver // Optional: replace global resolver
|
||||
|
||||
// Test the resolver
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
addrs, err := resolver.LookupHost(ctx, "themoviedb.org")
|
||||
if err != nil {
|
||||
log.TLogln("DNS resolution test failed:", err)
|
||||
} else {
|
||||
log.TLogln("DNS resolution successful, addresses:", addrs)
|
||||
}
|
||||
}
|
||||
|
||||
// func dnsResolve() {
|
||||
// addrs, err := net.LookupHost("themoviedb.org")
|
||||
// if len(addrs) == 0 {
|
||||
// log.TLogln("System DNS check failed", err)
|
||||
|
||||
// fn := func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
// d := net.Dialer{}
|
||||
// return d.DialContext(ctx, "udp", "1.1.1.1:53")
|
||||
// }
|
||||
|
||||
// net.DefaultResolver = &net.Resolver{
|
||||
// Dial: fn,
|
||||
// }
|
||||
|
||||
// addrs, err = net.LookupHost("themoviedb.org")
|
||||
// if err != nil {
|
||||
// log.TLogln("Check CloudFlare DNS error:", err)
|
||||
// } else {
|
||||
// log.TLogln("Use CloudFlare DNS")
|
||||
// }
|
||||
// } else {
|
||||
// log.TLogln("System DNS check passed")
|
||||
// }
|
||||
// }
|
||||
@@ -0,0 +1,55 @@
|
||||
//go:build android
|
||||
// +build android
|
||||
|
||||
package main
|
||||
|
||||
// #cgo LDFLAGS: -static-libstdc++
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"server"
|
||||
"server/log"
|
||||
"server/settings"
|
||||
)
|
||||
|
||||
func Preconfig(dkill bool) {
|
||||
sigc := make(chan os.Signal, 1)
|
||||
signal.Notify(sigc,
|
||||
syscall.SIGHUP,
|
||||
syscall.SIGINT,
|
||||
syscall.SIGTERM,
|
||||
syscall.SIGQUIT)
|
||||
go func() {
|
||||
for s := range sigc {
|
||||
if dkill {
|
||||
if settings.BTsets.EnableDebug || s != syscall.SIGPIPE {
|
||||
log.TLogln("Signal catched:", s)
|
||||
log.TLogln("To stop server, close it from web / api")
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
log.TLogln("Signal catched:", s, "stopping server...")
|
||||
|
||||
done := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
server.Stop()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
log.TLogln("Server stopped gracefully")
|
||||
case <-time.After(5 * time.Second):
|
||||
log.TLogln("Server stop timeout, exiting forcefully")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//go:build !windows && !android
|
||||
// +build !windows,!android
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"server"
|
||||
|
||||
"server/log"
|
||||
"server/settings"
|
||||
)
|
||||
|
||||
func Preconfig(dkill bool) {
|
||||
sigc := make(chan os.Signal, 1)
|
||||
signal.Notify(sigc,
|
||||
syscall.SIGHUP,
|
||||
syscall.SIGINT,
|
||||
syscall.SIGTERM,
|
||||
syscall.SIGQUIT)
|
||||
go func() {
|
||||
for s := range sigc {
|
||||
if dkill {
|
||||
if settings.BTsets.EnableDebug || s != syscall.SIGPIPE {
|
||||
log.TLogln("Signal catched:", s)
|
||||
log.TLogln("To stop server, close it from web / api")
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
log.TLogln("Signal catched:", s, "stopping server...")
|
||||
|
||||
done := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
server.Stop()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
log.TLogln("Server stopped gracefully")
|
||||
case <-time.After(5 * time.Second):
|
||||
log.TLogln("Server stop timeout, exiting forcefully")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
//go:build windows
|
||||
// +build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"server/torr"
|
||||
"server/torr/state"
|
||||
)
|
||||
|
||||
const (
|
||||
EsSystemRequired = 0x00000001
|
||||
EsAwaymodeRequired = 0x00000040 // Added for future improvements
|
||||
EsContinuous = 0x80000000
|
||||
)
|
||||
|
||||
var (
|
||||
pulseTime = 60 * time.Second
|
||||
clearFlagTimeout = 3 * 60 * time.Second
|
||||
)
|
||||
|
||||
func Preconfig(kill bool) {
|
||||
go func() {
|
||||
// need work on one thread because SetThreadExecutionState sets flag to thread. We need set and clear flag for same thread.
|
||||
runtime.LockOSThread()
|
||||
// don't sleep/hibernate windows
|
||||
kernel32 := syscall.NewLazyDLL("kernel32.dll")
|
||||
setThreadExecStateProc := kernel32.NewProc("SetThreadExecutionState")
|
||||
currentExecState := uintptr(EsContinuous)
|
||||
normalExecutionState := uintptr(EsContinuous)
|
||||
systemRequireState := uintptr(EsSystemRequired | EsContinuous)
|
||||
pulse := time.NewTicker(pulseTime)
|
||||
var clearFlagTime int64 = -1
|
||||
for {
|
||||
select {
|
||||
case <-pulse.C:
|
||||
{
|
||||
systemRequired := false
|
||||
for _, torrent := range torr.ListTorrent() {
|
||||
if torrent.Stat != state.TorrentInDB {
|
||||
systemRequired = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if systemRequired && currentExecState != systemRequireState {
|
||||
// Looks like sending just EsSystemRequired to clear timer is broken in Win11.
|
||||
// Enable system required to avoid the system to idle to sleep.
|
||||
currentExecState = systemRequireState
|
||||
setThreadExecStateProc.Call(systemRequireState)
|
||||
}
|
||||
|
||||
if !systemRequired && currentExecState != normalExecutionState {
|
||||
// Clear EXECUTION_STATE flags to disable away mode and allow the system to idle to sleep normally.
|
||||
|
||||
// Avoid clear flag immediately to add time to start next episode
|
||||
if clearFlagTime == -1 {
|
||||
clearFlagTime = time.Now().Unix() + int64(clearFlagTimeout.Seconds())
|
||||
}
|
||||
|
||||
if clearFlagTime >= time.Now().Unix() {
|
||||
clearFlagTime = -1
|
||||
currentExecState = normalExecutionState
|
||||
setThreadExecStateProc.Call(normalExecutionState)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package dlna
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/anacrolix/dms/dlna/dms"
|
||||
"github.com/anacrolix/log"
|
||||
"github.com/wlynxg/anet"
|
||||
|
||||
"server/settings"
|
||||
"server/web/pages/template"
|
||||
)
|
||||
|
||||
var dmsServer *dms.Server
|
||||
|
||||
func Start() {
|
||||
logger := log.Default.WithNames("dlna")
|
||||
dmsServer = &dms.Server{
|
||||
Logger: logger.WithNames("dms", "server"),
|
||||
Interfaces: func() (ifs []net.Interface) {
|
||||
var err error
|
||||
ifaces, err := anet.Interfaces()
|
||||
if err != nil {
|
||||
logger.Levelf(log.Error, "%v", err)
|
||||
return
|
||||
// os.Exit(1) // avoid start on Android 13+
|
||||
}
|
||||
for _, i := range ifaces {
|
||||
// interface flags seem to always be 0 on Windows
|
||||
if runtime.GOOS != "windows" && (i.Flags&net.FlagLoopback != 0 || i.Flags&net.FlagUp == 0 || i.Flags&net.FlagMulticast == 0) {
|
||||
continue
|
||||
}
|
||||
ifs = append(ifs, i)
|
||||
}
|
||||
return
|
||||
}(),
|
||||
HTTPConn: func() net.Listener {
|
||||
port := 9080
|
||||
for {
|
||||
logger.Levelf(log.Info, "Check dlna port %d", port)
|
||||
m, err := net.Listen("tcp", settings.IP+":"+strconv.Itoa(port))
|
||||
if m != nil {
|
||||
m.Close()
|
||||
}
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
port++
|
||||
}
|
||||
logger.Levelf(log.Info, "Set dlna port %d", port)
|
||||
conn, err := net.Listen("tcp", settings.IP+":"+strconv.Itoa(port))
|
||||
if err != nil {
|
||||
logger.Levelf(log.Error, "%v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return conn
|
||||
}(),
|
||||
FriendlyName: getDefaultFriendlyName(),
|
||||
NoTranscode: true,
|
||||
NoProbe: true,
|
||||
StallEventSubscribe: false,
|
||||
Icons: []dms.Icon{
|
||||
{
|
||||
Width: 48,
|
||||
Height: 48,
|
||||
Depth: 24,
|
||||
Mimetype: "image/png",
|
||||
Bytes: template.Dlnaicon48png,
|
||||
},
|
||||
{
|
||||
Width: 120,
|
||||
Height: 120,
|
||||
Depth: 24,
|
||||
Mimetype: "image/png",
|
||||
Bytes: template.Dlnaicon120png,
|
||||
},
|
||||
},
|
||||
LogHeaders: settings.BTsets.EnableDebug,
|
||||
NotifyInterval: 30 * time.Second,
|
||||
AllowedIpNets: func() []*net.IPNet {
|
||||
var nets []*net.IPNet
|
||||
_, ipnet, _ := net.ParseCIDR("0.0.0.0/0")
|
||||
nets = append(nets, ipnet)
|
||||
_, ipnet, _ = net.ParseCIDR("::/0")
|
||||
nets = append(nets, ipnet)
|
||||
return nets
|
||||
}(),
|
||||
OnBrowseDirectChildren: onBrowse,
|
||||
OnBrowseMetadata: onBrowseMeta,
|
||||
}
|
||||
|
||||
if err := dmsServer.Init(); err != nil {
|
||||
logger.Levelf(log.Error, "error initing dms server: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
go func() {
|
||||
if err := dmsServer.Run(); err != nil {
|
||||
logger.Levelf(log.Error, "%v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func Stop() {
|
||||
if dmsServer != nil {
|
||||
dmsServer.Close()
|
||||
dmsServer = nil
|
||||
}
|
||||
}
|
||||
|
||||
func onBrowse(path, rootObjectPath, host, userAgent string) (ret []interface{}, err error) {
|
||||
if path == "/" {
|
||||
ret = getRoot()
|
||||
return
|
||||
} else if path == "/TR" {
|
||||
ret = getTorrents()
|
||||
return
|
||||
} else if isHashPath(path) {
|
||||
ret = getTorrent(path, host)
|
||||
return
|
||||
} else if filepath.Base(path) == "LD" {
|
||||
ret = loadTorrent(path, host)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func onBrowseMeta(path string, rootObjectPath string, host, userAgent string) (ret interface{}, err error) {
|
||||
ret = getTorrentMeta(path, host)
|
||||
if ret == nil {
|
||||
err = fmt.Errorf("meta not found")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func getDefaultFriendlyName() string {
|
||||
logger := log.Default.WithNames("dlna")
|
||||
|
||||
if settings.BTsets.FriendlyName != "" {
|
||||
return settings.BTsets.FriendlyName
|
||||
}
|
||||
|
||||
ret := "TorrServer"
|
||||
userName := ""
|
||||
user, err := user.Current()
|
||||
if err != nil {
|
||||
logger.Printf("getDefaultFriendlyName could not get username: %s", err)
|
||||
} else {
|
||||
userName = user.Name
|
||||
}
|
||||
host, err := os.Hostname()
|
||||
if err != nil {
|
||||
logger.Printf("getDefaultFriendlyName could not get hostname: %s", err)
|
||||
}
|
||||
|
||||
if userName == "" && host == "" {
|
||||
return ret
|
||||
}
|
||||
|
||||
if userName != "" && host != "" {
|
||||
if userName == host {
|
||||
return ret + ": " + userName
|
||||
}
|
||||
return ret + ": " + userName + " on " + host
|
||||
}
|
||||
|
||||
if host == "localhost" { // useless host, use 1st IP
|
||||
ifaces, err := anet.Interfaces()
|
||||
if err != nil {
|
||||
return ret + ": " + userName + "@" + host
|
||||
}
|
||||
var list []string
|
||||
for _, i := range ifaces {
|
||||
// interface flags seem to always be 0 on Windows
|
||||
if runtime.GOOS != "windows" && (i.Flags&net.FlagLoopback != 0 || i.Flags&net.FlagUp == 0 || i.Flags&net.FlagMulticast == 0) {
|
||||
continue
|
||||
}
|
||||
addrs, _ := anet.InterfaceAddrsByInterface(&i)
|
||||
for _, addr := range addrs {
|
||||
var ip net.IP
|
||||
switch v := addr.(type) {
|
||||
case *net.IPNet:
|
||||
ip = v.IP
|
||||
case *net.IPAddr:
|
||||
ip = v.IP
|
||||
}
|
||||
if !ip.IsLoopback() && ip.To4() != nil {
|
||||
list = append(list, ip.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(list) > 0 {
|
||||
sort.Strings(list)
|
||||
return ret + " " + list[0]
|
||||
}
|
||||
}
|
||||
return ret + ": " + userName + "@" + host
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
package dlna
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/anacrolix/dms/dlna"
|
||||
"github.com/anacrolix/dms/upnpav"
|
||||
|
||||
"server/log"
|
||||
mt "server/mimetype"
|
||||
"server/settings"
|
||||
"server/torr"
|
||||
"server/torr/state"
|
||||
)
|
||||
|
||||
func getRoot() (ret []interface{}) {
|
||||
// Torrents Object (ROOT)
|
||||
tObj := upnpav.Object{
|
||||
ID: "%2FTR",
|
||||
ParentID: "0",
|
||||
Restricted: 1,
|
||||
Title: "Torrents",
|
||||
Class: "object.container.storageFolder",
|
||||
Date: upnpav.Timestamp{Time: time.Now()},
|
||||
}
|
||||
|
||||
// add Torrents Object
|
||||
vol := len(torr.ListTorrent())
|
||||
cnt := upnpav.Container{Object: tObj, ChildCount: vol}
|
||||
ret = append(ret, cnt)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func getTorrents() (ret []interface{}) {
|
||||
torrs := torr.ListTorrent()
|
||||
// sort by title as in cds SortCaps
|
||||
sort.Slice(torrs, func(i, j int) bool {
|
||||
return torrs[i].Title < torrs[j].Title
|
||||
})
|
||||
|
||||
vol := 0
|
||||
for _, t := range torrs {
|
||||
vol++
|
||||
obj := upnpav.Object{
|
||||
ID: "%2F" + t.TorrentSpec.InfoHash.HexString(),
|
||||
ParentID: "%2FTR",
|
||||
Restricted: 1,
|
||||
Title: strings.ReplaceAll(t.Title, "/", "|"),
|
||||
Class: "object.container.storageFolder",
|
||||
Icon: t.Poster,
|
||||
AlbumArtURI: t.Poster,
|
||||
Date: upnpav.Timestamp{Time: time.Unix(t.Timestamp, 0)}, // time.Now()
|
||||
}
|
||||
cnt := upnpav.Container{Object: obj, ChildCount: 1}
|
||||
ret = append(ret, cnt)
|
||||
}
|
||||
if vol == 0 {
|
||||
obj := upnpav.Object{
|
||||
ID: "%2FNT",
|
||||
ParentID: "%2FTR",
|
||||
Restricted: 1,
|
||||
Title: "No Torrents",
|
||||
Class: "object.container.storageFolder",
|
||||
Date: upnpav.Timestamp{Time: time.Now()},
|
||||
}
|
||||
cnt := upnpav.Container{Object: obj, ChildCount: 0}
|
||||
ret = append(ret, cnt)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func getTorrent(path, host string) (ret []interface{}) {
|
||||
// find torrent without load
|
||||
torrs := torr.ListTorrent()
|
||||
var torr *torr.Torrent
|
||||
for _, t := range torrs {
|
||||
if strings.Contains(path, t.TorrentSpec.InfoHash.HexString()) {
|
||||
torr = t
|
||||
break
|
||||
}
|
||||
}
|
||||
if torr == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// get content from torrent
|
||||
parent := "%2F" + torr.TorrentSpec.InfoHash.HexString()
|
||||
// if torrent not loaded, get button for load
|
||||
if torr.Files() == nil {
|
||||
obj := upnpav.Object{
|
||||
ID: parent + "%2FLD",
|
||||
ParentID: parent,
|
||||
Restricted: 1,
|
||||
Title: "Load Torrent",
|
||||
Class: "object.container.storageFolder",
|
||||
Date: upnpav.Timestamp{Time: time.Now()},
|
||||
}
|
||||
cnt := upnpav.Container{Object: obj, ChildCount: 1}
|
||||
ret = append(ret, cnt)
|
||||
return
|
||||
}
|
||||
|
||||
ret = loadTorrent(path, host)
|
||||
return
|
||||
}
|
||||
|
||||
func getTorrentMeta(path, host string) (ret interface{}) {
|
||||
// Meta object
|
||||
if path == "/" {
|
||||
// root object meta
|
||||
rootObj := upnpav.Object{
|
||||
ID: "0",
|
||||
ParentID: "-1",
|
||||
Restricted: 1,
|
||||
Searchable: 1,
|
||||
Title: "TorrServer",
|
||||
Date: upnpav.Timestamp{Time: time.Now()},
|
||||
Class: "object.container.storageFolder",
|
||||
}
|
||||
meta := upnpav.Container{Object: rootObj, ChildCount: 1}
|
||||
return meta
|
||||
} else if filepath.Base(path) == "TR" {
|
||||
// TR Object Meta
|
||||
trObj := upnpav.Object{
|
||||
ID: "%2FTR",
|
||||
ParentID: "0",
|
||||
Restricted: 1,
|
||||
Searchable: 1,
|
||||
Title: "Torrents",
|
||||
Date: upnpav.Timestamp{Time: time.Now()},
|
||||
Class: "object.container.storageFolder",
|
||||
}
|
||||
torrs := torr.ListTorrent()
|
||||
vol := len(torrs)
|
||||
meta := upnpav.Container{Object: trObj, ChildCount: vol}
|
||||
return meta
|
||||
} else if isHashPath(path) {
|
||||
// find torrent without load
|
||||
torrs := torr.ListTorrent()
|
||||
var torr *torr.Torrent
|
||||
for _, t := range torrs {
|
||||
if strings.Contains(path, t.TorrentSpec.InfoHash.HexString()) {
|
||||
torr = t
|
||||
break
|
||||
}
|
||||
}
|
||||
if torr == nil {
|
||||
return nil
|
||||
}
|
||||
// hash object meta
|
||||
obj := upnpav.Object{
|
||||
ID: "%2F" + torr.TorrentSpec.InfoHash.HexString(),
|
||||
ParentID: "%2FTR",
|
||||
Restricted: 1,
|
||||
Title: torr.Title,
|
||||
Date: upnpav.Timestamp{Time: time.Unix(torr.Timestamp, 0)}, // time.Now()
|
||||
}
|
||||
meta := upnpav.Container{Object: obj, ChildCount: 1}
|
||||
return meta
|
||||
} else if filepath.Base(path) == "LD" {
|
||||
parent := url.PathEscape(filepath.Dir(path))
|
||||
// LD object meta
|
||||
obj := upnpav.Object{
|
||||
ID: parent + "%2FLD",
|
||||
ParentID: parent,
|
||||
Restricted: 1,
|
||||
Searchable: 1,
|
||||
Title: "Load Torrents",
|
||||
Date: upnpav.Timestamp{Time: time.Now()},
|
||||
}
|
||||
meta := upnpav.Container{Object: obj, ChildCount: 1}
|
||||
return meta
|
||||
} else {
|
||||
file := filepath.Base(path)
|
||||
id := url.PathEscape(path)
|
||||
parent := url.PathEscape(filepath.Dir(path))
|
||||
// file object meta
|
||||
obj := upnpav.Object{
|
||||
ID: id,
|
||||
ParentID: parent,
|
||||
Restricted: 1,
|
||||
Searchable: 1,
|
||||
Title: file,
|
||||
Date: upnpav.Timestamp{Time: time.Now()},
|
||||
}
|
||||
meta := upnpav.Container{Object: obj, ChildCount: 1}
|
||||
return meta
|
||||
}
|
||||
}
|
||||
|
||||
func loadTorrent(path, host string) (ret []interface{}) {
|
||||
hash := filepath.Base(filepath.Dir(path))
|
||||
if hash == "/" || hash == "\\" {
|
||||
hash = filepath.Base(path)
|
||||
}
|
||||
if len(hash) != 40 {
|
||||
return
|
||||
}
|
||||
|
||||
tor := torr.GetTorrent(hash)
|
||||
if tor == nil {
|
||||
log.TLogln("Dlna error get info from torrent", hash)
|
||||
return
|
||||
}
|
||||
if len(tor.Files()) == 0 {
|
||||
time.Sleep(time.Millisecond * 200)
|
||||
timeout := time.Now().Add(time.Second * 60)
|
||||
for {
|
||||
tor = torr.GetTorrent(hash)
|
||||
if len(tor.Files()) > 0 {
|
||||
break
|
||||
}
|
||||
time.Sleep(time.Millisecond * 200)
|
||||
if time.Now().After(timeout) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
parent := "%2F" + tor.TorrentSpec.InfoHash.HexString()
|
||||
files := tor.Status().FileStats
|
||||
for _, f := range files {
|
||||
obj := getObjFromTorrent(path, parent, host, tor, f)
|
||||
if obj != nil {
|
||||
ret = append(ret, obj)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func getLink(host, path string) string {
|
||||
if !strings.HasPrefix(host, "http") {
|
||||
host = "http://" + host
|
||||
}
|
||||
pos := strings.LastIndex(host, ":")
|
||||
if pos > 7 {
|
||||
host = host[:pos]
|
||||
}
|
||||
return host + ":" + settings.Port + "/" + path
|
||||
}
|
||||
|
||||
func getObjFromTorrent(path, parent, host string, torr *torr.Torrent, file *state.TorrentFileStat) (ret interface{}) {
|
||||
mime, err := mt.MimeTypeByPath(file.Path)
|
||||
if err != nil {
|
||||
if settings.BTsets.EnableDebug {
|
||||
log.TLogln("Can't detect mime type", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
// TODO: handle subtitles for media
|
||||
if !mime.IsMedia() {
|
||||
return
|
||||
}
|
||||
if settings.BTsets.EnableDebug {
|
||||
log.TLogln("mime type", mime.String(), file.Path)
|
||||
}
|
||||
|
||||
obj := upnpav.Object{
|
||||
ID: parent + "%2F" + url.PathEscape(file.Path),
|
||||
ParentID: parent,
|
||||
Restricted: 1,
|
||||
Title: file.Path,
|
||||
Class: "object.item." + mime.Type() + "Item",
|
||||
Date: upnpav.Timestamp{Time: time.Now()},
|
||||
}
|
||||
|
||||
item := upnpav.Item{
|
||||
Object: obj,
|
||||
Res: make([]upnpav.Resource, 0, 1),
|
||||
}
|
||||
// pathPlay := "stream/" + url.PathEscape(file.Path) + "?link=" + torr.TorrentSpec.InfoHash.HexString() + "&play&index=" + strconv.Itoa(file.Id)
|
||||
pathPlay := "play/" + torr.TorrentSpec.InfoHash.HexString() + "/" + strconv.Itoa(file.Id)
|
||||
item.Res = append(item.Res, upnpav.Resource{
|
||||
URL: getLink(host, pathPlay),
|
||||
ProtocolInfo: fmt.Sprintf("http-get:*:%s:%s", mime, dlna.ContentFeatures{
|
||||
SupportRange: true,
|
||||
SupportTimeSeek: true,
|
||||
}.String()),
|
||||
Size: uint64(file.Length),
|
||||
})
|
||||
return item
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package dlna
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func isHashPath(path string) bool {
|
||||
base := filepath.Base(path)
|
||||
if len(base) == 40 {
|
||||
data := []byte(base)
|
||||
for _, v := range data {
|
||||
if !(v >= 48 && v <= 57 || v >= 65 && v <= 70 || v >= 97 && v <= 102) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
+1316
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,866 @@
|
||||
basePath: /
|
||||
definitions:
|
||||
api.cacheReqJS:
|
||||
properties:
|
||||
action:
|
||||
type: string
|
||||
hash:
|
||||
type: string
|
||||
type: object
|
||||
api.setsReqJS:
|
||||
properties:
|
||||
action:
|
||||
type: string
|
||||
sets:
|
||||
$ref: '#/definitions/settings.BTSets'
|
||||
type: object
|
||||
api.torrReqJS:
|
||||
properties:
|
||||
action:
|
||||
type: string
|
||||
category:
|
||||
type: string
|
||||
data:
|
||||
type: string
|
||||
filter:
|
||||
type: string
|
||||
hash:
|
||||
type: string
|
||||
link:
|
||||
type: string
|
||||
poster:
|
||||
type: string
|
||||
save_to_db:
|
||||
type: boolean
|
||||
title:
|
||||
type: string
|
||||
type: object
|
||||
api.viewedReqJS:
|
||||
properties:
|
||||
action:
|
||||
type: string
|
||||
file_index:
|
||||
type: integer
|
||||
hash:
|
||||
type: string
|
||||
type: object
|
||||
models.TorrentDetails:
|
||||
properties:
|
||||
audioQuality:
|
||||
type: integer
|
||||
categories:
|
||||
type: string
|
||||
createDate:
|
||||
type: string
|
||||
hash:
|
||||
type: string
|
||||
imdbid:
|
||||
type: string
|
||||
link:
|
||||
type: string
|
||||
magnet:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
names:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
peer:
|
||||
type: integer
|
||||
seed:
|
||||
type: integer
|
||||
size:
|
||||
type: string
|
||||
title:
|
||||
type: string
|
||||
tracker:
|
||||
type: string
|
||||
videoQuality:
|
||||
type: integer
|
||||
year:
|
||||
type: integer
|
||||
type: object
|
||||
settings.BTSets:
|
||||
properties:
|
||||
cacheSize:
|
||||
description: Cache
|
||||
format: int64
|
||||
type: integer
|
||||
connectionsLimit:
|
||||
type: integer
|
||||
disableDHT:
|
||||
type: boolean
|
||||
disablePEX:
|
||||
type: boolean
|
||||
disableTCP:
|
||||
type: boolean
|
||||
disableUPNP:
|
||||
type: boolean
|
||||
disableUTP:
|
||||
type: boolean
|
||||
disableUpload:
|
||||
type: boolean
|
||||
downloadRateLimit:
|
||||
description: in kb, 0 - inf
|
||||
type: integer
|
||||
enableDLNA:
|
||||
description: DLNA
|
||||
type: boolean
|
||||
enableDebug:
|
||||
description: debug logs
|
||||
type: boolean
|
||||
enableIPv6:
|
||||
description: BT Config
|
||||
type: boolean
|
||||
enableProxy:
|
||||
description: P2P Proxy
|
||||
type: boolean
|
||||
enableRutorSearch:
|
||||
description: Rutor
|
||||
type: boolean
|
||||
enableTorznabSearch:
|
||||
description: Torznab
|
||||
type: boolean
|
||||
forceEncrypt:
|
||||
description: Torrent
|
||||
type: boolean
|
||||
friendlyName:
|
||||
type: string
|
||||
peersListenPort:
|
||||
type: integer
|
||||
preloadCache:
|
||||
description: in percent
|
||||
type: integer
|
||||
proxyHosts:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
readerReadAHead:
|
||||
description: in percent, 5%-100%, [...S__X__E...] [S-E] not clean
|
||||
type: integer
|
||||
removeCacheOnDrop:
|
||||
type: boolean
|
||||
responsiveMode:
|
||||
description: Reader
|
||||
type: boolean
|
||||
retrackersMode:
|
||||
description: 0 - don`t add, 1 - add retrackers (def), 2 - remove retrackers
|
||||
3 - replace retrackers
|
||||
type: integer
|
||||
showFSActiveTorr:
|
||||
description: FS
|
||||
type: boolean
|
||||
sslCert:
|
||||
type: string
|
||||
sslKey:
|
||||
type: string
|
||||
sslPort:
|
||||
description: HTTPS
|
||||
type: integer
|
||||
storeSettingsInJson:
|
||||
description: Storage preferences
|
||||
type: boolean
|
||||
storeViewedInJson:
|
||||
type: boolean
|
||||
tmdbsettings:
|
||||
allOf:
|
||||
- $ref: '#/definitions/settings.TMDBConfig'
|
||||
description: TMDB
|
||||
torrentDisconnectTimeout:
|
||||
description: in seconds
|
||||
type: integer
|
||||
torrentsSavePath:
|
||||
type: string
|
||||
torznabUrls:
|
||||
items:
|
||||
$ref: '#/definitions/settings.TorznabConfig'
|
||||
type: array
|
||||
uploadRateLimit:
|
||||
description: in kb, 0 - inf
|
||||
type: integer
|
||||
useDisk:
|
||||
description: Disk
|
||||
type: boolean
|
||||
type: object
|
||||
settings.TMDBConfig:
|
||||
properties:
|
||||
apikey:
|
||||
description: TMDB API Key
|
||||
type: string
|
||||
apiurl:
|
||||
description: 'Base API URL (default: https://api.themoviedb.org)'
|
||||
type: string
|
||||
imageURL:
|
||||
description: 'Image URL (default: https://image.tmdb.org)'
|
||||
type: string
|
||||
imageURLRu:
|
||||
description: 'Image URL for Russian users (default: https://imagetmdb.com)'
|
||||
type: string
|
||||
type: object
|
||||
settings.TorznabConfig:
|
||||
properties:
|
||||
host:
|
||||
type: string
|
||||
key:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
type: object
|
||||
settings.Viewed:
|
||||
properties:
|
||||
file_index:
|
||||
type: integer
|
||||
hash:
|
||||
type: string
|
||||
type: object
|
||||
state.CacheState:
|
||||
properties:
|
||||
capacity:
|
||||
format: int64
|
||||
type: integer
|
||||
filled:
|
||||
format: int64
|
||||
type: integer
|
||||
hash:
|
||||
type: string
|
||||
pieces:
|
||||
additionalProperties:
|
||||
$ref: '#/definitions/state.ItemState'
|
||||
type: object
|
||||
piecesCount:
|
||||
type: integer
|
||||
piecesLength:
|
||||
format: int64
|
||||
type: integer
|
||||
readers:
|
||||
items:
|
||||
$ref: '#/definitions/state.ReaderState'
|
||||
type: array
|
||||
torrent:
|
||||
$ref: '#/definitions/state.TorrentStatus'
|
||||
type: object
|
||||
state.ItemState:
|
||||
properties:
|
||||
completed:
|
||||
type: boolean
|
||||
id:
|
||||
type: integer
|
||||
length:
|
||||
format: int64
|
||||
type: integer
|
||||
priority:
|
||||
type: integer
|
||||
size:
|
||||
format: int64
|
||||
type: integer
|
||||
type: object
|
||||
state.ReaderState:
|
||||
properties:
|
||||
end:
|
||||
type: integer
|
||||
reader:
|
||||
type: integer
|
||||
start:
|
||||
type: integer
|
||||
type: object
|
||||
state.TorrentFileStat:
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
length:
|
||||
type: integer
|
||||
path:
|
||||
type: string
|
||||
type: object
|
||||
state.TorrentStat:
|
||||
enum:
|
||||
- 0
|
||||
- 1
|
||||
- 2
|
||||
- 3
|
||||
- 4
|
||||
- 5
|
||||
type: integer
|
||||
x-enum-varnames:
|
||||
- TorrentAdded
|
||||
- TorrentGettingInfo
|
||||
- TorrentPreload
|
||||
- TorrentWorking
|
||||
- TorrentClosed
|
||||
- TorrentInDB
|
||||
state.TorrentStatus:
|
||||
properties:
|
||||
active_peers:
|
||||
type: integer
|
||||
bit_rate:
|
||||
type: string
|
||||
bytes_read:
|
||||
type: integer
|
||||
bytes_read_data:
|
||||
type: integer
|
||||
bytes_read_useful_data:
|
||||
type: integer
|
||||
bytes_written:
|
||||
type: integer
|
||||
bytes_written_data:
|
||||
type: integer
|
||||
category:
|
||||
type: string
|
||||
chunks_read:
|
||||
type: integer
|
||||
chunks_read_useful:
|
||||
type: integer
|
||||
chunks_read_wasted:
|
||||
type: integer
|
||||
chunks_written:
|
||||
type: integer
|
||||
connected_seeders:
|
||||
type: integer
|
||||
data:
|
||||
type: string
|
||||
download_speed:
|
||||
type: number
|
||||
duration_seconds:
|
||||
type: number
|
||||
file_stats:
|
||||
items:
|
||||
$ref: '#/definitions/state.TorrentFileStat'
|
||||
type: array
|
||||
half_open_peers:
|
||||
type: integer
|
||||
hash:
|
||||
type: string
|
||||
loaded_size:
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
pending_peers:
|
||||
type: integer
|
||||
pieces_dirtied_bad:
|
||||
type: integer
|
||||
pieces_dirtied_good:
|
||||
type: integer
|
||||
poster:
|
||||
type: string
|
||||
preload_size:
|
||||
type: integer
|
||||
preloaded_bytes:
|
||||
type: integer
|
||||
stat:
|
||||
$ref: '#/definitions/state.TorrentStat'
|
||||
stat_string:
|
||||
type: string
|
||||
timestamp:
|
||||
type: integer
|
||||
title:
|
||||
type: string
|
||||
torrent_size:
|
||||
type: integer
|
||||
torrs_hash:
|
||||
type: string
|
||||
total_peers:
|
||||
type: integer
|
||||
upload_speed:
|
||||
type: number
|
||||
type: object
|
||||
externalDocs:
|
||||
description: OpenAPI
|
||||
url: https://swagger.io/resources/open-api/
|
||||
info:
|
||||
contact: {}
|
||||
description: Torrent streaming server.
|
||||
license:
|
||||
name: GPL 3.0
|
||||
title: Swagger Torrserver API
|
||||
version: '{version.Version}'
|
||||
paths:
|
||||
/cache:
|
||||
post:
|
||||
description: Return cache stats.
|
||||
parameters:
|
||||
- description: Cache stats request
|
||||
in: body
|
||||
name: request
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/api.cacheReqJS'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Cache stats
|
||||
schema:
|
||||
$ref: '#/definitions/state.CacheState'
|
||||
summary: Return cache stats
|
||||
tags:
|
||||
- API
|
||||
/download/{size}:
|
||||
get:
|
||||
description: Download the test file of given size (for speed testing purpose).
|
||||
parameters:
|
||||
- description: Test file size (in MB)
|
||||
in: path
|
||||
name: size
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/octet-stream
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
type: file
|
||||
summary: Generates test file of given size
|
||||
tags:
|
||||
- API
|
||||
/echo:
|
||||
get:
|
||||
description: Tests whether server is alive or not
|
||||
produces:
|
||||
- text/plain
|
||||
responses:
|
||||
"200":
|
||||
description: Server version
|
||||
schema:
|
||||
type: string
|
||||
summary: Tests server status
|
||||
tags:
|
||||
- API
|
||||
/ffp/{hash}/{id}:
|
||||
get:
|
||||
description: Gather informations using ffprobe.
|
||||
parameters:
|
||||
- description: Torrent hash
|
||||
in: path
|
||||
name: hash
|
||||
required: true
|
||||
type: string
|
||||
- description: File index in torrent
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Data returned from ffprobe
|
||||
summary: Gather informations using ffprobe
|
||||
tags:
|
||||
- API
|
||||
/magnets:
|
||||
get:
|
||||
description: Get HTML of magnet links.
|
||||
produces:
|
||||
- text/html
|
||||
responses:
|
||||
"200":
|
||||
description: HTML with Magnet links
|
||||
summary: Get HTML of magnet links
|
||||
tags:
|
||||
- Pages
|
||||
/play/{hash}/{id}:
|
||||
get:
|
||||
description: Play given torrent referenced by infohash and file id.
|
||||
parameters:
|
||||
- description: Torrent infohash
|
||||
in: path
|
||||
name: hash
|
||||
required: true
|
||||
type: string
|
||||
- description: File index in torrent
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/octet-stream
|
||||
responses:
|
||||
"200":
|
||||
description: Torrent data
|
||||
summary: Play given torrent by infohash
|
||||
tags:
|
||||
- API
|
||||
/playlist:
|
||||
get:
|
||||
description: Get HTTP link of torrent in M3U list.
|
||||
parameters:
|
||||
- description: Torrent hash
|
||||
in: query
|
||||
name: hash
|
||||
required: true
|
||||
type: string
|
||||
- description: From last play file
|
||||
in: query
|
||||
name: fromlast
|
||||
type: boolean
|
||||
produces:
|
||||
- audio/x-mpegurl
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
type: file
|
||||
summary: Get HTTP link of torrent in M3U list
|
||||
tags:
|
||||
- API
|
||||
/playlistall/all.m3u:
|
||||
get:
|
||||
description: Retrieve all torrents and generates a bundled M3U playlist.
|
||||
produces:
|
||||
- audio/x-mpegurl
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
type: file
|
||||
summary: Get a M3U playlist with all torrents
|
||||
tags:
|
||||
- API
|
||||
/search:
|
||||
get:
|
||||
description: Makes a rutor search.
|
||||
parameters:
|
||||
- description: Rutor query
|
||||
in: query
|
||||
name: query
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Rutor torrent search result(s)
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/models.TorrentDetails'
|
||||
type: array
|
||||
summary: Makes a rutor search
|
||||
tags:
|
||||
- API
|
||||
/settings:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Allow to get or set server settings.
|
||||
parameters:
|
||||
- description: 'Settings request. Available params for action: get, set, def'
|
||||
in: body
|
||||
name: request
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/api.setsReqJS'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Settings JSON or nothing. Depends on what action has been asked.
|
||||
schema:
|
||||
$ref: '#/definitions/settings.BTSets'
|
||||
summary: Get / Set server settings
|
||||
tags:
|
||||
- API
|
||||
/shutdown:
|
||||
get:
|
||||
description: Gracefully shuts down server after 1 second.
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
summary: Shuts down server
|
||||
tags:
|
||||
- API
|
||||
/stat:
|
||||
get:
|
||||
description: Show server and torrents statistics.
|
||||
produces:
|
||||
- text/plain
|
||||
responses:
|
||||
"200":
|
||||
description: TorrServer statistics
|
||||
summary: TorrServer Statistics
|
||||
tags:
|
||||
- Pages
|
||||
/storage/settings:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Retrieves the current storage preferences for settings and viewed
|
||||
history
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Storage preferences
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
"401":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"500":
|
||||
description: Internal server error
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
security:
|
||||
- ApiKeyAuth: []
|
||||
summary: Get storage configuration settings
|
||||
tags:
|
||||
- API
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
- application/x-www-form-urlencoded
|
||||
description: Updates the storage preferences for settings and viewed history.
|
||||
Requires application restart for changes to take effect.
|
||||
parameters:
|
||||
- description: Storage preferences to update
|
||||
in: body
|
||||
name: request
|
||||
required: true
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
- description: Settings storage type
|
||||
enum:
|
||||
- json
|
||||
- bbolt
|
||||
in: formData
|
||||
name: settings
|
||||
type: string
|
||||
- description: Viewed history storage type
|
||||
enum:
|
||||
- json
|
||||
- bbolt
|
||||
in: formData
|
||||
name: viewed
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Update successful
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"400":
|
||||
description: Invalid input data
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"401":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"403":
|
||||
description: Read-only mode
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"500":
|
||||
description: Internal server error
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
security:
|
||||
- ApiKeyAuth: []
|
||||
summary: Update storage configuration settings
|
||||
tags:
|
||||
- API
|
||||
/stream:
|
||||
get:
|
||||
description: Multi usage endpoint.
|
||||
parameters:
|
||||
- description: Magnet/hash/link to torrent
|
||||
in: query
|
||||
name: link
|
||||
required: true
|
||||
type: string
|
||||
- description: File index in torrent
|
||||
in: query
|
||||
name: index
|
||||
type: string
|
||||
- description: Should preload torrent
|
||||
in: query
|
||||
name: preload
|
||||
type: string
|
||||
- description: Get statistics from torrent
|
||||
in: query
|
||||
name: stat
|
||||
type: string
|
||||
- description: Should save torrent
|
||||
in: query
|
||||
name: save
|
||||
type: string
|
||||
- description: Get torrent as M3U playlist
|
||||
in: query
|
||||
name: m3u
|
||||
type: string
|
||||
- description: Get M3U from last played file
|
||||
in: query
|
||||
name: fromlast
|
||||
type: string
|
||||
- description: Start stream torrent
|
||||
in: query
|
||||
name: play
|
||||
type: string
|
||||
- description: Set title of torrent
|
||||
in: query
|
||||
name: title
|
||||
type: string
|
||||
- description: Set poster link of torrent
|
||||
in: query
|
||||
name: poster
|
||||
type: string
|
||||
- description: 'Set category of torrent, used in web: movie, tv, music, other'
|
||||
in: query
|
||||
name: category
|
||||
type: string
|
||||
produces:
|
||||
- application/octet-stream
|
||||
responses:
|
||||
"200":
|
||||
description: Data returned according to query
|
||||
summary: Multi usage endpoint
|
||||
tags:
|
||||
- API
|
||||
/tmdb/settings:
|
||||
get:
|
||||
description: Get TMDB API configuration
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: TMDB settings
|
||||
schema:
|
||||
$ref: '#/definitions/settings.TMDBConfig'
|
||||
summary: Get TMDB settings
|
||||
tags:
|
||||
- API
|
||||
/torrent/upload:
|
||||
post:
|
||||
consumes:
|
||||
- multipart/form-data
|
||||
description: Supports multiple files. Returns array of statuses.
|
||||
parameters:
|
||||
- description: Torrent file(s) to insert
|
||||
in: formData
|
||||
name: file
|
||||
required: true
|
||||
type: file
|
||||
- description: Save to DB
|
||||
in: formData
|
||||
name: save
|
||||
type: string
|
||||
- description: Torrent title (single file only)
|
||||
in: formData
|
||||
name: title
|
||||
type: string
|
||||
- description: Torrent category
|
||||
in: formData
|
||||
name: category
|
||||
type: string
|
||||
- description: Torrent poster (single file only)
|
||||
in: formData
|
||||
name: poster
|
||||
type: string
|
||||
- description: Torrent data
|
||||
in: formData
|
||||
name: data
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Torrent statuses
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/state.TorrentStatus'
|
||||
type: array
|
||||
summary: Add .torrent files
|
||||
tags:
|
||||
- API
|
||||
/torrents:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Allow to list, add, remove, get, set, drop, wipe torrents on server.
|
||||
The action depends of what has been asked.
|
||||
parameters:
|
||||
- description: 'Torrent request. Available params for action: add, get, set,
|
||||
rem, list, drop, wipe. link required for add, hash required for get, set,
|
||||
rem, drop.'
|
||||
in: body
|
||||
name: request
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/api.torrReqJS'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
summary: Handle torrents informations
|
||||
tags:
|
||||
- API
|
||||
/torznab/search:
|
||||
get:
|
||||
description: Makes a torznab search.
|
||||
parameters:
|
||||
- description: Torznab query
|
||||
in: query
|
||||
name: query
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Torznab torrent search result(s)
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/models.TorrentDetails'
|
||||
type: array
|
||||
summary: Makes a torznab search
|
||||
tags:
|
||||
- API
|
||||
/viewed:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Allow to set, list or remove viewed torrents from server.
|
||||
parameters:
|
||||
- description: 'Viewed torrent request. Available params for action: set, rem,
|
||||
list'
|
||||
in: body
|
||||
name: request
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/api.viewedReqJS'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/settings.Viewed'
|
||||
type: array
|
||||
summary: Set / List / Remove viewed torrents
|
||||
tags:
|
||||
- API
|
||||
securityDefinitions:
|
||||
BasicAuth:
|
||||
type: basic
|
||||
swagger: "2.0"
|
||||
@@ -0,0 +1,52 @@
|
||||
package ffprobe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"gopkg.in/vansante/go-ffprobe.v2"
|
||||
)
|
||||
|
||||
var binFile = "ffprobe"
|
||||
|
||||
func init() {
|
||||
path, err := exec.LookPath("ffprobe")
|
||||
if err == nil {
|
||||
ffprobe.SetFFProbeBinPath(path)
|
||||
binFile = path
|
||||
} else {
|
||||
// working dir
|
||||
if _, err := os.Stat("ffprobe"); os.IsNotExist(err) {
|
||||
ffprobe.SetFFProbeBinPath(filepath.Dir(os.Args[0]) + "/ffprobe")
|
||||
binFile = filepath.Dir(os.Args[0]) + "/ffprobe"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func Exists() bool {
|
||||
_, err := os.Stat(binFile)
|
||||
return !os.IsNotExist(err)
|
||||
}
|
||||
|
||||
func ProbeUrl(link string) (*ffprobe.ProbeData, error) {
|
||||
data, err := ffprobe.ProbeURL(getCtx(), link)
|
||||
return data, err
|
||||
}
|
||||
|
||||
func ProbeReader(reader io.Reader) (*ffprobe.ProbeData, error) {
|
||||
data, err := ffprobe.ProbeReader(getCtx(), reader)
|
||||
return data, err
|
||||
}
|
||||
|
||||
func getCtx() context.Context {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go func() {
|
||||
time.Sleep(5 * time.Minute)
|
||||
cancel()
|
||||
}()
|
||||
return ctx
|
||||
}
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
module server
|
||||
|
||||
go 1.25
|
||||
|
||||
replace (
|
||||
github.com/anacrolix/torrent v1.59.1 => github.com/tsynik/torrent v1.2.22
|
||||
github.com/anacrolix/upnp v0.1.4 => github.com/tsynik/upnp v0.1.5
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/YouROK/tunsgo v0.0.8
|
||||
github.com/agnivade/levenshtein v1.2.1
|
||||
github.com/alexflint/go-arg v1.6.0
|
||||
github.com/anacrolix/dms v1.7.2
|
||||
github.com/anacrolix/log v0.17.0
|
||||
github.com/anacrolix/missinggo/v2 v2.10.0
|
||||
github.com/anacrolix/publicip v0.3.1
|
||||
github.com/anacrolix/torrent v1.59.1
|
||||
github.com/dustin/go-humanize v1.0.1
|
||||
github.com/gin-contrib/cors v1.7.6
|
||||
github.com/gin-contrib/location/v2 v2.0.0
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/hanwen/go-fuse/v2 v2.9.0
|
||||
github.com/kljensen/snowball v0.10.0
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/swaggo/files v1.0.1
|
||||
github.com/swaggo/gin-swagger v1.6.1
|
||||
github.com/swaggo/swag v1.16.6
|
||||
github.com/wlynxg/anet v0.0.5
|
||||
go.etcd.io/bbolt v1.4.3
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96
|
||||
golang.org/x/image v0.33.0
|
||||
golang.org/x/net v0.49.0
|
||||
golang.org/x/time v0.14.0
|
||||
gopkg.in/telebot.v4 v4.0.0-beta.7
|
||||
gopkg.in/vansante/go-ffprobe.v2 v2.2.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/RoaringBitmap/roaring v1.9.4 // indirect
|
||||
github.com/alecthomas/atomic v0.1.0-alpha2 // indirect
|
||||
github.com/alexflint/go-scalar v1.2.0 // indirect
|
||||
github.com/anacrolix/chansync v0.7.0 // indirect
|
||||
github.com/anacrolix/dht/v2 v2.23.0 // indirect
|
||||
github.com/anacrolix/envpprof v1.4.0 // indirect
|
||||
github.com/anacrolix/ffprobe v1.1.0 // indirect
|
||||
github.com/anacrolix/generics v0.1.0 // indirect
|
||||
github.com/anacrolix/missinggo v1.3.0 // indirect
|
||||
github.com/anacrolix/missinggo/perf v1.0.0 // indirect
|
||||
github.com/anacrolix/multiless v0.4.0 // indirect
|
||||
github.com/anacrolix/stm v0.5.0 // indirect
|
||||
github.com/anacrolix/sync v0.5.4 // indirect
|
||||
github.com/anacrolix/upnp v0.1.4 // indirect
|
||||
github.com/anacrolix/utp v0.2.0 // indirect
|
||||
github.com/benbjohnson/clock v1.3.5 // indirect
|
||||
github.com/benbjohnson/immutable v0.4.3 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.24.4 // indirect
|
||||
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.14.2 // indirect
|
||||
github.com/bytedance/sonic/loader v0.4.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/davidlazar/go-crypto v0.0.0-20200604182044-b73af7476f6c // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
|
||||
github.com/dunglas/httpsfv v1.1.0 // indirect
|
||||
github.com/edsrzf/mmap-go v1.2.0 // indirect
|
||||
github.com/filecoin-project/go-clock v0.1.0 // indirect
|
||||
github.com/flynn/noise v1.1.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.22.3 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.3 // indirect
|
||||
github.com/go-openapi/spec v0.22.1 // indirect
|
||||
github.com/go-openapi/swag/conv v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/jsonutils v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/loading v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/stringutils v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.25.4 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.28.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.0 // indirect
|
||||
github.com/google/btree v1.1.3 // indirect
|
||||
github.com/google/gopacket v1.1.19 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/hashicorp/golang-lru v1.0.2 // indirect
|
||||
github.com/huandu/xstrings v1.5.0 // indirect
|
||||
github.com/huin/goupnp v1.3.0 // indirect
|
||||
github.com/ipfs/boxo v0.36.0 // indirect
|
||||
github.com/ipfs/go-cid v0.6.0 // indirect
|
||||
github.com/ipfs/go-datastore v0.9.1 // indirect
|
||||
github.com/ipfs/go-log/v2 v2.9.1 // indirect
|
||||
github.com/ipld/go-ipld-prime v0.21.0 // indirect
|
||||
github.com/jackpal/go-nat-pmp v1.0.2 // indirect
|
||||
github.com/jbenet/go-temp-err-catcher v0.1.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/koron/go-ssdp v0.0.6 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/libp2p/go-buffer-pool v0.1.0 // indirect
|
||||
github.com/libp2p/go-cidranger v1.1.0 // indirect
|
||||
github.com/libp2p/go-flow-metrics v0.3.0 // indirect
|
||||
github.com/libp2p/go-libp2p v0.47.0 // indirect
|
||||
github.com/libp2p/go-libp2p-asn-util v0.4.1 // indirect
|
||||
github.com/libp2p/go-libp2p-kad-dht v0.38.0 // indirect
|
||||
github.com/libp2p/go-libp2p-kbucket v0.8.0 // indirect
|
||||
github.com/libp2p/go-libp2p-record v0.3.1 // indirect
|
||||
github.com/libp2p/go-libp2p-routing-helpers v0.7.5 // indirect
|
||||
github.com/libp2p/go-msgio v0.3.0 // indirect
|
||||
github.com/libp2p/go-netroute v0.4.0 // indirect
|
||||
github.com/libp2p/go-reuseport v0.4.0 // indirect
|
||||
github.com/libp2p/go-yamux/v5 v5.0.1 // indirect
|
||||
github.com/marten-seemann/tcp v0.0.0-20210406111302-dfbc87cc63fd // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/miekg/dns v1.1.72 // indirect
|
||||
github.com/mikioh/tcpinfo v0.0.0-20190314235526-30a79bb1804b // indirect
|
||||
github.com/mikioh/tcpopt v0.0.0-20190314235656-172688c1accc // indirect
|
||||
github.com/minio/sha256-simd v1.0.1 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/mr-tron/base58 v1.2.0 // indirect
|
||||
github.com/mschoch/smat v0.2.0 // indirect
|
||||
github.com/multiformats/go-base32 v0.1.0 // indirect
|
||||
github.com/multiformats/go-base36 v0.2.0 // indirect
|
||||
github.com/multiformats/go-multiaddr v0.16.1 // indirect
|
||||
github.com/multiformats/go-multiaddr-dns v0.4.1 // indirect
|
||||
github.com/multiformats/go-multiaddr-fmt v0.1.0 // indirect
|
||||
github.com/multiformats/go-multibase v0.2.0 // indirect
|
||||
github.com/multiformats/go-multicodec v0.10.0 // indirect
|
||||
github.com/multiformats/go-multihash v0.2.3 // indirect
|
||||
github.com/multiformats/go-multistream v0.6.1 // indirect
|
||||
github.com/multiformats/go-varint v0.1.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pion/datachannel v1.5.10 // indirect
|
||||
github.com/pion/dtls/v2 v2.2.12 // indirect
|
||||
github.com/pion/dtls/v3 v3.1.1 // indirect
|
||||
github.com/pion/ice/v4 v4.0.10 // indirect
|
||||
github.com/pion/interceptor v0.1.40 // indirect
|
||||
github.com/pion/logging v0.2.4 // indirect
|
||||
github.com/pion/mdns/v2 v2.0.7 // indirect
|
||||
github.com/pion/randutil v0.1.0 // indirect
|
||||
github.com/pion/rtcp v1.2.15 // indirect
|
||||
github.com/pion/rtp v1.8.19 // indirect
|
||||
github.com/pion/sctp v1.8.39 // indirect
|
||||
github.com/pion/sdp/v3 v3.0.13 // indirect
|
||||
github.com/pion/srtp/v3 v3.0.6 // indirect
|
||||
github.com/pion/stun v0.6.1 // indirect
|
||||
github.com/pion/stun/v3 v3.0.0 // indirect
|
||||
github.com/pion/transport/v2 v2.2.10 // indirect
|
||||
github.com/pion/transport/v3 v3.0.7 // indirect
|
||||
github.com/pion/transport/v4 v4.0.1 // indirect
|
||||
github.com/pion/turn/v4 v4.0.2 // indirect
|
||||
github.com/pion/webrtc/v4 v4.1.2 // indirect
|
||||
github.com/polydawn/refmt v0.89.0 // indirect
|
||||
github.com/prometheus/client_golang v1.23.2 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.66.1 // indirect
|
||||
github.com/prometheus/procfs v0.17.0 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/quic-go/webtransport-go v0.10.0 // indirect
|
||||
github.com/rs/dnscache v0.0.0-20230804202142-fc85eb664529 // indirect
|
||||
github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 // indirect
|
||||
github.com/spaolacci/murmur3 v1.1.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
github.com/whyrusleeping/go-keyspace v0.0.0-20160322163242-5b898ac5add1 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/otel v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.40.0 // indirect
|
||||
go.uber.org/dig v1.19.0 // indirect
|
||||
go.uber.org/fx v1.24.0 // indirect
|
||||
go.uber.org/mock v0.6.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.uber.org/zap v1.27.1 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/arch v0.23.0 // indirect
|
||||
golang.org/x/crypto v0.47.0 // indirect
|
||||
golang.org/x/mod v0.32.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
golang.org/x/tools v0.41.0 // indirect
|
||||
gonum.org/v1/gonum v0.17.0 // indirect
|
||||
lukechampine.com/blake3 v1.4.1 // indirect
|
||||
)
|
||||
+1458
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,121 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var (
|
||||
logPath = ""
|
||||
webLogPath = ""
|
||||
)
|
||||
|
||||
var webLog *log.Logger
|
||||
|
||||
var (
|
||||
logFile *os.File
|
||||
webLogFile *os.File
|
||||
)
|
||||
|
||||
func Init(path, webpath string) {
|
||||
webLogPath = webpath
|
||||
logPath = path
|
||||
|
||||
if webpath != "" {
|
||||
ff, err := os.OpenFile(webLogPath, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0o666)
|
||||
if err != nil {
|
||||
TLogln("Error create web log file:", err)
|
||||
} else {
|
||||
webLogFile = ff
|
||||
webLog = log.New(ff, " ", log.LstdFlags)
|
||||
}
|
||||
}
|
||||
|
||||
if path != "" {
|
||||
if fi, err := os.Lstat(path); err == nil {
|
||||
if fi.Size() >= 100*1024*1024 { // 100MB
|
||||
os.Remove(path)
|
||||
}
|
||||
}
|
||||
ff, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0o666)
|
||||
if err != nil {
|
||||
TLogln("Error create log file:", err)
|
||||
return
|
||||
}
|
||||
logFile = ff
|
||||
os.Stdout = ff
|
||||
os.Stderr = ff
|
||||
// var timeFmt string
|
||||
// var ok bool
|
||||
// timeFmt, ok = os.LookupEnv("GO_LOG_TIME_FMT")
|
||||
// if !ok {
|
||||
// timeFmt = "2006-01-02T15:04:05-0700"
|
||||
// }
|
||||
// log.SetFlags(log.Lmsgprefix)
|
||||
// log.SetPrefix(time.Now().Format(timeFmt) + " TSM ")
|
||||
log.SetFlags(log.LstdFlags | log.LUTC | log.Lmsgprefix)
|
||||
log.SetPrefix("UTC0 ")
|
||||
log.SetOutput(ff)
|
||||
}
|
||||
}
|
||||
|
||||
func Close() {
|
||||
if logFile != nil {
|
||||
logFile.Close()
|
||||
}
|
||||
if webLogFile != nil {
|
||||
webLogFile.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func TLogln(v ...interface{}) {
|
||||
log.Println(v...)
|
||||
}
|
||||
|
||||
func WebLogln(v ...interface{}) {
|
||||
if webLog != nil {
|
||||
webLog.Println(v...)
|
||||
}
|
||||
}
|
||||
|
||||
func WebLogger() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if webLog == nil {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
body := ""
|
||||
// save body if not form or file
|
||||
if !strings.HasPrefix(c.Request.Header.Get("Content-Type"), "multipart/form-data") {
|
||||
body, _ := io.ReadAll(c.Request.Body)
|
||||
c.Request.Body = io.NopCloser(bytes.NewBuffer(body))
|
||||
} else {
|
||||
body = "body hidden, too large"
|
||||
}
|
||||
c.Next()
|
||||
|
||||
statusCode := c.Writer.Status()
|
||||
clientIP := c.ClientIP()
|
||||
method := c.Request.Method
|
||||
path := c.Request.URL.Path
|
||||
raw := c.Request.URL.RawQuery
|
||||
if raw != "" {
|
||||
path = path + "?" + raw
|
||||
}
|
||||
|
||||
logStr := fmt.Sprintf("%3d | %12s | %-7s %#v %v",
|
||||
statusCode,
|
||||
clientIP,
|
||||
method,
|
||||
path,
|
||||
string(body),
|
||||
)
|
||||
WebLogln(logStr)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package mimetype
|
||||
|
||||
import (
|
||||
"log"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func init() {
|
||||
// Add a minimal number of mime types to augment go's built in types
|
||||
// for environments which don't have access to a mime.types file (e.g.
|
||||
// Termux on android)
|
||||
for _, t := range []struct {
|
||||
mimeType string
|
||||
extensions string
|
||||
}{
|
||||
{"image/bmp", ".bmp"},
|
||||
{"image/gif", ".gif"},
|
||||
{"image/jpeg", ".jpg,.jpeg"},
|
||||
{"image/png", ".png"},
|
||||
{"image/tiff", ".tiff,.tif"},
|
||||
{"audio/x-aac", ".aac"},
|
||||
{"audio/dsd", ".dsd,.dsf,.dff"},
|
||||
{"audio/flac", ".flac"},
|
||||
{"audio/mpeg", ".mpga,.mpega,.mp2,.mp3,.m4a"},
|
||||
{"audio/ogg", ".oga,.ogg,.opus,.spx"},
|
||||
{"audio/opus", ".opus"},
|
||||
{"audio/weba", ".weba"},
|
||||
{"audio/x-ape", ".ape"},
|
||||
// {"audio/x-dsd", ".dsd"},
|
||||
// {"audio/x-dff", ".dff"},
|
||||
// {"audio/x-dsf", ".dsf"},
|
||||
{"audio/x-wav", ".wav"},
|
||||
{"video/dv", ".dif,.dv"},
|
||||
{"video/fli", ".fli"},
|
||||
{"video/mp4", ".mp4"},
|
||||
{"video/mpeg", ".mpeg,.mpg,.mpe"},
|
||||
{"video/x-matroska", ".mpv,.mkv"},
|
||||
{"video/mp2t", ".ts,.m2ts,.mts"},
|
||||
{"video/ogg", ".ogv"},
|
||||
{"video/webm", ".webm"},
|
||||
{"video/x-ms-vob", ".vob"},
|
||||
{"video/x-msvideo", ".avi"},
|
||||
{"video/x-quicktime", ".qt,.mov"},
|
||||
{"text/srt", ".srt"},
|
||||
{"text/smi", ".smi"},
|
||||
{"text/ssa", ".ssa"},
|
||||
} {
|
||||
for _, ext := range strings.Split(t.extensions, ",") {
|
||||
err := mime.AddExtensionType(ext, t.mimeType)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := mime.AddExtensionType(".rmvb", "application/vnd.rn-realmedia-vbr"); err != nil {
|
||||
log.Printf("Could not register application/vnd.rn-realmedia-vbr MIME type: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Example: "video/mpeg"
|
||||
type mimeType string
|
||||
|
||||
// IsMedia returns true for media MIME-types
|
||||
func (mt mimeType) IsMedia() bool {
|
||||
return mt.IsVideo() || mt.IsAudio() || mt.IsImage()
|
||||
}
|
||||
|
||||
// IsVideo returns true for video MIME-types
|
||||
func (mt mimeType) IsVideo() bool {
|
||||
return strings.HasPrefix(string(mt), "video/") || mt == "application/vnd.rn-realmedia-vbr"
|
||||
}
|
||||
|
||||
// IsAudio returns true for audio MIME-types
|
||||
func (mt mimeType) IsAudio() bool {
|
||||
return strings.HasPrefix(string(mt), "audio/")
|
||||
}
|
||||
|
||||
// IsImage returns true for image MIME-types
|
||||
func (mt mimeType) IsImage() bool {
|
||||
return strings.HasPrefix(string(mt), "image/")
|
||||
}
|
||||
|
||||
// IsSub returns true for subtitles MIME-types
|
||||
func (mt mimeType) IsSub() bool {
|
||||
return strings.HasPrefix(string(mt), "text/srt") || strings.HasPrefix(string(mt), "text/smi") || strings.HasPrefix(string(mt), "text/ssa")
|
||||
}
|
||||
|
||||
// Returns the group "type", the part before the '/'.
|
||||
func (mt mimeType) Type() string {
|
||||
return strings.SplitN(string(mt), "/", 2)[0]
|
||||
}
|
||||
|
||||
// Returns the string representation of this MIME-type
|
||||
func (mt mimeType) String() string {
|
||||
return string(mt)
|
||||
}
|
||||
|
||||
// MimeTypeByPath determines the MIME-type of file at the given path
|
||||
func MimeTypeByPath(filePath string) (ret mimeType, err error) {
|
||||
ret = mimeTypeByBaseName(path.Base(filePath))
|
||||
if ret == "" {
|
||||
ret, err = mimeTypeByContent(filePath)
|
||||
}
|
||||
// Custom DLNA-compat mime mappings
|
||||
// TODO: make this depend on client headers / profile map
|
||||
if ret == "video/mp2t" {
|
||||
ret = "video/mpeg"
|
||||
// } else if ret == "video/x-matroska" {
|
||||
// ret = "video/mpeg"
|
||||
} else if ret == "video/x-msvideo" {
|
||||
ret = "video/avi"
|
||||
} else if ret == "" {
|
||||
ret = "application/octet-stream"
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Guess MIME-type from the extension, ignoring ".part".
|
||||
func mimeTypeByBaseName(name string) mimeType {
|
||||
name = strings.TrimSuffix(name, ".part")
|
||||
ext := path.Ext(name)
|
||||
if ext != "" {
|
||||
return mimeType(mime.TypeByExtension(ext))
|
||||
}
|
||||
return mimeType("")
|
||||
}
|
||||
|
||||
// Guess the MIME-type by analysing the first 512 bytes of the file.
|
||||
func mimeTypeByContent(path string) (ret mimeType, err error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
var data [512]byte
|
||||
if n, err := file.Read(data[:]); err == nil {
|
||||
ret = mimeType(http.DetectContentType(data[:n]))
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"server/log"
|
||||
"server/settings"
|
||||
|
||||
"github.com/YouROK/tunsgo/opts"
|
||||
"github.com/YouROK/tunsgo/p2p"
|
||||
)
|
||||
|
||||
var (
|
||||
P2Proxy *p2p.P2PServer
|
||||
)
|
||||
|
||||
func Start() {
|
||||
if settings.BTsets.EnableProxy {
|
||||
cfg := opts.DefOptions()
|
||||
var err error
|
||||
|
||||
cfg.Server.Port = settings.Args.Port
|
||||
cfg.Hosts = settings.BTsets.ProxyHosts
|
||||
|
||||
P2Proxy, err = p2p.NewP2PServer(cfg)
|
||||
if err != nil {
|
||||
log.TLogln("Error starting P2PServer:", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func Stop() {
|
||||
if P2Proxy != nil {
|
||||
P2Proxy.Stop()
|
||||
P2Proxy = nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package rutor
|
||||
|
||||
import (
|
||||
"compress/flate"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"server/rutor/models"
|
||||
)
|
||||
|
||||
func TestParseChannel(t *testing.T) {
|
||||
channel := make(chan *models.TorrentDetails, 0)
|
||||
var ftors []*models.TorrentDetails
|
||||
go func() {
|
||||
for torr := range channel {
|
||||
ftors = append(ftors, torr)
|
||||
}
|
||||
}()
|
||||
|
||||
path, _ := os.Getwd()
|
||||
ff, err := os.Open(filepath.Join(path, "rutor.ls"))
|
||||
if err == nil {
|
||||
defer ff.Close()
|
||||
r := flate.NewReader(ff)
|
||||
defer r.Close()
|
||||
dec := json.NewDecoder(r)
|
||||
|
||||
_, err := dec.Token()
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
for dec.More() {
|
||||
var torr *models.TorrentDetails
|
||||
err = dec.Decode(&torr)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
channel <- torr
|
||||
}
|
||||
close(channel)
|
||||
} else {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseArr(t *testing.T) {
|
||||
var ftors []*models.TorrentDetails
|
||||
path, _ := os.Getwd()
|
||||
ff, err := os.Open(filepath.Join(path, "rutor.ls"))
|
||||
if err == nil {
|
||||
defer ff.Close()
|
||||
r := flate.NewReader(ff)
|
||||
defer r.Close()
|
||||
dec := json.NewDecoder(r)
|
||||
|
||||
_, err := dec.Token()
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
for dec.More() {
|
||||
var torr *models.TorrentDetails
|
||||
err = dec.Decode(&torr)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
ftors = append(ftors, torr)
|
||||
fmt.Println(len(ftors))
|
||||
}
|
||||
} else {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
CatMovie = "Movie"
|
||||
CatSeries = "Series"
|
||||
CatDocMovie = "DocMovie"
|
||||
CatDocSeries = "DocSeries"
|
||||
CatCartoonMovie = "CartoonMovie"
|
||||
CatCartoonSeries = "CartoonSeries"
|
||||
CatTVShow = "TVShow"
|
||||
CatAnime = "Anime"
|
||||
|
||||
Q_LOWER = 0
|
||||
Q_WEBDL_720 = 100
|
||||
Q_BDRIP_720 = 101
|
||||
Q_BDRIP_HEVC_720 = 102
|
||||
Q_WEBDL_1080 = 200
|
||||
Q_BDRIP_1080 = 201
|
||||
Q_BDRIP_HEVC_1080 = 202
|
||||
Q_BDREMUX_1080 = 203
|
||||
Q_WEBDL_SDR_2160 = 300
|
||||
Q_WEBDL_HDR_2160 = 301
|
||||
Q_WEBDL_DV_2160 = 302
|
||||
Q_BDRIP_SDR_2160 = 303
|
||||
Q_BDRIP_HDR_2160 = 304
|
||||
Q_BDRIP_DV_2160 = 305
|
||||
Q_UHD_BDREMUX_SDR = 306
|
||||
Q_UHD_BDREMUX_HDR = 307
|
||||
Q_UHD_BDREMUX_DV = 308
|
||||
|
||||
Q_UNKNOWN = 0
|
||||
Q_A = 1 // Авторский, по типу Гоблина или старых переводчиков
|
||||
Q_L1 = 100 // Любительский одноголосый закадровый
|
||||
Q_L2 = 101 // Любительский двухголосый закадровый
|
||||
Q_L = 102 // Любительский 3-5 человек закадровый
|
||||
Q_LS = 103 // Любительский студия
|
||||
Q_P1 = 200 // Професиональный одноголосый закадровый
|
||||
Q_P2 = 201 // Профессиональный двухголосый закадровый
|
||||
Q_P = 202 // Профессиональный 3-5 человек закадровый
|
||||
Q_PS = 203 // Профессиональный студия
|
||||
Q_D = 300 // Официальное профессиональное многоголосое озвучивание
|
||||
Q_LICENSE = 301 // Лицензия
|
||||
)
|
||||
|
||||
type TorrentDetails struct {
|
||||
Title string
|
||||
Name string
|
||||
Names []string
|
||||
Categories string
|
||||
Size string
|
||||
CreateDate time.Time
|
||||
Tracker string
|
||||
Link string
|
||||
Year int
|
||||
Peer int
|
||||
Seed int
|
||||
Magnet string
|
||||
Hash string
|
||||
IMDBID string
|
||||
VideoQuality int
|
||||
AudioQuality int
|
||||
}
|
||||
|
||||
type TorrentFile struct {
|
||||
Name string
|
||||
Size int64
|
||||
}
|
||||
|
||||
func (d TorrentDetails) GetNames() string {
|
||||
return strings.Join(d.Names, " ")
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package rutor
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/flate"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"server/rutor/models"
|
||||
"server/settings"
|
||||
)
|
||||
|
||||
// TestConcurrentSearchAndLoadDB проверяет отсутствие гонки при одновременном
|
||||
// обновлении индекса (loadDB) и поиске (Search).
|
||||
// !Запускать с -count=3
|
||||
func TestConcurrentSearchAndLoadDB(t *testing.T) {
|
||||
if settings.BTsets == nil {
|
||||
settings.BTsets = &settings.BTSets{EnableRutorSearch: true}
|
||||
defer func() { settings.BTsets = nil }()
|
||||
} else {
|
||||
old := settings.BTsets.EnableRutorSearch
|
||||
settings.BTsets.EnableRutorSearch = true
|
||||
defer func() { settings.BTsets.EnableRutorSearch = old }()
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
oldPath := settings.Path
|
||||
settings.Path = dir
|
||||
defer func() { settings.Path = oldPath }()
|
||||
|
||||
const numTorrents = 800
|
||||
seed := make([]*models.TorrentDetails, numTorrents)
|
||||
for i := 0; i < numTorrents; i++ {
|
||||
s := strconv.Itoa(i)
|
||||
seed[i] = &models.TorrentDetails{
|
||||
Title: "Test Film Number " + s + " Part One Two Three Year",
|
||||
Name: "Film " + s,
|
||||
Year: 2015 + i%10,
|
||||
}
|
||||
}
|
||||
data, err := json.Marshal(seed)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var compressed bytes.Buffer
|
||||
w, _ := flate.NewWriter(&compressed, flate.DefaultCompression)
|
||||
_, _ = w.Write(data)
|
||||
_ = w.Close()
|
||||
if err := os.WriteFile(filepath.Join(dir, "rutor.ls"), compressed.Bytes(), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Горутина: многократно перезагружает БД (долгая перезапись индекса)
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 20; i++ {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
default:
|
||||
loadDB()
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Несколько горутин: постоянный поиск, пока идёт переиндексация
|
||||
for i := 0; i < 8; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
queries := []string{"Test", "Film", "Number", "Part", "Year", "xxx"}
|
||||
for j := 0; j < 200; j++ {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
default:
|
||||
_ = Search(queries[j%len(queries)])
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Даём время на пересечение loadDB и Search
|
||||
time.Sleep(800 * time.Millisecond)
|
||||
close(done)
|
||||
wg.Wait()
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package rutor
|
||||
|
||||
import (
|
||||
"compress/flate"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/agnivade/levenshtein"
|
||||
|
||||
"server/log"
|
||||
"server/rutor/models"
|
||||
"server/rutor/torrsearch"
|
||||
"server/rutor/utils"
|
||||
"server/settings"
|
||||
utils2 "server/torr/utils"
|
||||
)
|
||||
|
||||
var (
|
||||
mu sync.RWMutex
|
||||
torrs []*models.TorrentDetails
|
||||
isStop bool
|
||||
)
|
||||
|
||||
func Start() {
|
||||
go func() {
|
||||
if settings.BTsets.EnableRutorSearch {
|
||||
if !updateDB() {
|
||||
loadDB()
|
||||
}
|
||||
isStop = false
|
||||
for !isStop {
|
||||
for i := 0; i < 3*60*60; i++ {
|
||||
time.Sleep(time.Second)
|
||||
if isStop {
|
||||
return
|
||||
}
|
||||
}
|
||||
updateDB()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func Stop() {
|
||||
mu.Lock()
|
||||
isStop = true
|
||||
torrs = nil
|
||||
torrsearch.NewIndex(nil)
|
||||
mu.Unlock()
|
||||
utils2.FreeOSMemGC()
|
||||
time.Sleep(time.Millisecond * 1500)
|
||||
}
|
||||
|
||||
// http://releases.yourok.ru/torr/rutor.ls
|
||||
func updateDB() bool {
|
||||
log.TLogln("Update rutor db")
|
||||
|
||||
fnOrig := filepath.Join(settings.Path, "rutor.ls")
|
||||
|
||||
if fi, err := os.Stat(fnOrig); err == nil {
|
||||
if time.Since(fi.ModTime()) < time.Minute*175 /*2:55*/ {
|
||||
log.TLogln("Less 3 hours rutor db old")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
fnTmp := filepath.Join(settings.Path, "rutor.tmp")
|
||||
out, err := os.Create(fnTmp)
|
||||
if err != nil {
|
||||
log.TLogln("Error create file rutor.tmp:", err)
|
||||
return false
|
||||
}
|
||||
|
||||
resp, err := http.Get("http://releases.yourok.ru/torr/rutor.ls")
|
||||
if err != nil {
|
||||
log.TLogln("Error connect to rutor db:", err)
|
||||
out.Close()
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, err = io.Copy(out, resp.Body)
|
||||
out.Close()
|
||||
if err != nil {
|
||||
log.TLogln("Error download rutor db:", err)
|
||||
return false
|
||||
}
|
||||
|
||||
md5Tmp := utils.MD5File(fnTmp)
|
||||
md5Orig := utils.MD5File(fnOrig)
|
||||
if md5Tmp != md5Orig {
|
||||
err = os.Remove(fnOrig)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
log.TLogln("Error remove old rutor db:", err)
|
||||
return false
|
||||
}
|
||||
err = os.Rename(fnTmp, fnOrig)
|
||||
if err != nil {
|
||||
log.TLogln("Error rename rutor db:", err)
|
||||
return false
|
||||
}
|
||||
loadDB()
|
||||
return true
|
||||
} else {
|
||||
os.Remove(fnTmp)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func loadDB() {
|
||||
log.TLogln("Load rutor db")
|
||||
ff, err := os.Open(filepath.Join(settings.Path, "rutor.ls"))
|
||||
if err == nil {
|
||||
defer ff.Close()
|
||||
r := flate.NewReader(ff)
|
||||
defer r.Close()
|
||||
var ftorrs []*models.TorrentDetails
|
||||
dec := json.NewDecoder(r)
|
||||
|
||||
_, err := dec.Token()
|
||||
if err != nil {
|
||||
log.TLogln("Error read token rutor db:", err)
|
||||
return
|
||||
}
|
||||
|
||||
for dec.More() {
|
||||
var torr *models.TorrentDetails
|
||||
err = dec.Decode(&torr)
|
||||
if err == nil {
|
||||
ftorrs = append(ftorrs, torr)
|
||||
}
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
torrs = ftorrs
|
||||
log.TLogln("Index rutor db")
|
||||
torrsearch.NewIndex(torrs)
|
||||
log.TLogln("Torrents count:", len(torrs))
|
||||
log.TLogln("Indexed words:", len(torrsearch.GetIDX()))
|
||||
|
||||
} else {
|
||||
log.TLogln("Error load rutor db:", err)
|
||||
}
|
||||
utils2.FreeOSMemGC()
|
||||
}
|
||||
|
||||
func Search(query string) []*models.TorrentDetails {
|
||||
if !settings.BTsets.EnableRutorSearch {
|
||||
return nil
|
||||
}
|
||||
mu.RLock()
|
||||
matchedIDs := torrsearch.Search(query)
|
||||
if len(matchedIDs) == 0 {
|
||||
mu.RUnlock()
|
||||
return nil
|
||||
}
|
||||
var list []*models.TorrentDetails
|
||||
for _, id := range matchedIDs {
|
||||
list = append(list, torrs[id])
|
||||
}
|
||||
mu.RUnlock()
|
||||
|
||||
hash := utils.ClearStr(query)
|
||||
|
||||
sort.Slice(list, func(i, j int) bool {
|
||||
lhash := utils.ClearStr(strings.ToLower(list[i].Name+list[i].GetNames())) + strconv.Itoa(list[i].Year)
|
||||
lev1 := levenshtein.ComputeDistance(hash, lhash)
|
||||
lhash = utils.ClearStr(strings.ToLower(list[j].Name+list[j].GetNames())) + strconv.Itoa(list[j].Year)
|
||||
lev2 := levenshtein.ComputeDistance(hash, lhash)
|
||||
if lev1 == lev2 {
|
||||
return list[j].CreateDate.Before(list[i].CreateDate)
|
||||
}
|
||||
return lev1 < lev2
|
||||
})
|
||||
return list
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package torrsearch
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
snowballeng "github.com/kljensen/snowball/english"
|
||||
snowballru "github.com/kljensen/snowball/russian"
|
||||
)
|
||||
|
||||
// lowercaseFilter returns a slice of tokens normalized to lower case.
|
||||
func lowercaseFilter(tokens []string) []string {
|
||||
r := make([]string, len(tokens))
|
||||
for i, token := range tokens {
|
||||
r[i] = replaceChars(strings.ToLower(token))
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// stopwordFilter returns a slice of tokens with stop words removed.
|
||||
func stopwordFilter(tokens []string) []string {
|
||||
r := make([]string, 0, len(tokens))
|
||||
for _, token := range tokens {
|
||||
if !isStopWord(token) {
|
||||
r = append(r, token)
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// stemmerFilter returns a slice of stemmed tokens.
|
||||
func stemmerFilter(tokens []string) []string {
|
||||
r := make([]string, len(tokens))
|
||||
for i, token := range tokens {
|
||||
worden := snowballeng.Stem(token, false)
|
||||
wordru := snowballru.Stem(token, false)
|
||||
if wordru == "" || worden == "" {
|
||||
continue
|
||||
}
|
||||
if wordru != token {
|
||||
r[i] = wordru
|
||||
} else {
|
||||
r[i] = worden
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func replaceChars(word string) string {
|
||||
out := []rune(word)
|
||||
for i, r := range out {
|
||||
if r == 'ё' {
|
||||
out[i] = 'е'
|
||||
}
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func isStopWord(word string) bool {
|
||||
switch word {
|
||||
case "a", "am", "an", "and", "are", "as", "at", "be",
|
||||
"by", "did", "do", "is", "of", "or", "s", "so", "t",
|
||||
"и", "в", "с", "со", "а", "но", "к", "у",
|
||||
"же", "бы", "по", "от", "о", "из", "ну",
|
||||
"ли", "ни", "нибудь", "уж", "ведь", "ж", "об":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package torrsearch
|
||||
|
||||
import (
|
||||
"server/rutor/models"
|
||||
)
|
||||
|
||||
// Index is an inverted Index. It maps tokens to document IDs.
|
||||
type Index map[string][]int
|
||||
|
||||
var idx Index
|
||||
|
||||
func NewIndex(torrs []*models.TorrentDetails) {
|
||||
idx = make(Index)
|
||||
idx.add(torrs)
|
||||
}
|
||||
|
||||
func Search(text string) []int {
|
||||
return idx.search(text)
|
||||
}
|
||||
|
||||
func GetIDX() Index {
|
||||
return idx
|
||||
}
|
||||
|
||||
func (idx Index) add(torrs []*models.TorrentDetails) {
|
||||
for ID, torr := range torrs {
|
||||
for _, token := range analyze(torr.Title) {
|
||||
ids := idx[token]
|
||||
if ids != nil && ids[len(ids)-1] == ID {
|
||||
// Don't add same ID twice.
|
||||
continue
|
||||
}
|
||||
idx[token] = append(ids, ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// intersection returns the set intersection between a and b.
|
||||
// a and b have to be sorted in ascending order and contain no duplicates.
|
||||
func intersection(a []int, b []int) []int {
|
||||
maxLen := len(a)
|
||||
if len(b) > maxLen {
|
||||
maxLen = len(b)
|
||||
}
|
||||
r := make([]int, 0, maxLen)
|
||||
var i, j int
|
||||
for i < len(a) && j < len(b) {
|
||||
if a[i] < b[j] {
|
||||
i++
|
||||
} else if a[i] > b[j] {
|
||||
j++
|
||||
} else {
|
||||
r = append(r, a[i])
|
||||
i++
|
||||
j++
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// Search queries the Index for the given text.
|
||||
func (idx Index) search(text string) []int {
|
||||
var r []int
|
||||
for _, token := range analyze(text) {
|
||||
if ids, ok := idx[token]; ok {
|
||||
if r == nil {
|
||||
r = ids
|
||||
} else {
|
||||
r = intersection(r, ids)
|
||||
}
|
||||
} else {
|
||||
// Token doesn't exist.
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package torrsearch
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// tokenize returns a slice of tokens for the given text.
|
||||
func tokenize(text string) []string {
|
||||
return strings.FieldsFunc(text, func(r rune) bool {
|
||||
// Split on any character that is not a letter or a number.
|
||||
return !unicode.IsLetter(r) && !unicode.IsNumber(r)
|
||||
})
|
||||
}
|
||||
|
||||
// analyze analyzes the text and returns a slice of tokens.
|
||||
func analyze(text string) []string {
|
||||
tokens := tokenize(text)
|
||||
tokens = lowercaseFilter(tokens)
|
||||
tokens = stopwordFilter(tokens)
|
||||
// tokens = stemmerFilter(tokens)
|
||||
return tokens
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func ClearStr(str string) string {
|
||||
ret := ""
|
||||
str = strings.ToLower(str)
|
||||
for _, r := range str {
|
||||
if (r >= '0' && r <= '9') || (r >= 'a' && r <= 'z') || (r >= 'а' && r <= 'я') || r == 'ё' {
|
||||
ret = ret + string(r)
|
||||
}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func MD5File(fname string) string {
|
||||
f, err := os.Open(fname)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
defer f.Close()
|
||||
|
||||
buf := make([]byte, 1024*1024)
|
||||
h := sha256.New()
|
||||
|
||||
for {
|
||||
bytesRead, err := f.Read(buf)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
h.Write(buf[:bytesRead])
|
||||
}
|
||||
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"server/tgbot"
|
||||
|
||||
"server/log"
|
||||
"server/settings"
|
||||
"server/web"
|
||||
)
|
||||
|
||||
func Start() {
|
||||
settings.InitSets(settings.Args.RDB, settings.Args.SearchWA)
|
||||
// https checks
|
||||
if settings.Args.Ssl {
|
||||
// set settings ssl enabled
|
||||
settings.Ssl = settings.Args.Ssl
|
||||
if settings.Args.SslPort == "" {
|
||||
dbSSlPort := strconv.Itoa(settings.BTsets.SslPort)
|
||||
if dbSSlPort != "0" {
|
||||
settings.Args.SslPort = dbSSlPort
|
||||
} else {
|
||||
settings.Args.SslPort = "8091"
|
||||
}
|
||||
} else { // store ssl port from params to DB
|
||||
dbSSlPort, err := strconv.Atoi(settings.Args.SslPort)
|
||||
if err == nil {
|
||||
settings.BTsets.SslPort = dbSSlPort
|
||||
}
|
||||
}
|
||||
// check if ssl cert and key files exist
|
||||
if settings.Args.SslCert != "" && settings.Args.SslKey != "" {
|
||||
// set settings ssl cert and key files
|
||||
settings.BTsets.SslCert = settings.Args.SslCert
|
||||
settings.BTsets.SslKey = settings.Args.SslKey
|
||||
}
|
||||
log.TLogln("Check web ssl port", settings.Args.SslPort)
|
||||
l, err := net.Listen("tcp", settings.Args.IP+":"+settings.Args.SslPort)
|
||||
if l != nil {
|
||||
l.Close()
|
||||
}
|
||||
if err != nil {
|
||||
log.TLogln("Port", settings.Args.SslPort, "already in use! Please set different ssl port for HTTPS. Abort")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
// http checks
|
||||
if settings.Args.Port == "" {
|
||||
settings.Args.Port = "8090"
|
||||
}
|
||||
|
||||
log.TLogln("Check web port", settings.Args.Port)
|
||||
l, err := net.Listen("tcp", settings.Args.IP+":"+settings.Args.Port)
|
||||
if l != nil {
|
||||
l.Close()
|
||||
}
|
||||
if err != nil {
|
||||
log.TLogln("Port", settings.Args.Port, "already in use! Please set different port for HTTP. Abort")
|
||||
os.Exit(1)
|
||||
}
|
||||
// remove old disk caches
|
||||
go cleanCache()
|
||||
// set settings http and https ports. Start web server.
|
||||
settings.Port = settings.Args.Port
|
||||
settings.SslPort = settings.Args.SslPort
|
||||
settings.IP = settings.Args.IP
|
||||
|
||||
if settings.Args.TGToken != "" {
|
||||
if err := tgbot.Start(settings.Args.TGToken); err != nil {
|
||||
log.TLogln("tg bot start failed", err)
|
||||
}
|
||||
}
|
||||
web.Start()
|
||||
}
|
||||
|
||||
func cleanCache() {
|
||||
if !settings.BTsets.UseDisk || settings.BTsets.TorrentsSavePath == "/" || settings.BTsets.TorrentsSavePath == "" {
|
||||
return
|
||||
}
|
||||
|
||||
dirs, err := os.ReadDir(settings.BTsets.TorrentsSavePath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
torrs := settings.ListTorrent()
|
||||
|
||||
log.TLogln("Remove unused cache in dir:", settings.BTsets.TorrentsSavePath)
|
||||
keep := map[string]bool{}
|
||||
for _, d := range dirs {
|
||||
if len(d.Name()) != 40 {
|
||||
// Not a hash
|
||||
continue
|
||||
}
|
||||
|
||||
if !settings.BTsets.RemoveCacheOnDrop {
|
||||
keep[d.Name()] = true
|
||||
for _, t := range torrs {
|
||||
if d.IsDir() && d.Name() == t.InfoHash.HexString() {
|
||||
keep[d.Name()] = false
|
||||
break
|
||||
}
|
||||
}
|
||||
for hash, del := range keep {
|
||||
if del && hash == d.Name() {
|
||||
log.TLogln("Remove unused cache:", d.Name())
|
||||
removeAllFiles(filepath.Join(settings.BTsets.TorrentsSavePath, d.Name()))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if d.IsDir() {
|
||||
log.TLogln("Remove unused cache:", d.Name())
|
||||
removeAllFiles(filepath.Join(settings.BTsets.TorrentsSavePath, d.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func removeAllFiles(path string) {
|
||||
files, err := os.ReadDir(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, f := range files {
|
||||
name := filepath.Join(path, f.Name())
|
||||
os.Remove(name)
|
||||
}
|
||||
os.Remove(path)
|
||||
}
|
||||
|
||||
func WaitServer() string {
|
||||
err := web.Wait()
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func Stop() {
|
||||
web.Stop()
|
||||
settings.CloseDB()
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package settings
|
||||
|
||||
type ExecArgs struct {
|
||||
Port string
|
||||
IP string
|
||||
Ssl bool
|
||||
SslPort string
|
||||
SslCert string
|
||||
SslKey string
|
||||
Path string
|
||||
LogPath string
|
||||
WebLogPath string
|
||||
RDB bool
|
||||
HttpAuth bool
|
||||
DontKill bool
|
||||
UI bool
|
||||
TorrentsDir string
|
||||
TorrentAddr string
|
||||
PubIPv4 string
|
||||
PubIPv6 string
|
||||
SearchWA bool
|
||||
MaxSize string
|
||||
TGToken string
|
||||
FusePath string
|
||||
WebDAV bool
|
||||
ProxyURL string
|
||||
ProxyMode string
|
||||
ForceHTTPS bool
|
||||
}
|
||||
|
||||
var Args *ExecArgs
|
||||
@@ -0,0 +1,212 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"io/fs"
|
||||
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"server/log"
|
||||
)
|
||||
|
||||
type TorznabConfig struct {
|
||||
Host string
|
||||
Key string
|
||||
Name string
|
||||
}
|
||||
|
||||
type TMDBConfig struct {
|
||||
APIKey string // TMDB API Key
|
||||
APIURL string // Base API URL (default: https://api.themoviedb.org)
|
||||
ImageURL string // Image URL (default: https://image.tmdb.org)
|
||||
ImageURLRu string // Image URL for Russian users (default: https://imagetmdb.com)
|
||||
}
|
||||
|
||||
type BTSets struct {
|
||||
// Cache
|
||||
CacheSize int64 // in byte, def 64 MB
|
||||
ReaderReadAHead int // in percent, 5%-100%, [...S__X__E...] [S-E] not clean
|
||||
PreloadCache int // in percent
|
||||
|
||||
// Disk
|
||||
UseDisk bool
|
||||
TorrentsSavePath string
|
||||
RemoveCacheOnDrop bool
|
||||
|
||||
// Torrent
|
||||
ForceEncrypt bool
|
||||
RetrackersMode int // 0 - don`t add, 1 - add retrackers (def), 2 - remove retrackers 3 - replace retrackers
|
||||
TorrentDisconnectTimeout int // in seconds
|
||||
EnableDebug bool // debug logs
|
||||
|
||||
// DLNA
|
||||
EnableDLNA bool
|
||||
FriendlyName string
|
||||
|
||||
// Rutor
|
||||
EnableRutorSearch bool
|
||||
|
||||
// Torznab
|
||||
EnableTorznabSearch bool
|
||||
TorznabUrls []TorznabConfig
|
||||
|
||||
// TMDB
|
||||
TMDBSettings TMDBConfig
|
||||
|
||||
// BT Config
|
||||
EnableIPv6 bool
|
||||
DisableTCP bool
|
||||
DisableUTP bool
|
||||
DisableUPNP bool
|
||||
DisableDHT bool
|
||||
DisablePEX bool
|
||||
DisableUpload bool
|
||||
DownloadRateLimit int // in kb, 0 - inf
|
||||
UploadRateLimit int // in kb, 0 - inf
|
||||
ConnectionsLimit int
|
||||
PeersListenPort int
|
||||
|
||||
// HTTPS
|
||||
SslPort int
|
||||
SslCert string
|
||||
SslKey string
|
||||
|
||||
// Reader
|
||||
ResponsiveMode bool // enable Responsive reader (don't wait pieceComplete)
|
||||
|
||||
// FS
|
||||
ShowFSActiveTorr bool
|
||||
|
||||
// Storage preferences
|
||||
StoreSettingsInJson bool
|
||||
StoreViewedInJson bool
|
||||
|
||||
// P2P Proxy
|
||||
EnableProxy bool
|
||||
ProxyHosts []string
|
||||
}
|
||||
|
||||
func (v *BTSets) String() string {
|
||||
buf, _ := json.Marshal(v)
|
||||
return string(buf)
|
||||
}
|
||||
|
||||
var BTsets *BTSets
|
||||
|
||||
func SetBTSets(sets *BTSets) {
|
||||
if ReadOnly {
|
||||
return
|
||||
}
|
||||
// failsafe checks (use defaults)
|
||||
if sets.CacheSize == 0 {
|
||||
sets.CacheSize = 64 * 1024 * 1024
|
||||
}
|
||||
if sets.ConnectionsLimit == 0 {
|
||||
sets.ConnectionsLimit = 25
|
||||
}
|
||||
if sets.TorrentDisconnectTimeout == 0 {
|
||||
sets.TorrentDisconnectTimeout = 30
|
||||
}
|
||||
|
||||
if sets.ReaderReadAHead < 5 {
|
||||
sets.ReaderReadAHead = 5
|
||||
}
|
||||
if sets.ReaderReadAHead > 100 {
|
||||
sets.ReaderReadAHead = 100
|
||||
}
|
||||
|
||||
if sets.PreloadCache < 0 {
|
||||
sets.PreloadCache = 0
|
||||
}
|
||||
if sets.PreloadCache > 100 {
|
||||
sets.PreloadCache = 100
|
||||
}
|
||||
|
||||
if sets.TorrentsSavePath == "" {
|
||||
sets.UseDisk = false
|
||||
} else if sets.UseDisk {
|
||||
BTsets = sets
|
||||
|
||||
go filepath.WalkDir(sets.TorrentsSavePath, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() && strings.ToLower(d.Name()) == ".tsc" {
|
||||
BTsets.TorrentsSavePath = path
|
||||
log.TLogln("Find directory \"" + BTsets.TorrentsSavePath + "\", use as cache dir")
|
||||
return io.EOF
|
||||
}
|
||||
if d.IsDir() && strings.HasPrefix(d.Name(), ".") {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
BTsets = sets
|
||||
buf, err := json.Marshal(BTsets)
|
||||
if err != nil {
|
||||
log.TLogln("Error marshal btsets", err)
|
||||
return
|
||||
}
|
||||
tdb.Set("Settings", "BitTorr", buf)
|
||||
}
|
||||
|
||||
func SetDefaultConfig() {
|
||||
sets := new(BTSets)
|
||||
sets.CacheSize = 64 * 1024 * 1024 // 64 MB
|
||||
sets.PreloadCache = 50
|
||||
sets.ConnectionsLimit = 25
|
||||
sets.RetrackersMode = 1
|
||||
sets.TorrentDisconnectTimeout = 30
|
||||
sets.ReaderReadAHead = 95 // 95%
|
||||
sets.ResponsiveMode = true
|
||||
sets.ShowFSActiveTorr = true
|
||||
sets.StoreSettingsInJson = true
|
||||
// Set default TMDB settings
|
||||
sets.TMDBSettings = TMDBConfig{
|
||||
APIKey: "",
|
||||
APIURL: "https://api.themoviedb.org",
|
||||
ImageURL: "https://image.tmdb.org",
|
||||
ImageURLRu: "https://imagetmdb.com",
|
||||
}
|
||||
BTsets = sets
|
||||
if !ReadOnly {
|
||||
buf, err := json.Marshal(BTsets)
|
||||
if err != nil {
|
||||
log.TLogln("Error marshal btsets", err)
|
||||
return
|
||||
}
|
||||
tdb.Set("Settings", "BitTorr", buf)
|
||||
}
|
||||
//Proxy
|
||||
sets.EnableProxy = false
|
||||
sets.ProxyHosts = []string{"*themoviedb.org", "*tmdb.org", "rutor.info"}
|
||||
}
|
||||
|
||||
func loadBTSets() {
|
||||
buf := tdb.Get("Settings", "BitTorr")
|
||||
if len(buf) > 0 {
|
||||
err := json.Unmarshal(buf, &BTsets)
|
||||
if err == nil {
|
||||
if BTsets.ReaderReadAHead < 5 {
|
||||
BTsets.ReaderReadAHead = 5
|
||||
}
|
||||
// Set default TMDB settings if missing (for existing configs)
|
||||
if BTsets.TMDBSettings.APIURL == "" {
|
||||
BTsets.TMDBSettings = TMDBConfig{
|
||||
APIKey: "",
|
||||
APIURL: "https://api.themoviedb.org",
|
||||
ImageURL: "https://image.tmdb.org",
|
||||
ImageURLRu: "https://imagetmdb.com",
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
log.TLogln("Error unmarshal btsets", err)
|
||||
}
|
||||
// initialize defaults on error
|
||||
SetDefaultConfig()
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"server/log"
|
||||
|
||||
bolt "go.etcd.io/bbolt"
|
||||
)
|
||||
|
||||
type TDB struct {
|
||||
Path string
|
||||
db *bolt.DB
|
||||
}
|
||||
|
||||
var globalBboltDB TorrServerDB
|
||||
|
||||
func NewTDB() TorrServerDB {
|
||||
if globalBboltDB != nil {
|
||||
return globalBboltDB // Return existing instance
|
||||
}
|
||||
db, err := bolt.Open(filepath.Join(Path, "config.db"), 0o666, &bolt.Options{Timeout: 5 * time.Second})
|
||||
if err != nil {
|
||||
log.TLogln(err)
|
||||
return nil
|
||||
}
|
||||
|
||||
tdb := new(TDB)
|
||||
tdb.db = db
|
||||
tdb.Path = Path
|
||||
globalBboltDB = tdb
|
||||
return globalBboltDB
|
||||
}
|
||||
|
||||
func (v *TDB) CloseDB() {
|
||||
if v.db != nil {
|
||||
v.db.Close()
|
||||
v.db = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (v *TDB) Get(xpath, name string) []byte {
|
||||
spath := strings.Split(xpath, "/")
|
||||
if len(spath) == 0 {
|
||||
return nil
|
||||
}
|
||||
var ret []byte
|
||||
err := v.db.View(func(tx *bolt.Tx) error {
|
||||
buckt := tx.Bucket([]byte(spath[0]))
|
||||
if buckt == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for i, p := range spath {
|
||||
if i == 0 {
|
||||
continue
|
||||
}
|
||||
buckt = buckt.Bucket([]byte(p))
|
||||
if buckt == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
data := buckt.Get([]byte(name))
|
||||
if data != nil {
|
||||
// CRITICAL: Copy the data before returning
|
||||
ret = make([]byte, len(data))
|
||||
copy(ret, data)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
log.TLogln("Error get sets", xpath+"/"+name, ", error:", err)
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
func (v *TDB) Set(xpath, name string, value []byte) {
|
||||
spath := strings.Split(xpath, "/")
|
||||
if len(spath) == 0 {
|
||||
return
|
||||
}
|
||||
err := v.db.Update(func(tx *bolt.Tx) error {
|
||||
buckt, err := tx.CreateBucketIfNotExists([]byte(spath[0]))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for i, p := range spath {
|
||||
if i == 0 {
|
||||
continue
|
||||
}
|
||||
buckt, err = buckt.CreateBucketIfNotExists([]byte(p))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return buckt.Put([]byte(name), value)
|
||||
})
|
||||
if err != nil {
|
||||
log.TLogln("Error put sets", xpath+"/"+name, ", error:", err)
|
||||
log.TLogln("value:", value)
|
||||
}
|
||||
}
|
||||
|
||||
func (v *TDB) List(xpath string) []string {
|
||||
spath := strings.Split(xpath, "/")
|
||||
if len(spath) == 0 {
|
||||
return nil
|
||||
}
|
||||
var ret []string
|
||||
err := v.db.View(func(tx *bolt.Tx) error {
|
||||
buckt := tx.Bucket([]byte(spath[0]))
|
||||
if buckt == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for i, p := range spath {
|
||||
if i == 0 {
|
||||
continue
|
||||
}
|
||||
buckt = buckt.Bucket([]byte(p))
|
||||
if buckt == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
buckt.ForEach(func(k, _ []byte) error {
|
||||
if len(k) > 0 {
|
||||
ret = append(ret, string(k))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
log.TLogln("Error list sets", xpath, ", error:", err)
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
func (v *TDB) Rem(xpath, name string) {
|
||||
spath := strings.Split(xpath, "/")
|
||||
if len(spath) == 0 {
|
||||
return
|
||||
}
|
||||
err := v.db.Update(func(tx *bolt.Tx) error {
|
||||
buckt := tx.Bucket([]byte(spath[0]))
|
||||
if buckt == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for i, p := range spath {
|
||||
if i == 0 {
|
||||
continue
|
||||
}
|
||||
buckt = buckt.Bucket([]byte(p))
|
||||
if buckt == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return buckt.Delete([]byte(name))
|
||||
})
|
||||
if err != nil {
|
||||
log.TLogln("Error rem sets", xpath+"/"+name, ", error:", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (v *TDB) Clear(xPath string) {
|
||||
spath := strings.Split(xPath, "/")
|
||||
if len(spath) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
err := v.db.Update(func(tx *bolt.Tx) error {
|
||||
buckt := tx.Bucket([]byte(spath[0]))
|
||||
if buckt == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for i, p := range spath {
|
||||
if i == 0 {
|
||||
continue
|
||||
}
|
||||
buckt = buckt.Bucket([]byte(p))
|
||||
if buckt == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Delete all entries in this bucket
|
||||
return buckt.ForEach(func(k, _ []byte) error {
|
||||
return buckt.Delete(k)
|
||||
})
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.TLogln("Error clear xPath", xPath, ", error:", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"server/log"
|
||||
)
|
||||
|
||||
type DBReadCache struct {
|
||||
db TorrServerDB
|
||||
listCache map[string][]string
|
||||
listCacheMutex sync.RWMutex
|
||||
dataCache map[[2]string][]byte
|
||||
dataCacheMutex sync.RWMutex
|
||||
}
|
||||
|
||||
func NewDBReadCache(db TorrServerDB) TorrServerDB {
|
||||
cdb := &DBReadCache{
|
||||
db: db,
|
||||
listCache: map[string][]string{},
|
||||
dataCache: map[[2]string][]byte{},
|
||||
}
|
||||
return cdb
|
||||
}
|
||||
|
||||
func (v *DBReadCache) CloseDB() {
|
||||
v.db.CloseDB()
|
||||
v.db = nil
|
||||
v.listCache = nil
|
||||
v.dataCache = nil
|
||||
}
|
||||
|
||||
func (v *DBReadCache) Get(xPath, name string) []byte {
|
||||
if v.dataCache == nil {
|
||||
return nil // или panic, или возвращаем ошибку
|
||||
}
|
||||
cacheKey := v.makeDataCacheKey(xPath, name)
|
||||
|
||||
v.dataCacheMutex.RLock()
|
||||
if data, ok := v.dataCache[cacheKey]; ok {
|
||||
defer v.dataCacheMutex.RUnlock()
|
||||
return data
|
||||
}
|
||||
v.dataCacheMutex.RUnlock()
|
||||
|
||||
// Если база данных закрыта, не пытаемся к ней обращаться
|
||||
if v.db == nil {
|
||||
return nil
|
||||
}
|
||||
data := v.db.Get(xPath, name)
|
||||
|
||||
v.dataCacheMutex.Lock()
|
||||
if v.dataCache != nil { // Двойная проверка
|
||||
v.dataCache[cacheKey] = data
|
||||
}
|
||||
v.dataCacheMutex.Unlock()
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
func (v *DBReadCache) Set(xPath, name string, value []byte) {
|
||||
if ReadOnly {
|
||||
if IsDebug() {
|
||||
log.TLogln("DBReadCache.Set: Read-only DB mode!", name)
|
||||
}
|
||||
return
|
||||
}
|
||||
// Проверяем, не закрыта ли база
|
||||
if v.dataCache == nil || v.db == nil {
|
||||
log.TLogln("DBReadCache.Set: no dataCache or DB is closed, cannot set", name)
|
||||
return
|
||||
}
|
||||
|
||||
cacheKey := v.makeDataCacheKey(xPath, name)
|
||||
|
||||
v.dataCacheMutex.Lock()
|
||||
if v.dataCache != nil { // Двойная проверка
|
||||
v.dataCache[cacheKey] = value
|
||||
}
|
||||
v.dataCacheMutex.Unlock()
|
||||
|
||||
if v.listCache != nil {
|
||||
delete(v.listCache, xPath)
|
||||
}
|
||||
|
||||
v.db.Set(xPath, name, value)
|
||||
}
|
||||
|
||||
func (v *DBReadCache) List(xPath string) []string {
|
||||
if v.listCache == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
v.listCacheMutex.RLock()
|
||||
if names, ok := v.listCache[xPath]; ok {
|
||||
defer v.listCacheMutex.RUnlock()
|
||||
return names
|
||||
}
|
||||
v.listCacheMutex.RUnlock()
|
||||
|
||||
// Проверяем, не закрыта ли база
|
||||
if v.db == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
names := v.db.List(xPath)
|
||||
|
||||
v.listCacheMutex.Lock()
|
||||
if v.listCache != nil { // Двойная проверка
|
||||
v.listCache[xPath] = names
|
||||
}
|
||||
v.listCacheMutex.Unlock()
|
||||
|
||||
return names
|
||||
}
|
||||
|
||||
func (v *DBReadCache) Rem(xPath, name string) {
|
||||
if ReadOnly {
|
||||
if IsDebug() {
|
||||
log.TLogln("DBReadCache.Rem: Read-only DB mode!", name)
|
||||
}
|
||||
return
|
||||
}
|
||||
// Проверяем, не закрыта ли база
|
||||
if v.dataCache == nil || v.db == nil {
|
||||
log.TLogln("DBReadCache.Rem: no dataCache or DB is closed, cannot remove", name)
|
||||
return
|
||||
}
|
||||
|
||||
cacheKey := v.makeDataCacheKey(xPath, name)
|
||||
|
||||
v.dataCacheMutex.Lock()
|
||||
if v.dataCache != nil {
|
||||
delete(v.dataCache, cacheKey)
|
||||
}
|
||||
v.dataCacheMutex.Unlock()
|
||||
|
||||
if v.listCache != nil {
|
||||
delete(v.listCache, xPath)
|
||||
}
|
||||
|
||||
v.db.Rem(xPath, name)
|
||||
}
|
||||
|
||||
func (v *DBReadCache) Clear(xPath string) {
|
||||
if ReadOnly {
|
||||
if IsDebug() {
|
||||
log.TLogln("DBReadCache.Clear: Read-only DB mode!", xPath)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Clear from underlying DB first
|
||||
if v.db != nil {
|
||||
v.db.Clear(xPath)
|
||||
}
|
||||
|
||||
// Clear cache
|
||||
v.listCacheMutex.Lock()
|
||||
delete(v.listCache, xPath)
|
||||
v.listCacheMutex.Unlock()
|
||||
|
||||
// Clear data cache entries for this xPath
|
||||
v.dataCacheMutex.Lock()
|
||||
for key := range v.dataCache {
|
||||
if key[0] == xPath {
|
||||
delete(v.dataCache, key)
|
||||
}
|
||||
}
|
||||
v.dataCacheMutex.Unlock()
|
||||
}
|
||||
|
||||
func (v *DBReadCache) makeDataCacheKey(xPath, name string) [2]string {
|
||||
return [2]string{xPath, name}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"server/log"
|
||||
)
|
||||
|
||||
type JsonDB struct {
|
||||
Path string
|
||||
filenameDelimiter string
|
||||
filenameExtension string
|
||||
fileMode fs.FileMode
|
||||
xPathDelimeter string
|
||||
}
|
||||
|
||||
var globalJsonDB TorrServerDB
|
||||
var jsonDbLocks = make(map[string]*sync.Mutex)
|
||||
var jsonDbLocksMutex sync.Mutex
|
||||
|
||||
func NewJsonDB() TorrServerDB {
|
||||
if globalJsonDB != nil {
|
||||
return globalJsonDB
|
||||
}
|
||||
globalJsonDB := &JsonDB{
|
||||
Path: Path,
|
||||
filenameDelimiter: ".",
|
||||
filenameExtension: ".json",
|
||||
fileMode: fs.FileMode(0o666),
|
||||
xPathDelimeter: "/",
|
||||
}
|
||||
return globalJsonDB
|
||||
}
|
||||
|
||||
func (v *JsonDB) CloseDB() {
|
||||
// Not necessary
|
||||
}
|
||||
|
||||
func (v *JsonDB) Set(xPath, name string, value []byte) {
|
||||
var err error = nil
|
||||
jsonObj := map[string]interface{}{}
|
||||
if err := json.Unmarshal(value, &jsonObj); err == nil {
|
||||
if filename, err := v.xPathToFilename(xPath); err == nil {
|
||||
v.lock(filename)
|
||||
defer v.unlock(filename)
|
||||
if root, err := v.readJsonFileAsMap(filename); err == nil {
|
||||
root[name] = jsonObj
|
||||
if err = v.writeMapAsJsonFile(filename, root); err == nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
v.log(fmt.Sprintf("Set: error writing entry %s->%s", xPath, name), err)
|
||||
}
|
||||
|
||||
func (v *JsonDB) Get(xPath, name string) []byte {
|
||||
var err error = nil
|
||||
if filename, err := v.xPathToFilename(xPath); err == nil {
|
||||
v.lock(filename)
|
||||
defer v.unlock(filename)
|
||||
if root, err := v.readJsonFileAsMap(filename); err == nil {
|
||||
if jsonData, ok := root[name]; ok {
|
||||
if byteData, err := json.Marshal(jsonData); err == nil {
|
||||
// Return a copy to be safe
|
||||
data := make([]byte, len(byteData))
|
||||
copy(data, byteData)
|
||||
return data
|
||||
}
|
||||
} else {
|
||||
// We assume this is not 'error' but 'no entry' which is normal
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
v.log(fmt.Sprintf("Get: error reading entry %s->%s", xPath, name), err)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *JsonDB) List(xPath string) []string {
|
||||
var err error = nil
|
||||
if filename, err := v.xPathToFilename(xPath); err == nil {
|
||||
v.lock(filename)
|
||||
defer v.unlock(filename)
|
||||
if root, err := v.readJsonFileAsMap(filename); err == nil {
|
||||
nameList := make([]string, 0, len(root))
|
||||
for k := range root {
|
||||
nameList = append(nameList, k)
|
||||
}
|
||||
return nameList
|
||||
}
|
||||
}
|
||||
v.log(fmt.Sprintf("List: error reading entries in xPath %s", xPath), err)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *JsonDB) Rem(xPath, name string) {
|
||||
var err error = nil
|
||||
if filename, err := v.xPathToFilename(xPath); err == nil {
|
||||
v.lock(filename)
|
||||
defer v.unlock(filename)
|
||||
if root, err := v.readJsonFileAsMap(filename); err == nil {
|
||||
delete(root, name)
|
||||
if err = v.writeMapAsJsonFile(filename, root); err == nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
v.log(fmt.Sprintf("Rem: error removing entry %s->%s", xPath, name), err)
|
||||
}
|
||||
|
||||
func (v *JsonDB) Clear(xPath string) {
|
||||
filename, err := v.xPathToFilename(xPath)
|
||||
if err != nil {
|
||||
v.log(fmt.Sprintf("Clear: error converting xPath %s to filename: %v", xPath, err))
|
||||
return
|
||||
}
|
||||
|
||||
v.lock(filename)
|
||||
defer v.unlock(filename)
|
||||
|
||||
path := filepath.Join(v.Path, filename)
|
||||
emptyData := []byte("{}")
|
||||
|
||||
if err := os.WriteFile(path, emptyData, v.fileMode); err != nil {
|
||||
v.log(fmt.Sprintf("Clear: error writing empty file for xPath %s: %v", xPath, err))
|
||||
}
|
||||
}
|
||||
|
||||
func (v *JsonDB) lock(filename string) {
|
||||
jsonDbLocksMutex.Lock()
|
||||
mtx, ok := jsonDbLocks[filename]
|
||||
if !ok {
|
||||
mtx = &sync.Mutex{}
|
||||
jsonDbLocks[filename] = mtx
|
||||
}
|
||||
jsonDbLocksMutex.Unlock()
|
||||
mtx.Lock()
|
||||
}
|
||||
|
||||
func (v *JsonDB) unlock(filename string) {
|
||||
jsonDbLocksMutex.Lock()
|
||||
if mtx, ok := jsonDbLocks[filename]; ok {
|
||||
mtx.Unlock()
|
||||
}
|
||||
jsonDbLocksMutex.Unlock()
|
||||
}
|
||||
|
||||
func (v *JsonDB) xPathToFilename(xPath string) (string, error) {
|
||||
if pathComponents := strings.Split(xPath, v.xPathDelimeter); len(pathComponents) > 0 {
|
||||
return strings.ToLower(strings.Join(pathComponents, v.filenameDelimiter) + v.filenameExtension), nil
|
||||
}
|
||||
return "", errors.New("xPath has no components")
|
||||
}
|
||||
|
||||
func (v *JsonDB) readJsonFileAsMap(filename string) (map[string]interface{}, error) {
|
||||
var err error = nil
|
||||
jsonData := map[string]interface{}{}
|
||||
path := filepath.Join(v.Path, filename)
|
||||
if fileData, err := os.ReadFile(path); err == nil {
|
||||
if err = json.Unmarshal(fileData, &jsonData); err != nil {
|
||||
v.log(fmt.Sprintf("readJsonFileAsMap(%s) fileData: %s error", filename, fileData), err)
|
||||
}
|
||||
}
|
||||
return jsonData, err
|
||||
}
|
||||
|
||||
func (v *JsonDB) writeMapAsJsonFile(filename string, o map[string]interface{}) error {
|
||||
var err error = nil
|
||||
path := filepath.Join(v.Path, filename)
|
||||
if fileData, err := json.MarshalIndent(o, "", " "); err == nil {
|
||||
if err = os.WriteFile(path, fileData, v.fileMode); err != nil {
|
||||
v.log(fmt.Sprintf("writeMapAsJsonFile path: %s, fileMode: %s, fileData: %s error", path, v.fileMode, fileData), err)
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (v *JsonDB) log(s string, params ...interface{}) {
|
||||
if len(params) > 0 {
|
||||
log.TLogln(fmt.Sprintf("JsonDB: %s: %s", s, fmt.Sprint(params...)))
|
||||
} else {
|
||||
log.TLogln(fmt.Sprintf("JsonDB: %s", s))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"time"
|
||||
|
||||
"server/log"
|
||||
"server/web/api/utils"
|
||||
|
||||
bolt "go.etcd.io/bbolt"
|
||||
)
|
||||
|
||||
var dbTorrentsName = []byte("Torrents")
|
||||
|
||||
type torrentBackupDB struct {
|
||||
Name string
|
||||
Magnet string
|
||||
InfoBytes []byte
|
||||
Hash string
|
||||
Size int64
|
||||
Timestamp int64
|
||||
}
|
||||
|
||||
// Migrate from torrserver.db to config.db
|
||||
// TODO: migrate categories and data too
|
||||
func MigrateTorrents() {
|
||||
if _, err := os.Lstat(filepath.Join(Path, "torrserver.db")); os.IsNotExist(err) {
|
||||
return
|
||||
}
|
||||
|
||||
db, err := bolt.Open(filepath.Join(Path, "torrserver.db"), 0o666, &bolt.Options{Timeout: 5 * time.Second})
|
||||
if err != nil {
|
||||
log.TLogln("MigrateTorrents", err)
|
||||
return
|
||||
}
|
||||
|
||||
torrs := make([]*torrentBackupDB, 0)
|
||||
err = db.View(func(tx *bolt.Tx) error {
|
||||
tdb := tx.Bucket(dbTorrentsName)
|
||||
if tdb == nil {
|
||||
return nil
|
||||
}
|
||||
c := tdb.Cursor()
|
||||
for h, _ := c.First(); h != nil; h, _ = c.Next() {
|
||||
hdb := tdb.Bucket(h)
|
||||
if hdb != nil {
|
||||
torr := new(torrentBackupDB)
|
||||
torr.Hash = string(h)
|
||||
tmp := hdb.Get([]byte("Name"))
|
||||
if tmp == nil {
|
||||
return fmt.Errorf("error load torrent")
|
||||
}
|
||||
torr.Name = string(tmp)
|
||||
|
||||
tmp = hdb.Get([]byte("Link"))
|
||||
if tmp == nil {
|
||||
return fmt.Errorf("error load torrent")
|
||||
}
|
||||
torr.Magnet = string(tmp)
|
||||
|
||||
tmp = hdb.Get([]byte("Size"))
|
||||
if tmp == nil {
|
||||
return fmt.Errorf("error load torrent")
|
||||
}
|
||||
torr.Size = b2i(tmp)
|
||||
|
||||
tmp = hdb.Get([]byte("Timestamp"))
|
||||
if tmp == nil {
|
||||
return fmt.Errorf("error load torrent")
|
||||
}
|
||||
torr.Timestamp = b2i(tmp)
|
||||
|
||||
torrs = append(torrs, torr)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
db.Close()
|
||||
if err == nil && len(torrs) > 0 {
|
||||
for _, torr := range torrs {
|
||||
spec, err := utils.ParseLink(torr.Magnet)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
title := torr.Name
|
||||
if len(spec.DisplayName) > len(title) {
|
||||
title = spec.DisplayName
|
||||
}
|
||||
log.TLogln("Migrate torrent", torr.Name, torr.Hash, torr.Size)
|
||||
AddTorrent(&TorrentDB{
|
||||
TorrentSpec: spec,
|
||||
Title: title,
|
||||
Timestamp: torr.Timestamp,
|
||||
Size: torr.Size,
|
||||
})
|
||||
}
|
||||
}
|
||||
os.Remove(filepath.Join(Path, "torrserver.db"))
|
||||
}
|
||||
|
||||
// MigrateSettingsToJson migrates Settings from BBolt to JSON
|
||||
func MigrateSettingsToJson(bboltDB, jsonDB TorrServerDB) error {
|
||||
// if BTsets != nil {
|
||||
// return errors.New("migration must be called before initializing BTSets")
|
||||
// }
|
||||
migrated, err := MigrateSingle(bboltDB, jsonDB, "Settings", "BitTorr")
|
||||
if migrated {
|
||||
log.TLogln("Settings migrated from BBolt to JSON")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// MigrateSettingsFromJson migrates Settings from JSON to BBolt
|
||||
func MigrateSettingsFromJson(jsonDB, bboltDB TorrServerDB) error {
|
||||
// if BTsets != nil {
|
||||
// return errors.New("migration must be called before initializing BTSets")
|
||||
// }
|
||||
migrated, err := MigrateSingle(jsonDB, bboltDB, "Settings", "BitTorr")
|
||||
if migrated {
|
||||
log.TLogln("Settings migrated from JSON to BBolt")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// MigrateViewedToJson migrates Viewed data from BBolt to JSON
|
||||
func MigrateViewedToJson(bboltDB, jsonDB TorrServerDB) error {
|
||||
migrated, skipped, err := MigrateAll(bboltDB, jsonDB, "Viewed")
|
||||
log.TLogln(fmt.Sprintf("Viewed->JSON: %d migrated, %d skipped", migrated, skipped))
|
||||
return err
|
||||
}
|
||||
|
||||
// MigrateViewedFromJson migrates Viewed data from JSON to BBolt
|
||||
func MigrateViewedFromJson(jsonDB, bboltDB TorrServerDB) error {
|
||||
migrated, skipped, err := MigrateAll(jsonDB, bboltDB, "Viewed")
|
||||
log.TLogln(fmt.Sprintf("Viewed->BBolt: %d migrated, %d skipped", migrated, skipped))
|
||||
return err
|
||||
}
|
||||
|
||||
// MigrateSingle migrates a single entry with validation
|
||||
// Returns: (migrated bool, error)
|
||||
func MigrateSingle(source, target TorrServerDB, xpath, name string) (bool, error) {
|
||||
sourceData := source.Get(xpath, name)
|
||||
if sourceData == nil {
|
||||
if IsDebug() {
|
||||
log.TLogln(fmt.Sprintf("No data to migrate for %s/%s", xpath, name))
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
targetData := target.Get(xpath, name)
|
||||
if targetData != nil {
|
||||
// Check if already identical
|
||||
if equal, err := isByteArraysEqualJson(sourceData, targetData); err == nil && equal {
|
||||
if IsDebug() {
|
||||
log.TLogln(fmt.Sprintf("Skipping %s/%s (already identical)", xpath, name))
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Perform migration
|
||||
target.Set(xpath, name, sourceData)
|
||||
if IsDebug() {
|
||||
log.TLogln(fmt.Sprintf("Migrating %s/%s", xpath, name))
|
||||
}
|
||||
|
||||
// Verify migration
|
||||
if err := verifyMigration(source, target, xpath, name, sourceData); err != nil {
|
||||
return false, fmt.Errorf("migration verification failed for %s/%s: %w", xpath, name, err)
|
||||
}
|
||||
if IsDebug() {
|
||||
log.TLogln(fmt.Sprintf("Successfully migrated %s/%s", xpath, name))
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// MigrateAll migrates all entries in an xpath with validation
|
||||
// Returns: (migratedCount, skippedCount, error)
|
||||
func MigrateAll(source, target TorrServerDB, xpath string) (int, int, error) {
|
||||
names := source.List(xpath)
|
||||
if len(names) == 0 {
|
||||
if IsDebug() {
|
||||
log.TLogln(fmt.Sprintf("No entries to migrate for %s", xpath))
|
||||
}
|
||||
return 0, 0, nil
|
||||
}
|
||||
|
||||
migratedCount := 0
|
||||
skippedCount := 0
|
||||
var firstError error
|
||||
if IsDebug() {
|
||||
log.TLogln(fmt.Sprintf("Starting migration of %d %s entries", len(names), xpath))
|
||||
}
|
||||
for i, name := range names {
|
||||
sourceData := source.Get(xpath, name)
|
||||
if sourceData == nil {
|
||||
skippedCount++
|
||||
if IsDebug() {
|
||||
log.TLogln(fmt.Sprintf("[%d/%d] Skipping %s/%s (no data in source)",
|
||||
i+1, len(names), xpath, name))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
targetData := target.Get(xpath, name)
|
||||
if targetData != nil {
|
||||
// Check if already identical
|
||||
if equal, err := isByteArraysEqualJson(sourceData, targetData); err == nil && equal {
|
||||
skippedCount++
|
||||
if IsDebug() {
|
||||
log.TLogln(fmt.Sprintf("[%d/%d] Skipping %s/%s (already identical)",
|
||||
i+1, len(names), xpath, name))
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Perform migration
|
||||
target.Set(xpath, name, sourceData)
|
||||
|
||||
// Verify migration
|
||||
if err := verifyMigration(source, target, xpath, name, sourceData); err != nil {
|
||||
log.TLogln(fmt.Sprintf("[%d/%d] Migration failed for %s/%s: %v",
|
||||
i+1, len(names), xpath, name, err))
|
||||
if firstError == nil {
|
||||
firstError = err
|
||||
}
|
||||
} else {
|
||||
migratedCount++
|
||||
if IsDebug() {
|
||||
log.TLogln(fmt.Sprintf("[%d/%d] Successfully migrated %s/%s",
|
||||
i+1, len(names), xpath, name))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
summary := fmt.Sprintf("%s migration complete: %d migrated, %d skipped",
|
||||
xpath, migratedCount, skippedCount)
|
||||
if firstError != nil {
|
||||
summary += fmt.Sprintf(", 1+ errors (first: %v)", firstError)
|
||||
}
|
||||
if IsDebug() {
|
||||
log.TLogln(summary)
|
||||
}
|
||||
|
||||
return migratedCount, skippedCount, firstError
|
||||
}
|
||||
|
||||
// SmartMigrate - keep for manual/advanced use
|
||||
func SmartMigrate(bboltDB, jsonDB TorrServerDB, forceDirection string) error {
|
||||
// if BTsets != nil {
|
||||
// return errors.New("migration must be called before initializing BTSets")
|
||||
// }
|
||||
switch forceDirection {
|
||||
case "viewed_to_json":
|
||||
return MigrateViewedToJson(bboltDB, jsonDB)
|
||||
case "viewed_to_bbolt":
|
||||
return MigrateViewedFromJson(jsonDB, bboltDB)
|
||||
case "settings_to_json":
|
||||
return MigrateSettingsToJson(bboltDB, jsonDB)
|
||||
case "settings_to_bbolt":
|
||||
return MigrateSettingsFromJson(jsonDB, bboltDB)
|
||||
case "sync_both":
|
||||
// Simple sync: copy missing data both ways
|
||||
if err := migrateMissing(bboltDB, jsonDB, "Settings", "BitTorr"); err != nil {
|
||||
return err
|
||||
}
|
||||
return syncViewedSimple(bboltDB, jsonDB)
|
||||
default:
|
||||
return fmt.Errorf("unknown migration direction: %s", forceDirection)
|
||||
}
|
||||
}
|
||||
|
||||
func isByteArraysEqualJson(a, b []byte) (bool, error) {
|
||||
if len(a) == 0 && len(b) == 0 {
|
||||
return true, nil
|
||||
}
|
||||
if len(a) == 0 || len(b) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
// Quick check: same length and byte equality
|
||||
if len(a) == len(b) {
|
||||
// Fast path: byte-by-byte comparison
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
break // Need to parse as JSON
|
||||
}
|
||||
}
|
||||
// If we get here, bytes are identical
|
||||
return true, nil
|
||||
}
|
||||
// Parse as JSON for structural comparison
|
||||
var objectA, objectB interface{}
|
||||
|
||||
if err := json.Unmarshal(a, &objectA); err != nil {
|
||||
return false, fmt.Errorf("error unmarshalling A: %w", err)
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(b, &objectB); err != nil {
|
||||
return false, fmt.Errorf("error unmarshalling B: %w", err)
|
||||
}
|
||||
|
||||
return reflect.DeepEqual(objectA, objectB), nil
|
||||
}
|
||||
|
||||
// Optimized version for performance
|
||||
func isByteArraysEqualJsonOptimized(a, b []byte) (bool, error) {
|
||||
// Fast paths
|
||||
if a == nil && b == nil {
|
||||
return true, nil
|
||||
}
|
||||
if len(a) != len(b) {
|
||||
return false, nil
|
||||
}
|
||||
if len(a) == 0 {
|
||||
return true, nil
|
||||
}
|
||||
// Byte equality (fastest check)
|
||||
equal := true
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
equal = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if equal {
|
||||
return true, nil
|
||||
}
|
||||
// Parse as JSON (slower but accurate)
|
||||
return isByteArraysEqualJson(a, b)
|
||||
}
|
||||
|
||||
func verifyMigration(source, target TorrServerDB, xpath, name string, originalData []byte) error {
|
||||
// Get migrated data
|
||||
migratedData := target.Get(xpath, name)
|
||||
if migratedData == nil {
|
||||
return fmt.Errorf("migration failed: no data after migration for %s/%s", xpath, name)
|
||||
}
|
||||
// Compare with original
|
||||
if equal, err := isByteArraysEqualJsonOptimized(originalData, migratedData); err != nil {
|
||||
return fmt.Errorf("verification failed for %s/%s: %w", xpath, name, err)
|
||||
} else if !equal {
|
||||
return fmt.Errorf("data mismatch after migration for %s/%s", xpath, name)
|
||||
}
|
||||
if IsDebug() {
|
||||
log.TLogln(fmt.Sprintf("Verified migration of %s/%s", xpath, name))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func b2i(v []byte) int64 {
|
||||
return int64(binary.BigEndian.Uint64(v))
|
||||
}
|
||||
|
||||
func migrateMissing(db1, db2 TorrServerDB, xpath, name string) error {
|
||||
// Copy from db1 to db2 if missing
|
||||
if db2.Get(xpath, name) == nil {
|
||||
if data := db1.Get(xpath, name); data != nil {
|
||||
db2.Set(xpath, name, data)
|
||||
}
|
||||
}
|
||||
// Copy from db2 to db1 if missing
|
||||
if db1.Get(xpath, name) == nil {
|
||||
if data := db2.Get(xpath, name); data != nil {
|
||||
db1.Set(xpath, name, data)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncViewedSimple(bboltDB, jsonDB TorrServerDB) error {
|
||||
// Get all hashes from both
|
||||
bboltHashes := bboltDB.List("Viewed")
|
||||
jsonHashes := jsonDB.List("Viewed")
|
||||
|
||||
allHashes := make(map[string]bool)
|
||||
for _, h := range bboltHashes {
|
||||
allHashes[h] = true
|
||||
}
|
||||
for _, h := range jsonHashes {
|
||||
allHashes[h] = true
|
||||
}
|
||||
|
||||
// For each hash, ensure it exists in both with merged data
|
||||
for hash := range allHashes {
|
||||
bboltData := bboltDB.Get("Viewed", hash)
|
||||
jsonData := jsonDB.Get("Viewed", hash)
|
||||
|
||||
merged := mergeViewedDataSimple(bboltData, jsonData)
|
||||
if merged != nil {
|
||||
bboltDB.Set("Viewed", hash, merged)
|
||||
jsonDB.Set("Viewed", hash, merged)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func mergeViewedDataSimple(data1, data2 []byte) []byte {
|
||||
if data1 == nil && data2 == nil {
|
||||
return nil
|
||||
}
|
||||
if data1 == nil {
|
||||
return data2
|
||||
}
|
||||
if data2 == nil {
|
||||
return data1
|
||||
}
|
||||
|
||||
// Try to merge
|
||||
var indices1, indices2 map[int]struct{}
|
||||
json.Unmarshal(data1, &indices1)
|
||||
json.Unmarshal(data2, &indices2)
|
||||
|
||||
merged := make(map[int]struct{})
|
||||
for idx := range indices1 {
|
||||
merged[idx] = struct{}{}
|
||||
}
|
||||
for idx := range indices2 {
|
||||
merged[idx] = struct{}{}
|
||||
}
|
||||
|
||||
result, _ := json.Marshal(merged)
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"server/log"
|
||||
)
|
||||
|
||||
// Add a global lock for database operations during migration
|
||||
var dbMigrationLock sync.RWMutex
|
||||
|
||||
func IsDebug() bool {
|
||||
if BTsets != nil {
|
||||
return BTsets.EnableDebug
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var (
|
||||
tdb TorrServerDB
|
||||
Path string
|
||||
IP string
|
||||
Port string
|
||||
Ssl bool
|
||||
SslPort string
|
||||
ReadOnly bool
|
||||
HttpAuth bool
|
||||
SearchWA bool
|
||||
PubIPv4 string
|
||||
PubIPv6 string
|
||||
TorAddr string
|
||||
MaxSize int64
|
||||
)
|
||||
|
||||
func InitSets(readOnly, searchWA bool) {
|
||||
ReadOnly = readOnly
|
||||
SearchWA = searchWA
|
||||
|
||||
bboltDB := NewTDB()
|
||||
if bboltDB == nil {
|
||||
log.TLogln("Error open bboltDB:", filepath.Join(Path, "config.db"))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
jsonDB := NewJsonDB()
|
||||
if jsonDB == nil {
|
||||
log.TLogln("Error open jsonDB")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Optional forced migration (for manual control)
|
||||
if migrationMode := os.Getenv("TS_MIGRATION_MODE"); migrationMode != "" {
|
||||
log.TLogln(fmt.Sprintf("Executing forced migration: %s", migrationMode))
|
||||
if err := SmartMigrate(bboltDB, jsonDB, migrationMode); err != nil {
|
||||
log.TLogln("Migration warning:", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Determine storage preferences
|
||||
settingsStoragePref, viewedStoragePref := determineStoragePreferences(bboltDB, jsonDB)
|
||||
|
||||
// Apply migrations (clean, one-way)
|
||||
applyCleanMigrations(bboltDB, jsonDB, settingsStoragePref, viewedStoragePref)
|
||||
|
||||
// Setup routing
|
||||
setupDatabaseRouting(bboltDB, jsonDB, settingsStoragePref, viewedStoragePref)
|
||||
|
||||
// Load settings
|
||||
loadBTSets()
|
||||
|
||||
// Update preferences if they changed
|
||||
if BTsets != nil && (BTsets.StoreSettingsInJson != settingsStoragePref || BTsets.StoreViewedInJson != viewedStoragePref) {
|
||||
BTsets.StoreSettingsInJson = settingsStoragePref
|
||||
BTsets.StoreViewedInJson = viewedStoragePref
|
||||
SetBTSets(BTsets)
|
||||
}
|
||||
|
||||
// Migrate old torrents
|
||||
MigrateTorrents()
|
||||
|
||||
logConfiguration(settingsStoragePref, viewedStoragePref)
|
||||
}
|
||||
|
||||
func determineStoragePreferences(bboltDB, jsonDB TorrServerDB) (settingsInJson, viewedInJson bool) {
|
||||
// Try to load existing settings first
|
||||
if existing := loadExistingSettings(bboltDB, jsonDB); existing != nil {
|
||||
if IsDebug() {
|
||||
log.TLogln(fmt.Sprintf("Found settings: StoreSettingsInJson=%v, StoreViewedInJson=%v",
|
||||
existing.StoreSettingsInJson, existing.StoreViewedInJson))
|
||||
}
|
||||
// Check if these are actually set or just default zero values
|
||||
// For now, trust the stored values
|
||||
return existing.StoreSettingsInJson, existing.StoreViewedInJson
|
||||
}
|
||||
|
||||
// Defaults (if not set by user)
|
||||
settingsInJson = true // JSON for settings (easy editable)
|
||||
viewedInJson = false // BBolt for viewed (performance)
|
||||
|
||||
// Environment overrides
|
||||
if env := os.Getenv("TS_SETTINGS_STORAGE"); env != "" {
|
||||
settingsInJson = (env == "json")
|
||||
}
|
||||
if env := os.Getenv("TS_VIEWED_STORAGE"); env != "" {
|
||||
viewedInJson = (env == "json")
|
||||
}
|
||||
|
||||
if IsDebug() {
|
||||
log.TLogln(fmt.Sprintf("Using flags: settingsInJson=%v, viewedInJson=%v",
|
||||
settingsInJson, viewedInJson))
|
||||
}
|
||||
return settingsInJson, viewedInJson
|
||||
}
|
||||
|
||||
func loadExistingSettings(bboltDB, jsonDB TorrServerDB) *BTSets {
|
||||
// Try JSON first
|
||||
if buf := jsonDB.Get("Settings", "BitTorr"); buf != nil {
|
||||
var sets BTSets
|
||||
if err := json.Unmarshal(buf, &sets); err == nil {
|
||||
return &sets
|
||||
}
|
||||
}
|
||||
// Try BBolt
|
||||
if buf := bboltDB.Get("Settings", "BitTorr"); buf != nil {
|
||||
var sets BTSets
|
||||
if err := json.Unmarshal(buf, &sets); err == nil {
|
||||
return &sets
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// func loadExistingSettingsDebug(bboltDB, jsonDB TorrServerDB) *BTSets {
|
||||
// // Try JSON first
|
||||
// if buf := jsonDB.Get("Settings", "BitTorr"); buf != nil {
|
||||
// log.TLogln(fmt.Sprintf("Found settings in JSON, size: %d bytes", len(buf)))
|
||||
// var sets BTSets
|
||||
// if err := json.Unmarshal(buf, &sets); err == nil {
|
||||
// log.TLogln(fmt.Sprintf("Parsed from JSON: StoreSettingsInJson=%v, StoreViewedInJson=%v",
|
||||
// sets.StoreSettingsInJson, sets.StoreViewedInJson))
|
||||
// return &sets
|
||||
// } else {
|
||||
// log.TLogln(fmt.Sprintf("Failed to parse JSON settings: %v", err))
|
||||
// }
|
||||
// } else {
|
||||
// log.TLogln("No settings found in JSON")
|
||||
// }
|
||||
|
||||
// // Try BBolt
|
||||
// if buf := bboltDB.Get("Settings", "BitTorr"); buf != nil {
|
||||
// log.TLogln(fmt.Sprintf("Found settings in BBolt, size: %d bytes", len(buf)))
|
||||
// var sets BTSets
|
||||
// if err := json.Unmarshal(buf, &sets); err == nil {
|
||||
// log.TLogln(fmt.Sprintf("Parsed from BBolt: StoreSettingsInJson=%v, StoreViewedInJson=%v",
|
||||
// sets.StoreSettingsInJson, sets.StoreViewedInJson))
|
||||
// return &sets
|
||||
// } else {
|
||||
// log.TLogln(fmt.Sprintf("Failed to parse BBolt settings: %v", err))
|
||||
// }
|
||||
// } else {
|
||||
// log.TLogln("No settings found in BBolt")
|
||||
// }
|
||||
|
||||
// log.TLogln("No existing storage settings found")
|
||||
// return nil
|
||||
// }
|
||||
|
||||
func applyCleanMigrations(bboltDB, jsonDB TorrServerDB, settingsInJson, viewedInJson bool) {
|
||||
// Settings migration
|
||||
if settingsInJson {
|
||||
safeMigrate(bboltDB, jsonDB, "Settings", "BitTorr", "JSON", true)
|
||||
} else {
|
||||
safeMigrate(jsonDB, bboltDB, "Settings", "BitTorr", "BBolt", true)
|
||||
}
|
||||
|
||||
// Viewed migration
|
||||
if viewedInJson {
|
||||
safeMigrateAll(bboltDB, jsonDB, "Viewed", "JSON", true)
|
||||
} else {
|
||||
safeMigrateAll(jsonDB, bboltDB, "Viewed", "BBolt", true)
|
||||
}
|
||||
}
|
||||
|
||||
func safeMigrate(source, target TorrServerDB, xpath, name, targetName string, clearSource bool) {
|
||||
if IsDebug() {
|
||||
log.TLogln(fmt.Sprintf("Checking migration of %s/%s to %s", xpath, name, targetName))
|
||||
}
|
||||
|
||||
migrated, err := MigrateSingle(source, target, xpath, name)
|
||||
if err != nil {
|
||||
log.TLogln(fmt.Sprintf("Migration error for %s/%s: %v", xpath, name, err))
|
||||
return
|
||||
}
|
||||
|
||||
if migrated {
|
||||
log.TLogln(fmt.Sprintf("Successfully migrated %s/%s to %s", xpath, name, targetName))
|
||||
// Clear source if requested
|
||||
if clearSource {
|
||||
source.Rem(xpath, name)
|
||||
if IsDebug() {
|
||||
log.TLogln(fmt.Sprintf("Cleared %s/%s from source", xpath, name))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.TLogln(fmt.Sprintf("No migration needed for %s/%s (already exists or no data)",
|
||||
xpath, name))
|
||||
}
|
||||
}
|
||||
|
||||
func safeMigrateAll(source, target TorrServerDB, xpath, targetName string, clearSource bool) {
|
||||
if IsDebug() {
|
||||
log.TLogln(fmt.Sprintf("Starting migration of all %s entries to %s", xpath, targetName))
|
||||
}
|
||||
|
||||
migrated, skipped, err := MigrateAll(source, target, xpath)
|
||||
log.TLogln(fmt.Sprintf("%s migration result: %d migrated, %d skipped", xpath, migrated, skipped))
|
||||
if err != nil {
|
||||
log.TLogln(fmt.Sprintf("Migration had errors: %v", err))
|
||||
}
|
||||
// Clear source if requested and we successfully migrated entries
|
||||
if clearSource && migrated > 0 {
|
||||
sourceCount := len(source.List(xpath))
|
||||
// Only clear if we migrated at least as many as were in source
|
||||
// (accounting for possible duplicates)
|
||||
if migrated >= sourceCount {
|
||||
source.Clear(xpath)
|
||||
if IsDebug() {
|
||||
log.TLogln(fmt.Sprintf("Cleared all %s entries from source", xpath))
|
||||
}
|
||||
} else {
|
||||
log.TLogln(fmt.Sprintf("Not clearing %s: only migrated %d of %d entries",
|
||||
xpath, migrated, sourceCount))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setupDatabaseRouting(bboltDB, jsonDB TorrServerDB, settingsInJson, viewedInJson bool) {
|
||||
dbRouter := NewXPathDBRouter()
|
||||
|
||||
if settingsInJson {
|
||||
dbRouter.RegisterRoute(jsonDB, "Settings")
|
||||
} else {
|
||||
dbRouter.RegisterRoute(bboltDB, "Settings")
|
||||
}
|
||||
|
||||
if viewedInJson {
|
||||
dbRouter.RegisterRoute(jsonDB, "Viewed")
|
||||
} else {
|
||||
dbRouter.RegisterRoute(bboltDB, "Viewed")
|
||||
}
|
||||
|
||||
dbRouter.RegisterRoute(bboltDB, "Torrents")
|
||||
tdb = NewDBReadCache(dbRouter)
|
||||
}
|
||||
|
||||
func logConfiguration(settingsInJson, viewedInJson bool) {
|
||||
settingsLoc := "JSON"
|
||||
if !settingsInJson {
|
||||
settingsLoc = "BBolt"
|
||||
}
|
||||
viewedLoc := "JSON"
|
||||
if !viewedInJson {
|
||||
viewedLoc = "BBolt"
|
||||
}
|
||||
|
||||
log.TLogln(fmt.Sprintf("Storage: Settings->%s, Viewed->%s, Torrents->BBolt",
|
||||
settingsLoc, viewedLoc))
|
||||
}
|
||||
|
||||
// SwitchSettingsStorage - simplified version
|
||||
func SwitchSettingsStorage(useJson bool) error {
|
||||
if ReadOnly {
|
||||
return errors.New("read-only mode")
|
||||
}
|
||||
// Acquire exclusive lock for migration
|
||||
dbMigrationLock.Lock()
|
||||
defer dbMigrationLock.Unlock()
|
||||
|
||||
bboltDB := NewTDB()
|
||||
if bboltDB == nil {
|
||||
return errors.New("failed to open BBolt DB")
|
||||
}
|
||||
// DON'T CLOSE! They're still in use by tdb
|
||||
// defer bboltDB.CloseDB()
|
||||
|
||||
jsonDB := NewJsonDB()
|
||||
if jsonDB == nil {
|
||||
return errors.New("failed to open JSON DB")
|
||||
}
|
||||
// DON'T CLOSE! They're still in use by tdb
|
||||
// defer jsonDB.CloseDB()
|
||||
|
||||
log.TLogln(fmt.Sprintf("Switching Settings storage to %s",
|
||||
map[bool]string{true: "JSON", false: "BBolt"}[useJson]))
|
||||
|
||||
// Update storage preference (must be called before migrate as this setting migrate too)
|
||||
if BTsets != nil {
|
||||
BTsets.StoreSettingsInJson = useJson
|
||||
SetBTSets(BTsets)
|
||||
}
|
||||
|
||||
var err error
|
||||
if useJson {
|
||||
err = MigrateSettingsToJson(bboltDB, jsonDB)
|
||||
} else {
|
||||
err = MigrateSettingsFromJson(jsonDB, bboltDB)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.TLogln("Settings storage switched. Restart required for routing changes.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// SwitchViewedStorage - simplified version
|
||||
func SwitchViewedStorage(useJson bool) error {
|
||||
if ReadOnly {
|
||||
return errors.New("read-only mode")
|
||||
}
|
||||
// Acquire exclusive lock for migration
|
||||
dbMigrationLock.Lock()
|
||||
defer dbMigrationLock.Unlock()
|
||||
|
||||
bboltDB := NewTDB()
|
||||
if bboltDB == nil {
|
||||
return errors.New("failed to open BBolt DB")
|
||||
}
|
||||
// DON'T CLOSE! They're still in use by tdb
|
||||
// defer bboltDB.CloseDB()
|
||||
|
||||
jsonDB := NewJsonDB()
|
||||
if jsonDB == nil {
|
||||
return errors.New("failed to open JSON DB")
|
||||
}
|
||||
// DON'T CLOSE! They're still in use by tdb
|
||||
// defer jsonDB.CloseDB()
|
||||
|
||||
log.TLogln(fmt.Sprintf("Switching Viewed storage to %s",
|
||||
map[bool]string{true: "JSON", false: "BBolt"}[useJson]))
|
||||
|
||||
var err error
|
||||
if useJson {
|
||||
err = MigrateViewedToJson(bboltDB, jsonDB)
|
||||
if err == nil {
|
||||
bboltDB.Clear("Viewed")
|
||||
}
|
||||
} else {
|
||||
err = MigrateViewedFromJson(jsonDB, bboltDB)
|
||||
if err == nil {
|
||||
jsonDB.Clear("Viewed")
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update preference
|
||||
if BTsets != nil {
|
||||
BTsets.StoreViewedInJson = useJson
|
||||
SetBTSets(BTsets)
|
||||
}
|
||||
|
||||
log.TLogln("Viewed storage switched. Restart required for routing changes.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Used in /storage/settings web API
|
||||
func GetStoragePreferences() map[string]interface{} {
|
||||
prefs := map[string]interface{}{
|
||||
"settings": "json", // Default fallback
|
||||
"viewed": "bbolt", // Default fallback
|
||||
}
|
||||
|
||||
if BTsets != nil {
|
||||
// Convert boolean preferences to string values
|
||||
if BTsets.StoreSettingsInJson {
|
||||
prefs["settings"] = "json"
|
||||
} else {
|
||||
prefs["settings"] = "bbolt"
|
||||
}
|
||||
|
||||
if BTsets.StoreViewedInJson {
|
||||
prefs["viewed"] = "json"
|
||||
} else {
|
||||
prefs["viewed"] = "bbolt"
|
||||
}
|
||||
}
|
||||
|
||||
if IsDebug() {
|
||||
log.TLogln(fmt.Sprintf("GetStoragePreferences: settings=%s, viewed=%s",
|
||||
prefs["settings"], prefs["viewed"]))
|
||||
}
|
||||
if tdb != nil {
|
||||
prefs["viewedCount"] = len(tdb.List("Viewed"))
|
||||
}
|
||||
|
||||
return prefs
|
||||
}
|
||||
|
||||
// Used in /storage/settings web API
|
||||
func SetStoragePreferences(prefs map[string]interface{}) error {
|
||||
if ReadOnly || BTsets == nil {
|
||||
return errors.New("cannot change storage preferences. Read-only mode")
|
||||
}
|
||||
|
||||
if IsDebug() {
|
||||
log.TLogln(fmt.Sprintf("SetStoragePreferences received: %v", prefs))
|
||||
}
|
||||
|
||||
// Apply changes
|
||||
if settingsPref, ok := prefs["settings"].(string); ok && settingsPref != "" {
|
||||
useJson := (settingsPref == "json")
|
||||
if IsDebug() {
|
||||
log.TLogln(fmt.Sprintf("Changing settings storage to useJson=%v (was %v)",
|
||||
useJson, BTsets.StoreSettingsInJson))
|
||||
}
|
||||
if BTsets.StoreSettingsInJson != useJson {
|
||||
if err := SwitchSettingsStorage(useJson); err != nil {
|
||||
return fmt.Errorf("failed to switch settings storage: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if viewedPref, ok := prefs["viewed"].(string); ok && viewedPref != "" {
|
||||
useJson := (viewedPref == "json")
|
||||
if IsDebug() {
|
||||
log.TLogln(fmt.Sprintf("Changing viewed storage to useJson=%v (was %v)",
|
||||
useJson, BTsets.StoreViewedInJson))
|
||||
}
|
||||
if BTsets.StoreViewedInJson != useJson {
|
||||
if err := SwitchViewedStorage(useJson); err != nil {
|
||||
return fmt.Errorf("failed to switch viewed storage: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func CloseDB() {
|
||||
if tdb != nil {
|
||||
tdb.CloseDB()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"github.com/anacrolix/torrent"
|
||||
"github.com/anacrolix/torrent/metainfo"
|
||||
)
|
||||
|
||||
type TorrentDB struct {
|
||||
*torrent.TorrentSpec
|
||||
|
||||
Title string `json:"title,omitempty"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Poster string `json:"poster,omitempty"`
|
||||
Data string `json:"data,omitempty"`
|
||||
|
||||
Timestamp int64 `json:"timestamp,omitempty"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
}
|
||||
|
||||
type File struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Id int `json:"id,omitempty"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
}
|
||||
|
||||
var mu sync.Mutex
|
||||
|
||||
func AddTorrent(torr *TorrentDB) {
|
||||
list := ListTorrent()
|
||||
mu.Lock()
|
||||
find := -1
|
||||
for i, db := range list {
|
||||
if db.InfoHash.HexString() == torr.InfoHash.HexString() {
|
||||
find = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if find != -1 {
|
||||
list[find] = torr
|
||||
} else {
|
||||
list = append(list, torr)
|
||||
}
|
||||
for _, db := range list {
|
||||
buf, err := json.Marshal(db)
|
||||
if err == nil {
|
||||
tdb.Set("Torrents", db.InfoHash.HexString(), buf)
|
||||
}
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
func ListTorrent() []*TorrentDB {
|
||||
// Use read lock to prevent migration during read
|
||||
dbMigrationLock.RLock()
|
||||
defer dbMigrationLock.RUnlock()
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
var list []*TorrentDB
|
||||
keys := tdb.List("Torrents")
|
||||
for _, key := range keys {
|
||||
buf := tdb.Get("Torrents", key)
|
||||
if len(buf) > 0 {
|
||||
var torr *TorrentDB
|
||||
err := json.Unmarshal(buf, &torr)
|
||||
if err == nil {
|
||||
list = append(list, torr)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(list, func(i, j int) bool {
|
||||
return list[i].Timestamp > list[j].Timestamp
|
||||
})
|
||||
return list
|
||||
}
|
||||
|
||||
func RemTorrent(hash metainfo.Hash) {
|
||||
mu.Lock()
|
||||
tdb.Rem("Torrents", hash.HexString())
|
||||
mu.Unlock()
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package settings
|
||||
|
||||
type TorrServerDB interface {
|
||||
CloseDB()
|
||||
Get(xPath, name string) []byte
|
||||
Set(xPath, name string, value []byte)
|
||||
List(xPath string) []string
|
||||
Rem(xPath, name string)
|
||||
Clear(xPath string)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"server/log"
|
||||
)
|
||||
|
||||
type Viewed struct {
|
||||
Hash string `json:"hash"`
|
||||
FileIndex int `json:"file_index"`
|
||||
}
|
||||
|
||||
func SetViewed(vv *Viewed) {
|
||||
var indexes map[int]struct{}
|
||||
var err error
|
||||
|
||||
buf := tdb.Get("Viewed", vv.Hash)
|
||||
if len(buf) == 0 {
|
||||
indexes = make(map[int]struct{})
|
||||
indexes[vv.FileIndex] = struct{}{}
|
||||
buf, err = json.Marshal(indexes)
|
||||
if err == nil {
|
||||
tdb.Set("Viewed", vv.Hash, buf)
|
||||
}
|
||||
} else {
|
||||
err = json.Unmarshal(buf, &indexes)
|
||||
if err == nil {
|
||||
indexes[vv.FileIndex] = struct{}{}
|
||||
buf, err = json.Marshal(indexes)
|
||||
if err == nil {
|
||||
tdb.Set("Viewed", vv.Hash, buf)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
log.TLogln("Error set viewed:", err)
|
||||
}
|
||||
}
|
||||
|
||||
func RemViewed(vv *Viewed) {
|
||||
buf := tdb.Get("Viewed", vv.Hash)
|
||||
var indeces map[int]struct{}
|
||||
err := json.Unmarshal(buf, &indeces)
|
||||
if err == nil {
|
||||
if vv.FileIndex != -1 {
|
||||
delete(indeces, vv.FileIndex)
|
||||
buf, err = json.Marshal(indeces)
|
||||
if err == nil {
|
||||
tdb.Set("Viewed", vv.Hash, buf)
|
||||
}
|
||||
} else {
|
||||
tdb.Rem("Viewed", vv.Hash)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
log.TLogln("Error rem viewed:", err)
|
||||
}
|
||||
}
|
||||
|
||||
func ListViewed(hash string) []*Viewed {
|
||||
var err error
|
||||
if hash != "" {
|
||||
buf := tdb.Get("Viewed", hash)
|
||||
if len(buf) == 0 {
|
||||
return []*Viewed{}
|
||||
}
|
||||
var indeces map[int]struct{}
|
||||
err = json.Unmarshal(buf, &indeces)
|
||||
if err == nil {
|
||||
var ret []*Viewed
|
||||
for i := range indeces {
|
||||
ret = append(ret, &Viewed{hash, i})
|
||||
}
|
||||
return ret
|
||||
}
|
||||
} else {
|
||||
var ret []*Viewed
|
||||
keys := tdb.List("Viewed")
|
||||
for _, key := range keys {
|
||||
buf := tdb.Get("Viewed", key)
|
||||
if len(buf) == 0 {
|
||||
return []*Viewed{}
|
||||
}
|
||||
var indeces map[int]struct{}
|
||||
err = json.Unmarshal(buf, &indeces)
|
||||
if err == nil {
|
||||
for i := range indeces {
|
||||
ret = append(ret, &Viewed{key, i})
|
||||
}
|
||||
}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
log.TLogln("Error list viewed:", err)
|
||||
return []*Viewed{}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"server/log"
|
||||
|
||||
"golang.org/x/exp/slices"
|
||||
)
|
||||
|
||||
type XPathDBRouter struct {
|
||||
dbs []TorrServerDB
|
||||
routes []string
|
||||
route2db map[string]TorrServerDB
|
||||
dbNames map[TorrServerDB]string
|
||||
}
|
||||
|
||||
func NewXPathDBRouter() *XPathDBRouter {
|
||||
router := &XPathDBRouter{
|
||||
dbs: []TorrServerDB{},
|
||||
dbNames: map[TorrServerDB]string{},
|
||||
routes: []string{},
|
||||
route2db: map[string]TorrServerDB{},
|
||||
}
|
||||
return router
|
||||
}
|
||||
|
||||
func (v *XPathDBRouter) RegisterRoute(db TorrServerDB, xPath string) error {
|
||||
newRoute := v.xPathToRoute(xPath)
|
||||
|
||||
if slices.Contains(v.routes, newRoute) {
|
||||
return fmt.Errorf("route \"%s\" already in routing table", newRoute)
|
||||
}
|
||||
|
||||
// First DB becomes Default DB with default route
|
||||
if len(v.dbs) == 0 && len(newRoute) != 0 {
|
||||
v.RegisterRoute(db, "")
|
||||
}
|
||||
|
||||
if !slices.Contains(v.dbs, db) {
|
||||
v.dbs = append(v.dbs, db)
|
||||
v.dbNames[db] = reflect.TypeOf(db).Elem().Name()
|
||||
v.log(fmt.Sprintf("Registered new DB \"%s\", total %d DBs registered", v.getDBName(db), len(v.dbs)))
|
||||
}
|
||||
|
||||
v.route2db[newRoute] = db
|
||||
v.routes = append(v.routes, newRoute)
|
||||
|
||||
// Sort routes by length descending.
|
||||
// It is important later to help selecting
|
||||
// most suitable route in getDBForXPath(xPath)
|
||||
sort.Slice(v.routes, func(iLeft, iRight int) bool {
|
||||
return len(v.routes[iLeft]) > len(v.routes[iRight])
|
||||
})
|
||||
v.log(fmt.Sprintf("Registered new route \"%s\" for DB \"%s\", total %d routes", getDefaultRoureName(newRoute), v.getDBName(db), len(v.routes)))
|
||||
return nil
|
||||
}
|
||||
|
||||
func getDefaultRoureName(route string) string {
|
||||
if len(route) > 0 {
|
||||
return route
|
||||
}
|
||||
return "default"
|
||||
}
|
||||
|
||||
func (v *XPathDBRouter) xPathToRoute(xPath string) string {
|
||||
return strings.ToLower(strings.TrimSpace(xPath))
|
||||
}
|
||||
|
||||
func (v *XPathDBRouter) getDBForXPath(xPath string) TorrServerDB {
|
||||
if len(v.dbs) == 0 {
|
||||
return nil
|
||||
}
|
||||
lookup_route := v.xPathToRoute(xPath)
|
||||
var db TorrServerDB = nil
|
||||
// Expected v.routes sorted by length descending
|
||||
for _, route_prefix := range v.routes {
|
||||
if strings.HasPrefix(lookup_route, route_prefix) {
|
||||
db = v.route2db[route_prefix]
|
||||
break
|
||||
}
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func (v *XPathDBRouter) Get(xPath, name string) []byte {
|
||||
return v.getDBForXPath(xPath).Get(xPath, name)
|
||||
}
|
||||
|
||||
func (v *XPathDBRouter) Set(xPath, name string, value []byte) {
|
||||
v.getDBForXPath(xPath).Set(xPath, name, value)
|
||||
}
|
||||
|
||||
func (v *XPathDBRouter) List(xPath string) []string {
|
||||
return v.getDBForXPath(xPath).List(xPath)
|
||||
}
|
||||
|
||||
func (v *XPathDBRouter) Rem(xPath, name string) {
|
||||
v.getDBForXPath(xPath).Rem(xPath, name)
|
||||
}
|
||||
|
||||
func (v *XPathDBRouter) Clear(xPath string) {
|
||||
v.getDBForXPath(xPath).Clear(xPath)
|
||||
}
|
||||
|
||||
func (v *XPathDBRouter) CloseDB() {
|
||||
for _, db := range v.dbs {
|
||||
db.CloseDB()
|
||||
}
|
||||
v.dbs = nil
|
||||
v.routes = nil
|
||||
v.route2db = nil
|
||||
v.dbNames = nil
|
||||
}
|
||||
|
||||
func (v *XPathDBRouter) getDBName(db TorrServerDB) string {
|
||||
return v.dbNames[db]
|
||||
}
|
||||
|
||||
func (v *XPathDBRouter) log(s string, params ...interface{}) {
|
||||
if len(params) > 0 {
|
||||
log.TLogln(fmt.Sprintf("XPathDBRouter: %s: %s", s, fmt.Sprint(params...)))
|
||||
} else {
|
||||
log.TLogln(fmt.Sprintf("XPathDBRouter: %s", s))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
# TorrServer Telegram Bot
|
||||
|
||||
[](https://github.com/YouROK/TorrServer/blob/master/LICENSE)
|
||||
[](https://github.com/YouROK/TorrServer)
|
||||
|
||||
## Introduction
|
||||
|
||||
Telegram bot for managing [TorrServer](https://github.com/YouROK/TorrServer) — add torrents, stream, search, and control the server directly from Telegram.
|
||||
|
||||
## Features
|
||||
|
||||
- Torrent management — add, remove, drop, list via magnet, hash, or `torrs://`
|
||||
- Export & import — magnets list; import multiple from text
|
||||
- Streaming — playback links, M3U playlists, preload
|
||||
- Search — RuTor and Torznab with one-click add
|
||||
- Inline mode — `@botname` in any chat: list torrents or search
|
||||
- Status & snake — real-time status, cache visualization
|
||||
- File operations — browse files, download to Telegram
|
||||
- FFprobe — media metadata via `/ffp`
|
||||
- Localization — Russian and English
|
||||
- Admin — shutdown, settings, presets (whitelist users only)
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Enable the Bot
|
||||
|
||||
Start TorrServer with a Telegram bot token:
|
||||
|
||||
```bash
|
||||
TorrServer --tg YOUR_BOT_TOKEN
|
||||
```
|
||||
|
||||
Or use `-T`:
|
||||
|
||||
```bash
|
||||
TorrServer -T YOUR_BOT_TOKEN
|
||||
```
|
||||
|
||||
Create a bot via [@BotFather](https://t.me/BotFather) to get the token.
|
||||
|
||||
### Configuration
|
||||
|
||||
Config file `tg.cfg` (JSON) in the TorrServer data directory:
|
||||
|
||||
| Field | Description |
|
||||
|------------|-------------|
|
||||
| `HostTG` | Telegram API URL (default: `https://api.telegram.org`) |
|
||||
| `HostWeb` | Base URL for stream links (auto-detected if empty) |
|
||||
| `Socks5` | Optional SOCKS5 for reaching Telegram (e.g. `127.0.0.1:1080`, `socks5://user:pass@host:port`) if direct access to `api.telegram.org` is blocked or times out |
|
||||
| `WhiteIds` | Allowed user IDs (empty = allow all) |
|
||||
| `BlackIds` | Blocked user IDs |
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"HostTG": "https://api.telegram.org",
|
||||
"HostWeb": "http://192.168.1.100:8090",
|
||||
"Socks5": "127.0.0.1:1080",
|
||||
"WhiteIds": [123456789],
|
||||
"BlackIds": []
|
||||
}
|
||||
```
|
||||
|
||||
If your network cannot connect to Telegram’s API directly, run a local SOCKS5 proxy (for example [sing-box](https://github.com/SagerNet/sing-box), v2ray, or `ssh -D`) and set `Socks5` to its address.
|
||||
|
||||
## Commands
|
||||
|
||||
### Core
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/help`, `/start`, `/id` | Help and user ID |
|
||||
| `/list [compact]` | List torrents with buttons |
|
||||
| `/add <link>` | Add torrent (magnet, hash, torrs://) |
|
||||
| `/clear` | Remove all (with confirmation) |
|
||||
| `/hash [N]` | Show info hashes |
|
||||
|
||||
### Management
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/remove <hash\|N>` | Remove torrent |
|
||||
| `/drop <hash\|N>` | Disconnect (keep in DB) |
|
||||
| `/set <hash\|N> <title>` | Set title |
|
||||
| `/status [hash\|N]` | Status with refresh/stop |
|
||||
| `/cache <hash\|N>` | Cache stats |
|
||||
| `/preload <hash\|N> <index>` | Preload file |
|
||||
|
||||
### Links & Playback
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/link`, `/play` | Stream URL |
|
||||
| `/m3u`, `/m3uall` | M3U playlist |
|
||||
|
||||
### Search
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/search <query>` | RuTor + Torznab (all sources) |
|
||||
| `/rutor <query>` | RuTor only |
|
||||
| `/torznab <query> [index]` | Torznab indexers |
|
||||
|
||||
### Other
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/export`, `/import` | Export/import magnets |
|
||||
| `/categories` | List categories |
|
||||
| `/server`, `/stats`, `/stat` | Server info |
|
||||
| `/viewed` | Viewed files |
|
||||
| `/ffp <hash\|N> <id> [json]` | FFprobe metadata |
|
||||
| `/speedtest [size]` | Download test (1–100 MB) |
|
||||
| `/snake [hash\|N] [cols] [rows]` | Cache visualization |
|
||||
| `/lang [RU\|EN]` | Language |
|
||||
|
||||
### Admin Only
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/shutdown` | Shut down server |
|
||||
| `/settings` | Interactive settings menu (sub-pages: Search, Network, Other, Cache, Paths, Storage) |
|
||||
| `/preset <name>` | Apply named preset: `performance`, `storage`, `streaming`, `low`, `default` |
|
||||
| `/preset <key> <value> ...` | Apply key-value pairs: `cache 256`, `preload 50`, `conn 100`, etc. |
|
||||
|
||||
**Preset examples:**
|
||||
- `/preset performance` — max cache, high preload, no limits
|
||||
- `/preset cache 256 preload 50` — set cache 256 MB and preload 50%
|
||||
- `/preset cache 512 conn 100 down 0 up 0` — multiple values
|
||||
|
||||
**Preset keys:** `cache`, `preload`, `readahead`, `conn`, `timeout`, `port`, `down`, `up`, `retr`, `responsive`, `cachedrop`
|
||||
|
||||
## Inline Mode
|
||||
|
||||
Type `@YourBotName` in any chat:
|
||||
|
||||
- **Empty, "list", or "play"** — torrents with play links
|
||||
- **2+ characters** — search RuTor + Torznab
|
||||
|
||||
## Text Input
|
||||
|
||||
Paste as plain message to add torrent:
|
||||
|
||||
- `magnet:?xt=urn:btih:...`
|
||||
- `torrs://...`
|
||||
- 40-char info hash
|
||||
|
||||
Reply to file list with `2-12` to download files 2–12 to Telegram.
|
||||
|
||||
## Security
|
||||
|
||||
- **Whitelist** — restrict to specific user IDs
|
||||
- **Blacklist** — block user IDs
|
||||
- **Admin** — when whitelist is used, admin = whitelisted users
|
||||
- **Settings** — sensitive values masked in `/settings`
|
||||
|
||||
## Dependencies
|
||||
|
||||
- [telebot v4](https://gopkg.in/telebot.v4) — Telegram Bot API
|
||||
- [go-humanize](https://github.com/dustin/go-humanize)
|
||||
- [go-ffprobe](https://gopkg.in/vansante/go-ffprobe.v2)
|
||||
@@ -0,0 +1,129 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/anacrolix/torrent"
|
||||
tele "gopkg.in/telebot.v4"
|
||||
"server/log"
|
||||
set "server/settings"
|
||||
"server/torr"
|
||||
"server/web/api/utils"
|
||||
)
|
||||
|
||||
func addTorrentFromSpec(c tele.Context, torrSpec *torrent.TorrentSpec, displayLabel string) error {
|
||||
msg, err := c.Bot().Send(c.Sender(), tr(c.Sender().ID, "connecting"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tor, err := torr.AddTorrent(torrSpec, "", "", "", "")
|
||||
if err != nil {
|
||||
log.TLogln("tg add err", err)
|
||||
_, _ = c.Bot().Edit(msg, fmt.Sprintf(tr(c.Sender().ID, "add_error"), err.Error()))
|
||||
return err
|
||||
}
|
||||
if tor == nil {
|
||||
_, _ = c.Bot().Edit(msg, tr(c.Sender().ID, "add_not_created"))
|
||||
return errors.New("torrent not created")
|
||||
}
|
||||
|
||||
if set.BTsets != nil && set.BTsets.EnableDebug {
|
||||
if tor.Data != "" {
|
||||
log.TLogln("tg add data", logSafeStr(tor.Data, 60))
|
||||
}
|
||||
if tor.Category != "" {
|
||||
log.TLogln("tg add category", logSafeStr(tor.Category, 40))
|
||||
}
|
||||
}
|
||||
|
||||
_, _ = c.Bot().Edit(msg, tr(c.Sender().ID, "add_getting_meta"))
|
||||
if !tor.GotInfo() {
|
||||
log.TLogln("tg add err", "timeout get torrent info")
|
||||
_, _ = c.Bot().Edit(msg, tr(c.Sender().ID, "add_timeout"))
|
||||
return errors.New("timeout connection get torrent info")
|
||||
}
|
||||
|
||||
if tor.Title == "" {
|
||||
tor.Title = torrSpec.DisplayName
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
torr.SaveTorrentToDB(tor)
|
||||
|
||||
if len(displayLabel) > 80 {
|
||||
displayLabel = displayLabel[:77] + "..."
|
||||
}
|
||||
_, _ = c.Bot().Edit(msg, fmt.Sprintf(tr(c.Sender().ID, "add_success"), displayLabel))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func addTorrent(c tele.Context, link string) error {
|
||||
log.TLogln("tg add torrent", logHashOrTruncate(link))
|
||||
link = strings.ReplaceAll(link, "&", "&")
|
||||
var torrSpec *torrent.TorrentSpec
|
||||
var err error
|
||||
if strings.HasPrefix(strings.ToLower(link), "torrs://") {
|
||||
torrSpec, _, err = utils.ParseTorrsHash(link)
|
||||
} else {
|
||||
torrSpec, err = utils.ParseLink(link)
|
||||
}
|
||||
if err != nil {
|
||||
log.TLogln("tg add parse err", err)
|
||||
return err
|
||||
}
|
||||
return addTorrentFromSpec(c, torrSpec, link)
|
||||
}
|
||||
|
||||
func addTorrentFromDocument(c tele.Context, doc *tele.Document) error {
|
||||
if doc == nil || doc.FileID == "" {
|
||||
return errors.New("no document")
|
||||
}
|
||||
reader, err := c.Bot().File(&doc.File)
|
||||
if err != nil {
|
||||
log.TLogln("tg add document getfile err", err)
|
||||
return err
|
||||
}
|
||||
defer func() { _ = reader.Close() }()
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
log.TLogln("tg add document read err", err)
|
||||
return err
|
||||
}
|
||||
torrSpec, err := utils.ParseFromBytes(data)
|
||||
if err != nil {
|
||||
log.TLogln("tg add document parse err", err)
|
||||
return err
|
||||
}
|
||||
displayLabel := doc.FileName
|
||||
if displayLabel == "" {
|
||||
displayLabel = ".torrent"
|
||||
}
|
||||
return addTorrentFromSpec(c, torrSpec, displayLabel)
|
||||
}
|
||||
|
||||
func cmdAdd(c tele.Context) error {
|
||||
uid := c.Sender().ID
|
||||
args := c.Args()
|
||||
if len(args) == 0 {
|
||||
return c.Send(tr(uid, "add_usage"))
|
||||
}
|
||||
link := strings.TrimSpace(strings.Join(args, " "))
|
||||
if link == "" {
|
||||
return c.Send(tr(uid, "add_no_link"))
|
||||
}
|
||||
err := addTorrent(c, link)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return list(c)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"server/tgbot/config"
|
||||
)
|
||||
|
||||
func isAdmin(userID int64) bool {
|
||||
if len(config.Cfg.WhiteIds) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, id := range config.Cfg.WhiteIds {
|
||||
if id == userID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
tele "gopkg.in/telebot.v4"
|
||||
"server/dlna"
|
||||
"server/rutor"
|
||||
"server/settings"
|
||||
"server/torr"
|
||||
)
|
||||
|
||||
type pendingPreset struct {
|
||||
Sets *settings.BTSets
|
||||
Preset string // name for display
|
||||
UserID int64
|
||||
IsDef bool
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
pendingPresetMu sync.Mutex
|
||||
pendingPresets = make(map[string]pendingPreset)
|
||||
)
|
||||
|
||||
func init() {
|
||||
go func() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
for range ticker.C {
|
||||
pendingPresetMu.Lock()
|
||||
now := time.Now()
|
||||
for key, p := range pendingPresets {
|
||||
if now.Sub(p.CreatedAt) > 30*time.Minute {
|
||||
delete(pendingPresets, key)
|
||||
}
|
||||
}
|
||||
pendingPresetMu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func cmdPreset(c tele.Context) error {
|
||||
uid := c.Sender().ID
|
||||
if !isAdmin(uid) {
|
||||
return c.Send(tr(uid, "admin_only"))
|
||||
}
|
||||
if settings.BTsets == nil {
|
||||
return c.Send(tr(uid, "settings_not_loaded"))
|
||||
}
|
||||
if settings.ReadOnly {
|
||||
return c.Send(tr(uid, "settings_readonly"))
|
||||
}
|
||||
|
||||
args := strings.Fields(c.Text())
|
||||
if len(args) < 2 {
|
||||
return c.Send(tr(uid, "preset_usage"))
|
||||
}
|
||||
|
||||
sets := new(settings.BTSets)
|
||||
*sets = *settings.BTsets
|
||||
|
||||
first := strings.ToLower(args[1])
|
||||
presetName := first
|
||||
|
||||
if len(args) == 2 {
|
||||
if ok, _ := applyNamedPreset(sets, first, uid); ok {
|
||||
return sendPresetConfirm(c, uid, sets, presetName, false)
|
||||
}
|
||||
if first == "default" || first == "def" || first == "сброс" {
|
||||
return sendPresetConfirm(c, uid, nil, "default", true)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse key-value pairs: cache 256 preload 50 conn 100
|
||||
applied, errMsg := applyPresetKV(sets, args[1:], uid)
|
||||
if !applied {
|
||||
return c.Send(errMsg)
|
||||
}
|
||||
presetName = strings.Join(args[1:], " ")
|
||||
return sendPresetConfirm(c, uid, sets, presetName, false)
|
||||
}
|
||||
|
||||
func sendPresetConfirm(c tele.Context, uid int64, sets *settings.BTSets, presetName string, isDef bool) error {
|
||||
btnYes := tele.InlineButton{Text: tr(uid, "btn_yes"), Unique: "fpreset", Data: "1"}
|
||||
btnNo := tele.InlineButton{Text: tr(uid, "btn_no"), Unique: "fpreset", Data: "0"}
|
||||
kbd := &tele.ReplyMarkup{InlineKeyboard: [][]tele.InlineButton{{btnYes, btnNo}}}
|
||||
msg := tr(uid, "preset_confirm") + "\n\n<code>" + presetName + "</code>"
|
||||
sent, err := c.Bot().Send(c.Chat(), msg, kbd, tele.ModeHTML)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pendingPresetMu.Lock()
|
||||
pendingPresets[chatMsgKey(sent.Chat.ID, sent.ID)] = pendingPreset{
|
||||
Sets: sets, Preset: presetName, UserID: uid, IsDef: isDef, CreatedAt: time.Now(),
|
||||
}
|
||||
pendingPresetMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func presetConfirm(c tele.Context, confirm string) error {
|
||||
uid := c.Sender().ID
|
||||
if !isAdmin(uid) {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "admin_only")})
|
||||
}
|
||||
key := chatMsgKey(c.Callback().Message.Chat.ID, c.Callback().Message.ID)
|
||||
pendingPresetMu.Lock()
|
||||
p, ok := pendingPresets[key]
|
||||
delete(pendingPresets, key)
|
||||
pendingPresetMu.Unlock()
|
||||
if !ok || p.UserID != uid {
|
||||
_ = c.Bot().Delete(c.Callback().Message)
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "canceled")})
|
||||
}
|
||||
if confirm != "1" {
|
||||
_ = c.Bot().Delete(c.Callback().Message)
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "canceled")})
|
||||
}
|
||||
_ = c.Bot().Delete(c.Callback().Message)
|
||||
if settings.ReadOnly {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "settings_readonly")})
|
||||
}
|
||||
if p.IsDef {
|
||||
torr.SetDefSettings()
|
||||
dlna.Stop()
|
||||
rutor.Stop()
|
||||
rutor.Start()
|
||||
return c.Send(tr(uid, "settings_reset_done"))
|
||||
}
|
||||
if p.Sets == nil {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "callback_unknown")})
|
||||
}
|
||||
torr.SetSettings(p.Sets)
|
||||
dlna.Stop()
|
||||
if p.Sets.EnableDLNA {
|
||||
dlna.Start()
|
||||
}
|
||||
rutor.Stop()
|
||||
rutor.Start()
|
||||
return c.Send(tr(uid, "preset_applied") + p.Preset)
|
||||
}
|
||||
|
||||
func applyNamedPreset(s *settings.BTSets, name string, uid int64) (bool, string) {
|
||||
switch name {
|
||||
case "performance", "perf", "производительность":
|
||||
s.CacheSize = 512 * 1024 * 1024
|
||||
s.PreloadCache = 95
|
||||
s.ReaderReadAHead = 100
|
||||
s.ConnectionsLimit = 100
|
||||
s.TorrentDisconnectTimeout = 60
|
||||
s.PeersListenPort = 0
|
||||
s.DownloadRateLimit = 0
|
||||
s.UploadRateLimit = 0
|
||||
s.RetrackersMode = 1
|
||||
s.ResponsiveMode = true
|
||||
return true, tr(uid, "preset_applied") + " performance"
|
||||
case "storage", "store", "хранение":
|
||||
s.CacheSize = 64 * 1024 * 1024
|
||||
s.PreloadCache = 25
|
||||
s.ReaderReadAHead = 50
|
||||
s.RemoveCacheOnDrop = true
|
||||
return true, tr(uid, "preset_applied") + " storage"
|
||||
case "streaming", "stream", "стриминг":
|
||||
s.CacheSize = 256 * 1024 * 1024
|
||||
s.PreloadCache = 75
|
||||
s.ReaderReadAHead = 95
|
||||
s.ConnectionsLimit = 50
|
||||
s.ResponsiveMode = true
|
||||
return true, tr(uid, "preset_applied") + " streaming"
|
||||
case "low", "minimal", "минимум":
|
||||
s.CacheSize = 64 * 1024 * 1024
|
||||
s.PreloadCache = 25
|
||||
s.ReaderReadAHead = 50
|
||||
s.ConnectionsLimit = 25
|
||||
s.TorrentDisconnectTimeout = 30
|
||||
return true, tr(uid, "preset_applied") + " low"
|
||||
case "default", "def", "сброс":
|
||||
return false, "" // handled in cmdPreset
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
func applyPresetKV(s *settings.BTSets, args []string, uid int64) (bool, string) {
|
||||
if len(args) < 2 {
|
||||
return false, tr(uid, "preset_usage")
|
||||
}
|
||||
applied := false
|
||||
for i := 0; i < len(args)-1; i += 2 {
|
||||
key := strings.ToLower(args[i])
|
||||
val := strings.ToLower(strings.TrimSpace(args[i+1]))
|
||||
ok := false
|
||||
switch key {
|
||||
case "cache":
|
||||
if v := parseInt(val); v > 0 {
|
||||
s.CacheSize = int64(v) * 1024 * 1024
|
||||
ok = true
|
||||
}
|
||||
case "preload":
|
||||
if v := parseInt(val); v >= 0 && v <= 100 {
|
||||
s.PreloadCache = v
|
||||
ok = true
|
||||
}
|
||||
case "readahead":
|
||||
if v := parseInt(val); v >= 5 && v <= 100 {
|
||||
s.ReaderReadAHead = v
|
||||
ok = true
|
||||
}
|
||||
case "conn", "connections":
|
||||
if v := parseInt(val); v > 0 {
|
||||
s.ConnectionsLimit = v
|
||||
ok = true
|
||||
}
|
||||
case "timeout":
|
||||
if v := parseInt(val); v > 0 {
|
||||
s.TorrentDisconnectTimeout = v
|
||||
ok = true
|
||||
}
|
||||
case "port":
|
||||
v := parseInt(val)
|
||||
if val == "auto" || val == "0" {
|
||||
v = 0
|
||||
}
|
||||
if v >= 0 && (v == 0 || (v >= 1024 && v <= 65535)) {
|
||||
s.PeersListenPort = v
|
||||
ok = true
|
||||
}
|
||||
case "down", "download":
|
||||
v := 0
|
||||
if val != "inf" && val != "∞" && val != "0" {
|
||||
v = parseInt(val)
|
||||
}
|
||||
s.DownloadRateLimit = v
|
||||
ok = true
|
||||
case "up", "upload":
|
||||
v := 0
|
||||
if val != "inf" && val != "∞" && val != "0" {
|
||||
v = parseInt(val)
|
||||
}
|
||||
s.UploadRateLimit = v
|
||||
ok = true
|
||||
case "retr", "retrackers":
|
||||
var v int
|
||||
switch val {
|
||||
case "off":
|
||||
v = 0
|
||||
case "add":
|
||||
v = 1
|
||||
case "rem", "remove":
|
||||
v = 2
|
||||
case "repl", "replace":
|
||||
v = 3
|
||||
default:
|
||||
v = parseInt(val)
|
||||
}
|
||||
if v >= 0 && v <= 3 {
|
||||
s.RetrackersMode = v
|
||||
ok = true
|
||||
}
|
||||
case "responsive":
|
||||
s.ResponsiveMode = val == "1" || val == "on" || val == "true" || val == "да" || val == "yes"
|
||||
ok = true
|
||||
case "cachedrop":
|
||||
s.RemoveCacheOnDrop = val == "1" || val == "on" || val == "true" || val == "да" || val == "yes"
|
||||
ok = true
|
||||
}
|
||||
if ok {
|
||||
applied = true
|
||||
}
|
||||
}
|
||||
if !applied {
|
||||
return false, tr(uid, "preset_usage")
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
@@ -0,0 +1,654 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tele "gopkg.in/telebot.v4"
|
||||
"server/dlna"
|
||||
"server/rutor"
|
||||
"server/settings"
|
||||
"server/torr"
|
||||
)
|
||||
|
||||
func cmdSettings(c tele.Context) error {
|
||||
uid := c.Sender().ID
|
||||
if settings.BTsets == nil {
|
||||
return c.Send(tr(uid, "settings_not_loaded"))
|
||||
}
|
||||
return sendSettingsMenu(c, uid)
|
||||
}
|
||||
|
||||
func sendSettingsMenu(c tele.Context, uid int64) error {
|
||||
return sendSettingsMenuPage(c, uid, "1")
|
||||
}
|
||||
|
||||
func sendSettingsMenuPage(c tele.Context, uid int64, page string) error {
|
||||
msg := sendSettingsMenuText(c, uid, page)
|
||||
kbd := sendSettingsMenuKbd(uid, page)
|
||||
return c.Send(msg, kbd)
|
||||
}
|
||||
|
||||
func sendSettingsMenuText(c tele.Context, uid int64, page string) string {
|
||||
s := settings.BTsets
|
||||
msg := "⚙️ <b>" + tr(uid, "settings_title") + "</b>"
|
||||
switch page {
|
||||
case "1":
|
||||
msg += "\n\n"
|
||||
msg += fmt.Sprintf("🔍 %s: RuTor %s · Torznab %s\n", tr(uid, "settings_section_search"), boolIcon(s.EnableRutorSearch), boolIcon(s.EnableTorznabSearch))
|
||||
msg += fmt.Sprintf("📺 %s: DLNA %s · IPv6 %s · DHT %s · PEX %s · TCP %s · UTP %s\n", tr(uid, "settings_section_network"), boolIcon(s.EnableDLNA), boolIcon(s.EnableIPv6), boolIcon(!s.DisableDHT), boolIcon(!s.DisablePEX), boolIcon(!s.DisableTCP), boolIcon(!s.DisableUTP))
|
||||
msg += fmt.Sprintf("📦 %s: CacheDrop %s · Proxy %s · UseDisk %s\n", tr(uid, "settings_section_other"), boolIcon(s.RemoveCacheOnDrop), boolIcon(s.EnableProxy), boolIcon(s.UseDisk))
|
||||
case "1a":
|
||||
msg += " — " + tr(uid, "settings_section_search")
|
||||
msg += "\n\n"
|
||||
msg += fmt.Sprintf("RuTor %s · Torznab %s", boolIcon(s.EnableRutorSearch), boolIcon(s.EnableTorznabSearch))
|
||||
case "1b":
|
||||
msg += " — " + tr(uid, "settings_section_network")
|
||||
msg += "\n\n"
|
||||
msg += fmt.Sprintf("DLNA %s · IPv6 %s · Upload %s · DHT %s · PEX %s\n", boolIcon(s.EnableDLNA), boolIcon(s.EnableIPv6), boolIcon(!s.DisableUpload), boolIcon(!s.DisableDHT), boolIcon(!s.DisablePEX))
|
||||
msg += fmt.Sprintf("TCP %s · UTP %s · UPNP %s · Encrypt %s · Debug %s", boolIcon(!s.DisableTCP), boolIcon(!s.DisableUTP), boolIcon(!s.DisableUPNP), boolIcon(s.ForceEncrypt), boolIcon(s.EnableDebug))
|
||||
case "1c":
|
||||
msg += " — " + tr(uid, "settings_section_other")
|
||||
msg += "\n\n"
|
||||
msg += fmt.Sprintf("CacheDrop %s · Responsive %s · Proxy %s · UseDisk %s · FSActive %s", boolIcon(s.RemoveCacheOnDrop), boolIcon(s.ResponsiveMode), boolIcon(s.EnableProxy), boolIcon(s.UseDisk), boolIcon(s.ShowFSActiveTorr))
|
||||
case "2":
|
||||
msg += " — " + tr(uid, "settings_page2")
|
||||
msg += "\n\n"
|
||||
msg += fmt.Sprintf("💾 %s: %d MB · Preload %d%% · ReadAhead %d%%\n", tr(uid, "settings_limits_cache"), s.CacheSize/(1024*1024), s.PreloadCache, s.ReaderReadAHead)
|
||||
msg += fmt.Sprintf("🔌 %s: %d · Port %s · Timeout %ds\n", tr(uid, "settings_limits_connections"), s.ConnectionsLimit, portStr(s.PeersListenPort), s.TorrentDisconnectTimeout)
|
||||
msg += fmt.Sprintf("⬇️ %s: Down %s · Up %s · Retr %s\n", tr(uid, "settings_limits_speed"), rateStr(s.DownloadRateLimit), rateStr(s.UploadRateLimit), retrackersStr(s.RetrackersMode))
|
||||
case "2a":
|
||||
msg += " — " + tr(uid, "settings_page2") + " · " + tr(uid, "settings_limits_cache")
|
||||
msg += "\n\n"
|
||||
msg += fmt.Sprintf("Cache %d MB · Preload %d%% · ReadAhead %d%%", s.CacheSize/(1024*1024), s.PreloadCache, s.ReaderReadAHead)
|
||||
case "2b":
|
||||
msg += " — " + tr(uid, "settings_page2") + " · " + tr(uid, "settings_limits_connections")
|
||||
msg += "\n\n"
|
||||
msg += fmt.Sprintf("Connections %d · Port %s · Timeout %ds", s.ConnectionsLimit, portStr(s.PeersListenPort), s.TorrentDisconnectTimeout)
|
||||
case "2c":
|
||||
msg += " — " + tr(uid, "settings_page2") + " · " + tr(uid, "settings_limits_speed")
|
||||
msg += "\n\n"
|
||||
msg += fmt.Sprintf("Down %s · Up %s · Retrackers %s", rateStr(s.DownloadRateLimit), rateStr(s.UploadRateLimit), retrackersStr(s.RetrackersMode))
|
||||
case "3":
|
||||
msg += " — " + tr(uid, "settings_page3")
|
||||
msg += "\n\n"
|
||||
msg += fmt.Sprintf("📺 DLNA: %s · 💾 Path: %s\n", maskStr(s.FriendlyName, 25), maskVal(s.TorrentsSavePath))
|
||||
msg += fmt.Sprintf("🔐 SSL: %s · 🔑 TMDB: %s · Torznab: %d\n", maskVal(s.SslCert), maskVal(s.TMDBSettings.APIKey), len(s.TorznabUrls))
|
||||
msg += fmt.Sprintf("🌐 Proxy: %s", maskStr(strings.Join(s.ProxyHosts, ", "), 35))
|
||||
case "4":
|
||||
msg += " — " + tr(uid, "settings_page4")
|
||||
msg += "\n\n"
|
||||
msg += fmt.Sprintf("📄 %s: %s · 📺 %s: %s\n", tr(uid, "settings_storage_settings"), storageType(s.StoreSettingsInJson), tr(uid, "settings_storage_viewed"), storageType(s.StoreViewedInJson))
|
||||
msg += fmt.Sprintf("🔑 TMDB: %s · 🖼 URL: %s", maskVal(s.TMDBSettings.APIKey), maskStr(s.TMDBSettings.ImageURL, 20))
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
func storageType(useJSON bool) string {
|
||||
if useJSON {
|
||||
return "json"
|
||||
}
|
||||
return "bbolt"
|
||||
}
|
||||
|
||||
func rateStr(kb int) string {
|
||||
if kb == 0 {
|
||||
return "∞"
|
||||
}
|
||||
return fmt.Sprintf("%d", kb)
|
||||
}
|
||||
|
||||
func portStr(port int) string {
|
||||
if port == 0 {
|
||||
return "auto"
|
||||
}
|
||||
return fmt.Sprintf("%d", port)
|
||||
}
|
||||
|
||||
func maskStr(s string, maxLen int) string {
|
||||
if s == "" {
|
||||
return "—"
|
||||
}
|
||||
if len(s) > maxLen {
|
||||
return s[:maxLen-3] + "..."
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func maskVal(s string) string {
|
||||
if s == "" {
|
||||
return "—"
|
||||
}
|
||||
return "***"
|
||||
}
|
||||
|
||||
func retrackersStr(mode int) string {
|
||||
switch mode {
|
||||
case 0:
|
||||
return "off"
|
||||
case 1:
|
||||
return "add"
|
||||
case 2:
|
||||
return "remove"
|
||||
case 3:
|
||||
return "replace"
|
||||
default:
|
||||
return "?"
|
||||
}
|
||||
}
|
||||
|
||||
func sendSettingsMenuKbd(uid int64, page string) *tele.ReplyMarkup {
|
||||
s := settings.BTsets
|
||||
var btns [][]tele.InlineButton
|
||||
|
||||
switch page {
|
||||
case "1":
|
||||
btns = [][]tele.InlineButton{
|
||||
{
|
||||
{Text: "🔍 " + tr(uid, "settings_section_search"), Unique: "fset", Data: "page|1a"},
|
||||
{Text: "📺 " + tr(uid, "settings_section_network"), Unique: "fset", Data: "page|1b"},
|
||||
{Text: "📦 " + tr(uid, "settings_section_other"), Unique: "fset", Data: "page|1c"},
|
||||
},
|
||||
{
|
||||
{Text: "📥 " + tr(uid, "settings_export"), Unique: "fset", Data: "export"},
|
||||
{Text: "📊 " + tr(uid, "settings_nav_cache"), Unique: "fset", Data: "page|2"},
|
||||
{Text: "✏️ " + tr(uid, "settings_nav_paths"), Unique: "fset", Data: "page|3"},
|
||||
{Text: "💾 " + tr(uid, "settings_nav_storage"), Unique: "fset", Data: "page|4"},
|
||||
},
|
||||
}
|
||||
case "1a":
|
||||
btns = [][]tele.InlineButton{
|
||||
{
|
||||
{Text: "◀️ " + tr(uid, "settings_back"), Unique: "fset", Data: "page|1"},
|
||||
},
|
||||
{
|
||||
{Text: toggleBtn("RuTor", s.EnableRutorSearch), Unique: "fset", Data: "rutor|1a"},
|
||||
{Text: toggleBtn("Torznab", s.EnableTorznabSearch), Unique: "fset", Data: "torznab|1a"},
|
||||
},
|
||||
}
|
||||
case "1b":
|
||||
btns = [][]tele.InlineButton{
|
||||
{
|
||||
{Text: "◀️ " + tr(uid, "settings_back"), Unique: "fset", Data: "page|1"},
|
||||
},
|
||||
{
|
||||
{Text: toggleBtn("DLNA", s.EnableDLNA), Unique: "fset", Data: "dlna|1b"},
|
||||
{Text: toggleBtn("IPv6", s.EnableIPv6), Unique: "fset", Data: "ipv6|1b"},
|
||||
{Text: toggleBtn("Upload", !s.DisableUpload), Unique: "fset", Data: "upload|1b"},
|
||||
},
|
||||
{
|
||||
{Text: toggleBtn("DHT", !s.DisableDHT), Unique: "fset", Data: "dht|1b"},
|
||||
{Text: toggleBtn("PEX", !s.DisablePEX), Unique: "fset", Data: "pex|1b"},
|
||||
{Text: toggleBtn("TCP", !s.DisableTCP), Unique: "fset", Data: "tcp|1b"},
|
||||
{Text: toggleBtn("UTP", !s.DisableUTP), Unique: "fset", Data: "utp|1b"},
|
||||
},
|
||||
{
|
||||
{Text: toggleBtn("UPNP", !s.DisableUPNP), Unique: "fset", Data: "upnp|1b"},
|
||||
{Text: toggleBtn("Encrypt", s.ForceEncrypt), Unique: "fset", Data: "encrypt|1b"},
|
||||
{Text: toggleBtn("Debug", s.EnableDebug), Unique: "fset", Data: "debug|1b"},
|
||||
},
|
||||
}
|
||||
case "1c":
|
||||
btns = [][]tele.InlineButton{
|
||||
{
|
||||
{Text: "◀️ " + tr(uid, "settings_back"), Unique: "fset", Data: "page|1"},
|
||||
},
|
||||
{
|
||||
{Text: toggleBtn("CacheDrop", s.RemoveCacheOnDrop), Unique: "fset", Data: "cachedrop|1c"},
|
||||
{Text: toggleBtn("Responsive", s.ResponsiveMode), Unique: "fset", Data: "responsive|1c"},
|
||||
{Text: toggleBtn("Proxy", s.EnableProxy), Unique: "fset", Data: "proxy|1c"},
|
||||
},
|
||||
{
|
||||
{Text: toggleBtn("UseDisk", s.UseDisk), Unique: "fset", Data: "usedisk|1c"},
|
||||
{Text: toggleBtn("FSActive", s.ShowFSActiveTorr), Unique: "fset", Data: "fsactive|1c"},
|
||||
},
|
||||
}
|
||||
case "2":
|
||||
btns = [][]tele.InlineButton{
|
||||
{
|
||||
{Text: "💾 " + tr(uid, "settings_limits_cache"), Unique: "fset", Data: "page|2a"},
|
||||
{Text: "🔌 " + tr(uid, "settings_limits_connections"), Unique: "fset", Data: "page|2b"},
|
||||
{Text: "⬇️ " + tr(uid, "settings_limits_speed"), Unique: "fset", Data: "page|2c"},
|
||||
},
|
||||
{
|
||||
{Text: "◀️ " + tr(uid, "settings_back"), Unique: "fset", Data: "page|1"},
|
||||
{Text: "✏️ " + tr(uid, "settings_nav_paths"), Unique: "fset", Data: "page|3"},
|
||||
{Text: "💾 " + tr(uid, "settings_nav_storage"), Unique: "fset", Data: "page|4"},
|
||||
},
|
||||
}
|
||||
case "2a":
|
||||
cacheMB := int(s.CacheSize / (1024 * 1024))
|
||||
btns = [][]tele.InlineButton{
|
||||
{
|
||||
{Text: "◀️ " + tr(uid, "settings_back"), Unique: "fset", Data: "page|2"},
|
||||
},
|
||||
{
|
||||
{Text: "💾 " + optBtn("64", cacheMB == 64), Unique: "fset", Data: "cache|64|2a"},
|
||||
{Text: optBtn("128", cacheMB == 128), Unique: "fset", Data: "cache|128|2a"},
|
||||
{Text: optBtn("256", cacheMB == 256), Unique: "fset", Data: "cache|256|2a"},
|
||||
{Text: optBtn("512", cacheMB == 512), Unique: "fset", Data: "cache|512|2a"},
|
||||
},
|
||||
{
|
||||
{Text: "📥 " + optBtn("25%", s.PreloadCache == 25), Unique: "fset", Data: "preload|25|2a"},
|
||||
{Text: optBtn("50%", s.PreloadCache == 50), Unique: "fset", Data: "preload|50|2a"},
|
||||
{Text: optBtn("75%", s.PreloadCache == 75), Unique: "fset", Data: "preload|75|2a"},
|
||||
{Text: optBtn("95%", s.PreloadCache == 95), Unique: "fset", Data: "preload|95|2a"},
|
||||
},
|
||||
{
|
||||
{Text: "📖 " + optBtn("50%", s.ReaderReadAHead == 50), Unique: "fset", Data: "readahead|50|2a"},
|
||||
{Text: optBtn("75%", s.ReaderReadAHead == 75), Unique: "fset", Data: "readahead|75|2a"},
|
||||
{Text: optBtn("95%", s.ReaderReadAHead == 95), Unique: "fset", Data: "readahead|95|2a"},
|
||||
{Text: optBtn("100%", s.ReaderReadAHead == 100), Unique: "fset", Data: "readahead|100|2a"},
|
||||
},
|
||||
}
|
||||
case "2b":
|
||||
btns = [][]tele.InlineButton{
|
||||
{
|
||||
{Text: "◀️ " + tr(uid, "settings_back"), Unique: "fset", Data: "page|2"},
|
||||
},
|
||||
{
|
||||
{Text: "🔌 " + optBtn("25", s.ConnectionsLimit == 25), Unique: "fset", Data: "conn|25|2b"},
|
||||
{Text: optBtn("50", s.ConnectionsLimit == 50), Unique: "fset", Data: "conn|50|2b"},
|
||||
{Text: optBtn("100", s.ConnectionsLimit == 100), Unique: "fset", Data: "conn|100|2b"},
|
||||
},
|
||||
{
|
||||
{Text: "⏱ " + optBtn("15s", s.TorrentDisconnectTimeout == 15), Unique: "fset", Data: "timeout|15|2b"},
|
||||
{Text: optBtn("30s", s.TorrentDisconnectTimeout == 30), Unique: "fset", Data: "timeout|30|2b"},
|
||||
{Text: optBtn("60s", s.TorrentDisconnectTimeout == 60), Unique: "fset", Data: "timeout|60|2b"},
|
||||
{Text: optBtn("120s", s.TorrentDisconnectTimeout == 120), Unique: "fset", Data: "timeout|120|2b"},
|
||||
},
|
||||
{
|
||||
{Text: "🔌 " + optBtn("auto", s.PeersListenPort == 0), Unique: "fset", Data: "port|0|2b"},
|
||||
{Text: optBtn("6881", s.PeersListenPort == 6881), Unique: "fset", Data: "port|6881|2b"},
|
||||
{Text: optBtn("51413", s.PeersListenPort == 51413), Unique: "fset", Data: "port|51413|2b"},
|
||||
},
|
||||
}
|
||||
case "2c":
|
||||
btns = [][]tele.InlineButton{
|
||||
{
|
||||
{Text: "◀️ " + tr(uid, "settings_back"), Unique: "fset", Data: "page|2"},
|
||||
},
|
||||
{
|
||||
{Text: "⬇️ " + optBtn("∞", s.DownloadRateLimit == 0), Unique: "fset", Data: "down|0|2c"},
|
||||
{Text: optBtn("1M", s.DownloadRateLimit == 1024), Unique: "fset", Data: "down|1024|2c"},
|
||||
{Text: optBtn("5M", s.DownloadRateLimit == 5120), Unique: "fset", Data: "down|5120|2c"},
|
||||
{Text: optBtn("10M", s.DownloadRateLimit == 10240), Unique: "fset", Data: "down|10240|2c"},
|
||||
},
|
||||
{
|
||||
{Text: "⬆️ " + optBtn("∞", s.UploadRateLimit == 0), Unique: "fset", Data: "up|0|2c"},
|
||||
{Text: optBtn("1M", s.UploadRateLimit == 1024), Unique: "fset", Data: "up|1024|2c"},
|
||||
{Text: optBtn("5M", s.UploadRateLimit == 5120), Unique: "fset", Data: "up|5120|2c"},
|
||||
{Text: optBtn("10M", s.UploadRateLimit == 10240), Unique: "fset", Data: "up|10240|2c"},
|
||||
},
|
||||
{
|
||||
{Text: "🔄 " + optBtn("off", s.RetrackersMode == 0), Unique: "fset", Data: "retr|0|2c"},
|
||||
{Text: optBtn("add", s.RetrackersMode == 1), Unique: "fset", Data: "retr|1|2c"},
|
||||
{Text: optBtn("rem", s.RetrackersMode == 2), Unique: "fset", Data: "retr|2|2c"},
|
||||
{Text: optBtn("repl", s.RetrackersMode == 3), Unique: "fset", Data: "retr|3|2c"},
|
||||
},
|
||||
}
|
||||
case "3":
|
||||
btns = [][]tele.InlineButton{
|
||||
{
|
||||
{Text: "◀️ " + tr(uid, "settings_back"), Unique: "fset", Data: "page|1"},
|
||||
{Text: "📊 " + tr(uid, "settings_nav_cache"), Unique: "fset", Data: "page|2"},
|
||||
{Text: "💾 " + tr(uid, "settings_nav_storage"), Unique: "fset", Data: "page|4"},
|
||||
},
|
||||
{
|
||||
{Text: "✏️ " + tr(uid, "settings_set_friendlyname"), Unique: "fset", Data: "ask|friendlyname"},
|
||||
},
|
||||
{
|
||||
{Text: "✏️ " + tr(uid, "settings_set_path"), Unique: "fset", Data: "ask|torrentssavepath"},
|
||||
},
|
||||
{
|
||||
{Text: "🔐 " + tr(uid, "settings_set_sslcert"), Unique: "fset", Data: "ask|sslcert"},
|
||||
{Text: "🔑 " + tr(uid, "settings_set_sslkey"), Unique: "fset", Data: "ask|sslkey"},
|
||||
},
|
||||
{
|
||||
{Text: "🎬 " + tr(uid, "settings_set_tmdbkey"), Unique: "fset", Data: "ask|tmdbkey"},
|
||||
},
|
||||
{
|
||||
{Text: "🔍 " + tr(uid, "settings_torznab_test"), Unique: "fset", Data: "ask|torznab_test"},
|
||||
{Text: "➕ " + tr(uid, "settings_add_torznab"), Unique: "fset", Data: "ask|torznab_add"},
|
||||
{Text: "🗑 " + tr(uid, "settings_clear_torznab"), Unique: "fset", Data: "torznab_clear"},
|
||||
},
|
||||
{
|
||||
{Text: "✏️ " + tr(uid, "settings_set_proxyhosts"), Unique: "fset", Data: "ask|proxyhosts"},
|
||||
},
|
||||
}
|
||||
case "4":
|
||||
btns = [][]tele.InlineButton{
|
||||
{
|
||||
{Text: "◀️ " + tr(uid, "settings_back"), Unique: "fset", Data: "page|1"},
|
||||
{Text: "📊 " + tr(uid, "settings_nav_cache"), Unique: "fset", Data: "page|2"},
|
||||
{Text: "✏️ " + tr(uid, "settings_nav_paths"), Unique: "fset", Data: "page|3"},
|
||||
},
|
||||
{
|
||||
{Text: "📄 " + optBtn("json", s.StoreSettingsInJson), Unique: "fset", Data: "storage_set|json"},
|
||||
{Text: optBtn("bbolt", !s.StoreSettingsInJson), Unique: "fset", Data: "storage_set|bbolt"},
|
||||
},
|
||||
{
|
||||
{Text: "📺 " + optBtn("json", s.StoreViewedInJson), Unique: "fset", Data: "storage_view|json"},
|
||||
{Text: optBtn("bbolt", !s.StoreViewedInJson), Unique: "fset", Data: "storage_view|bbolt"},
|
||||
},
|
||||
{
|
||||
{Text: "🔄 " + tr(uid, "settings_reset"), Unique: "fset", Data: "reset_confirm"},
|
||||
},
|
||||
}
|
||||
}
|
||||
return &tele.ReplyMarkup{InlineKeyboard: btns}
|
||||
}
|
||||
|
||||
func boolIcon(v bool) string {
|
||||
if v {
|
||||
return "✅"
|
||||
}
|
||||
return "❌"
|
||||
}
|
||||
|
||||
func toggleBtn(label string, on bool) string {
|
||||
if on {
|
||||
return label + " ✅"
|
||||
}
|
||||
return label + " ❌"
|
||||
}
|
||||
|
||||
func optBtn(label string, isCurrent bool) string {
|
||||
if isCurrent {
|
||||
return label + " ✓"
|
||||
}
|
||||
return label
|
||||
}
|
||||
|
||||
func settingsCallback(c tele.Context, action string) error {
|
||||
uid := c.Sender().ID
|
||||
if !isAdmin(uid) {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "admin_only")})
|
||||
}
|
||||
if settings.BTsets == nil {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "settings_not_loaded")})
|
||||
}
|
||||
|
||||
if action == "export" {
|
||||
buf, err := json.MarshalIndent(settings.BTsets, "", " ")
|
||||
if err != nil {
|
||||
return c.Respond(&tele.CallbackResponse{Text: fmt.Sprintf(tr(uid, "settings_error"), err.Error())})
|
||||
}
|
||||
doc := &tele.Document{}
|
||||
doc.FileName = "torrserver_settings.json"
|
||||
doc.FileReader = bytes.NewReader(buf)
|
||||
doc.Caption = "⚙️ " + tr(uid, "settings_export_caption")
|
||||
_ = c.Send(doc)
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "settings_exported")})
|
||||
}
|
||||
|
||||
if action == "input_cancel" {
|
||||
return cancelSettingsInput(c)
|
||||
}
|
||||
|
||||
if action == "reset_confirm" {
|
||||
btnYes := tele.InlineButton{Text: tr(uid, "btn_yes"), Unique: "fset", Data: "reset_def|1"}
|
||||
btnNo := tele.InlineButton{Text: tr(uid, "btn_no"), Unique: "fset", Data: "reset_def|0"}
|
||||
kbd := &tele.ReplyMarkup{InlineKeyboard: [][]tele.InlineButton{{btnYes, btnNo}}}
|
||||
msg := sendSettingsMenuText(c, uid, "4") + "\n\n⚠️ " + tr(uid, "settings_reset_confirm")
|
||||
if _, err := c.Bot().Edit(c.Callback().Message, msg, kbd, tele.ModeHTML); err != nil {
|
||||
_ = c.Send(tr(uid, "settings_reset_confirm"), kbd)
|
||||
}
|
||||
return c.Respond(&tele.CallbackResponse{})
|
||||
}
|
||||
|
||||
if len(action) > 9 && action[:9] == "reset_def|" {
|
||||
if action[9:] != "1" {
|
||||
msg := sendSettingsMenuText(c, uid, "4")
|
||||
kbd := sendSettingsMenuKbd(uid, "4")
|
||||
if _, err := c.Bot().Edit(c.Callback().Message, msg, kbd, tele.ModeHTML); err != nil {
|
||||
_ = sendSettingsMenuPage(c, uid, "4")
|
||||
}
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "canceled")})
|
||||
}
|
||||
if settings.ReadOnly {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "settings_readonly")})
|
||||
}
|
||||
torr.SetDefSettings()
|
||||
dlna.Stop()
|
||||
rutor.Stop()
|
||||
rutor.Start()
|
||||
msg := sendSettingsMenuText(c, uid, "4")
|
||||
kbd := sendSettingsMenuKbd(uid, "4")
|
||||
if _, err := c.Bot().Edit(c.Callback().Message, msg, kbd, tele.ModeHTML); err != nil {
|
||||
_ = sendSettingsMenuPage(c, uid, "4")
|
||||
}
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "settings_reset_done")})
|
||||
}
|
||||
|
||||
if len(action) > 12 && action[:12] == "storage_set|" {
|
||||
if settings.ReadOnly {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "settings_readonly")})
|
||||
}
|
||||
val := action[12:]
|
||||
prefs := map[string]interface{}{"settings": val}
|
||||
if err := settings.SetStoragePreferences(prefs); err != nil {
|
||||
return c.Respond(&tele.CallbackResponse{Text: fmt.Sprintf(tr(uid, "settings_error"), err.Error())})
|
||||
}
|
||||
page := "4"
|
||||
msg := sendSettingsMenuText(c, uid, page)
|
||||
kbd := sendSettingsMenuKbd(uid, page)
|
||||
if _, err := c.Bot().Edit(c.Callback().Message, msg, kbd, tele.ModeHTML); err != nil {
|
||||
_ = sendSettingsMenuPage(c, uid, page)
|
||||
}
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "settings_saved")})
|
||||
}
|
||||
|
||||
if len(action) > 12 && action[:12] == "storage_view|" {
|
||||
if settings.ReadOnly {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "settings_readonly")})
|
||||
}
|
||||
val := action[12:]
|
||||
prefs := map[string]interface{}{"viewed": val}
|
||||
if err := settings.SetStoragePreferences(prefs); err != nil {
|
||||
return c.Respond(&tele.CallbackResponse{Text: fmt.Sprintf(tr(uid, "settings_error"), err.Error())})
|
||||
}
|
||||
page := "4"
|
||||
msg := sendSettingsMenuText(c, uid, page)
|
||||
kbd := sendSettingsMenuKbd(uid, page)
|
||||
if _, err := c.Bot().Edit(c.Callback().Message, msg, kbd, tele.ModeHTML); err != nil {
|
||||
_ = sendSettingsMenuPage(c, uid, page)
|
||||
}
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "settings_saved")})
|
||||
}
|
||||
|
||||
if len(action) > 4 && action[:4] == "ask|" {
|
||||
setting := action[4:]
|
||||
var hint string
|
||||
switch setting {
|
||||
case "friendlyname":
|
||||
hint = tr(uid, "settings_hint_friendlyname")
|
||||
case "torrentssavepath":
|
||||
hint = tr(uid, "settings_hint_path")
|
||||
case "sslcert":
|
||||
hint = tr(uid, "settings_hint_sslcert")
|
||||
case "sslkey":
|
||||
hint = tr(uid, "settings_hint_sslkey")
|
||||
case "tmdbkey":
|
||||
hint = tr(uid, "settings_hint_tmdbkey")
|
||||
case "proxyhosts":
|
||||
hint = tr(uid, "settings_hint_proxyhosts")
|
||||
case "torznab_add":
|
||||
hint = tr(uid, "settings_hint_torznab")
|
||||
case "torznab_test":
|
||||
hint = tr(uid, "settings_hint_torznab_test")
|
||||
default:
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "callback_unknown")})
|
||||
}
|
||||
return sendSettingsInputPrompt(c, uid, setting, hint)
|
||||
}
|
||||
|
||||
if len(action) > 5 && action[:5] == "page|" {
|
||||
page := action[5:]
|
||||
msg := sendSettingsMenuText(c, uid, page)
|
||||
kbd := sendSettingsMenuKbd(uid, page)
|
||||
if _, err := c.Bot().Edit(c.Callback().Message, msg, kbd, tele.ModeHTML); err != nil {
|
||||
_ = sendSettingsMenuPage(c, uid, page)
|
||||
}
|
||||
return c.Respond(&tele.CallbackResponse{})
|
||||
}
|
||||
|
||||
if settings.ReadOnly {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "settings_readonly")})
|
||||
}
|
||||
|
||||
sets := new(settings.BTSets)
|
||||
*sets = *settings.BTsets
|
||||
page := "1"
|
||||
|
||||
// Extract return page from action (e.g. "rutor|1a" -> action "rutor", page "1a")
|
||||
if idx := strings.Index(action, "|"); idx >= 0 {
|
||||
suffix := action[idx+1:]
|
||||
if suffix == "1a" || suffix == "1b" || suffix == "1c" {
|
||||
page = suffix
|
||||
action = action[:idx]
|
||||
}
|
||||
}
|
||||
|
||||
switch action {
|
||||
case "rutor":
|
||||
sets.EnableRutorSearch = !sets.EnableRutorSearch
|
||||
case "torznab":
|
||||
sets.EnableTorznabSearch = !sets.EnableTorznabSearch
|
||||
case "dlna":
|
||||
sets.EnableDLNA = !sets.EnableDLNA
|
||||
case "ipv6":
|
||||
sets.EnableIPv6 = !sets.EnableIPv6
|
||||
case "upload":
|
||||
sets.DisableUpload = !sets.DisableUpload
|
||||
case "dht":
|
||||
sets.DisableDHT = !sets.DisableDHT
|
||||
case "pex":
|
||||
sets.DisablePEX = !sets.DisablePEX
|
||||
case "tcp":
|
||||
sets.DisableTCP = !sets.DisableTCP
|
||||
case "utp":
|
||||
sets.DisableUTP = !sets.DisableUTP
|
||||
case "upnp":
|
||||
sets.DisableUPNP = !sets.DisableUPNP
|
||||
case "encrypt":
|
||||
sets.ForceEncrypt = !sets.ForceEncrypt
|
||||
case "debug":
|
||||
sets.EnableDebug = !sets.EnableDebug
|
||||
case "cachedrop":
|
||||
sets.RemoveCacheOnDrop = !sets.RemoveCacheOnDrop
|
||||
case "responsive":
|
||||
sets.ResponsiveMode = !sets.ResponsiveMode
|
||||
case "proxy":
|
||||
sets.EnableProxy = !sets.EnableProxy
|
||||
case "usedisk":
|
||||
sets.UseDisk = !sets.UseDisk
|
||||
case "fsactive":
|
||||
sets.ShowFSActiveTorr = !sets.ShowFSActiveTorr
|
||||
case "storejson":
|
||||
sets.StoreSettingsInJson = !sets.StoreSettingsInJson
|
||||
case "viewedjson":
|
||||
sets.StoreViewedInJson = !sets.StoreViewedInJson
|
||||
case "torznab_clear":
|
||||
if settings.ReadOnly {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "settings_readonly")})
|
||||
}
|
||||
sets.TorznabUrls = nil
|
||||
page = "3"
|
||||
torr.SetSettings(sets)
|
||||
rutor.Stop()
|
||||
rutor.Start()
|
||||
msg := sendSettingsMenuText(c, uid, page)
|
||||
kbd := sendSettingsMenuKbd(uid, page)
|
||||
if _, err := c.Bot().Edit(c.Callback().Message, msg, kbd, tele.ModeHTML); err != nil {
|
||||
_ = sendSettingsMenuPage(c, uid, page)
|
||||
}
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "settings_saved")})
|
||||
default:
|
||||
if parts := splitAction(action); len(parts) == 2 {
|
||||
key, value := parts[0], parts[1]
|
||||
page = "2"
|
||||
if idx := strings.Index(value, "|"); idx >= 0 {
|
||||
if ret := value[idx+1:]; ret == "2a" || ret == "2b" || ret == "2c" {
|
||||
page = ret
|
||||
}
|
||||
value = value[:idx]
|
||||
}
|
||||
switch key {
|
||||
case "cache":
|
||||
if v := parseInt(value); v > 0 {
|
||||
sets.CacheSize = int64(v) * 1024 * 1024
|
||||
}
|
||||
case "preload":
|
||||
if v := parseInt(value); v >= 0 && v <= 100 {
|
||||
sets.PreloadCache = v
|
||||
}
|
||||
case "readahead":
|
||||
if v := parseInt(value); v >= 5 && v <= 100 {
|
||||
sets.ReaderReadAHead = v
|
||||
}
|
||||
case "conn":
|
||||
if v := parseInt(value); v > 0 {
|
||||
sets.ConnectionsLimit = v
|
||||
}
|
||||
case "timeout":
|
||||
if v := parseInt(value); v > 0 {
|
||||
sets.TorrentDisconnectTimeout = v
|
||||
}
|
||||
case "port":
|
||||
v := parseInt(value)
|
||||
if v >= 0 && (v == 0 || (v >= 1024 && v <= 65535)) {
|
||||
sets.PeersListenPort = v
|
||||
}
|
||||
case "down":
|
||||
sets.DownloadRateLimit = parseInt(value)
|
||||
case "up":
|
||||
sets.UploadRateLimit = parseInt(value)
|
||||
case "retr":
|
||||
if v := parseInt(value); v >= 0 && v <= 3 {
|
||||
sets.RetrackersMode = v
|
||||
}
|
||||
default:
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "callback_unknown")})
|
||||
}
|
||||
} else {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "callback_unknown")})
|
||||
}
|
||||
}
|
||||
|
||||
torr.SetSettings(sets)
|
||||
dlna.Stop()
|
||||
if sets.EnableDLNA {
|
||||
dlna.Start()
|
||||
}
|
||||
rutor.Stop()
|
||||
rutor.Start()
|
||||
|
||||
msg := sendSettingsMenuText(c, uid, page)
|
||||
kbd := sendSettingsMenuKbd(uid, page)
|
||||
if _, err := c.Bot().Edit(c.Callback().Message, msg, kbd, tele.ModeHTML); err != nil {
|
||||
_ = sendSettingsMenuPage(c, uid, page)
|
||||
}
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "settings_saved")})
|
||||
}
|
||||
|
||||
func splitAction(action string) []string {
|
||||
for i := 0; i < len(action); i++ {
|
||||
if action[i] == '|' {
|
||||
return []string{action[:i], action[i+1:]}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseInt(s string) int {
|
||||
var n int
|
||||
for _, c := range s {
|
||||
if c >= '0' && c <= '9' {
|
||||
n = n*10 + int(c-'0')
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
tele "gopkg.in/telebot.v4"
|
||||
"server/torr"
|
||||
)
|
||||
|
||||
func cmdShutdown(c tele.Context) error {
|
||||
uid := c.Sender().ID
|
||||
btnYes := tele.InlineButton{Text: tr(uid, "btn_yes"), Unique: "fshutdown", Data: "1"}
|
||||
btnNo := tele.InlineButton{Text: tr(uid, "btn_no"), Unique: "fshutdown", Data: "0"}
|
||||
kbd := &tele.ReplyMarkup{InlineKeyboard: [][]tele.InlineButton{{btnYes, btnNo}}}
|
||||
return c.Send(tr(uid, "shutdown_confirm"), kbd)
|
||||
}
|
||||
|
||||
func shutdownConfirm(c tele.Context, confirm string) error {
|
||||
uid := c.Sender().ID
|
||||
if !isAdmin(uid) {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "admin_only")})
|
||||
}
|
||||
if confirm != "1" {
|
||||
_ = c.Respond(&tele.CallbackResponse{Text: tr(uid, "canceled")})
|
||||
return c.Bot().Delete(c.Callback().Message)
|
||||
}
|
||||
_ = c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "server_stopped")})
|
||||
_ = c.Bot().Delete(c.Callback().Message)
|
||||
_ = c.Send(tr(c.Sender().ID, "server_stopped"))
|
||||
go func() {
|
||||
torr.Shutdown()
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/proxy"
|
||||
tele "gopkg.in/telebot.v4"
|
||||
"gopkg.in/telebot.v4/middleware"
|
||||
|
||||
"server/log"
|
||||
"server/tgbot/config"
|
||||
up "server/tgbot/upload"
|
||||
)
|
||||
|
||||
func newTelegramHTTPClient() *http.Client {
|
||||
const timeout = 5 * time.Minute
|
||||
trimmed := strings.TrimSpace(config.Cfg.Socks5)
|
||||
if trimmed == "" {
|
||||
return &http.Client{Timeout: timeout}
|
||||
}
|
||||
raw := trimmed
|
||||
if !strings.Contains(raw, "://") {
|
||||
raw = "socks5://" + raw
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
log.TLogln("tg cfg Socks5 parse err, using direct", err)
|
||||
return &http.Client{Timeout: timeout}
|
||||
}
|
||||
if u.Scheme != "socks5" {
|
||||
log.TLogln("tg cfg Socks5: only socks5 is supported, got", u.Scheme)
|
||||
return &http.Client{Timeout: timeout}
|
||||
}
|
||||
proxyHost := u.Host
|
||||
if proxyHost == "" {
|
||||
log.TLogln("tg cfg Socks5: empty host, using direct")
|
||||
return &http.Client{Timeout: timeout}
|
||||
}
|
||||
var auth *proxy.Auth
|
||||
if u.User != nil {
|
||||
pw, _ := u.User.Password()
|
||||
auth = &proxy.Auth{User: u.User.Username(), Password: pw}
|
||||
}
|
||||
socksDial, err := proxy.SOCKS5("tcp", proxyHost, auth, proxy.Direct)
|
||||
if err != nil {
|
||||
log.TLogln("tg socks5 dialer err, using direct", err)
|
||||
return &http.Client{Timeout: timeout}
|
||||
}
|
||||
log.TLogln("tg using SOCKS5 proxy", proxyHost)
|
||||
transport := &http.Transport{
|
||||
Proxy: nil, // respect explicit socks only, not HTTP_PROXY, for this client
|
||||
DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
_ = ctx
|
||||
return socksDial.Dial(network, address)
|
||||
},
|
||||
}
|
||||
return &http.Client{Transport: transport, Timeout: timeout}
|
||||
}
|
||||
|
||||
func Start(token string) error {
|
||||
config.LoadConfig()
|
||||
loadUserLangs()
|
||||
|
||||
pref := tele.Settings{
|
||||
URL: config.Cfg.HostTG,
|
||||
Token: token,
|
||||
Poller: &tele.LongPoller{Timeout: 5 * time.Minute},
|
||||
ParseMode: tele.ModeHTML,
|
||||
Client: newTelegramHTTPClient(),
|
||||
}
|
||||
|
||||
log.TLogln("tg bot starting")
|
||||
|
||||
b, err := tele.NewBot(pref)
|
||||
if err != nil {
|
||||
log.TLogln("tg bot start err", err)
|
||||
return err
|
||||
}
|
||||
|
||||
up.TrFunc = tr
|
||||
up.EscapeFunc = escapeHtml
|
||||
|
||||
if err := b.SetCommands([]tele.Command{
|
||||
{Text: "help", Description: "Help and user ID"},
|
||||
{Text: "start", Description: "Start bot"},
|
||||
{Text: "list", Description: "List torrents"},
|
||||
{Text: "add", Description: "Add torrent"},
|
||||
{Text: "search", Description: "Search all (RuTor+Torznab)"},
|
||||
{Text: "rutor", Description: "Search RuTor"},
|
||||
{Text: "torznab", Description: "Search Torznab"},
|
||||
{Text: "remove", Description: "Remove torrent"},
|
||||
{Text: "status", Description: "Torrent status"},
|
||||
{Text: "link", Description: "Stream link"},
|
||||
{Text: "m3u", Description: "M3U playlist"},
|
||||
{Text: "preload", Description: "Preload file"},
|
||||
{Text: "queue", Description: "Upload queue status"},
|
||||
{Text: "server", Description: "Server info"},
|
||||
{Text: "stats", Description: "Summary statistics"},
|
||||
{Text: "stat", Description: "Detailed status"},
|
||||
{Text: "snake", Description: "Cache visualization"},
|
||||
{Text: "clear", Description: "Remove all torrents"},
|
||||
{Text: "hash", Description: "Show hashes"},
|
||||
{Text: "export", Description: "Export torrents"},
|
||||
{Text: "import", Description: "Import torrents"},
|
||||
{Text: "categories", Description: "List categories"},
|
||||
{Text: "lang", Description: "Set language RU|EN"},
|
||||
}); err != nil {
|
||||
log.TLogln("tg setcmd err", err)
|
||||
}
|
||||
|
||||
if len(config.Cfg.WhiteIds) > 0 {
|
||||
b.Use(middleware.Whitelist(config.Cfg.WhiteIds...))
|
||||
}
|
||||
if len(config.Cfg.BlackIds) > 0 {
|
||||
b.Use(middleware.Blacklist(config.Cfg.BlackIds...))
|
||||
}
|
||||
|
||||
b.Use(func(next tele.HandlerFunc) tele.HandlerFunc {
|
||||
return func(c tele.Context) error {
|
||||
if c.Sender() == nil {
|
||||
return nil
|
||||
}
|
||||
if c.Message() != nil && c.Message().Text != "" {
|
||||
cmd := logSafeStr(c.Message().Text, 60)
|
||||
log.TLogln("tg cmd", logUser(c.Sender()), cmd)
|
||||
}
|
||||
err := next(c)
|
||||
if err != nil {
|
||||
log.TLogln("tg cmd err", logUser(c.Sender()), err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
})
|
||||
|
||||
b.Handle("help", help)
|
||||
b.Handle("Help", help)
|
||||
b.Handle("/help", help)
|
||||
b.Handle("/Help", help)
|
||||
b.Handle("/start", help)
|
||||
b.Handle("/id", help)
|
||||
|
||||
b.Handle("/list", list)
|
||||
b.Handle("/clear", clear)
|
||||
b.Handle("/add", cmdAdd)
|
||||
b.Handle("/remove", cmdRemove)
|
||||
b.Handle("/drop", cmdDrop)
|
||||
b.Handle("/status", cmdStatus)
|
||||
b.Handle("/server", cmdServer)
|
||||
b.Handle("/link", cmdLink)
|
||||
b.Handle("/play", cmdLink)
|
||||
b.Handle("/cache", cmdCache)
|
||||
b.Handle("/m3u", cmdM3u)
|
||||
b.Handle("/m3uall", cmdM3uAll)
|
||||
b.Handle("/search", cmdSearch)
|
||||
b.Handle("/rutor", cmdSearchRutor)
|
||||
b.Handle("/torznab", cmdTorznab)
|
||||
b.Handle("/preload", cmdPreload)
|
||||
b.Handle("/queue", up.ShowQueue)
|
||||
b.Handle("/set", cmdSet)
|
||||
b.Handle("/hash", cmdHash)
|
||||
b.Handle("/export", cmdExport)
|
||||
b.Handle("/import", cmdImport)
|
||||
b.Handle("/categories", cmdCategories)
|
||||
b.Handle("/echo", cmdEcho)
|
||||
b.Handle("/db", cmdDb)
|
||||
b.Handle("/viewed", cmdViewed)
|
||||
b.Handle("/ffp", cmdFfp)
|
||||
b.Handle("/speedtest", cmdSpeedtest)
|
||||
b.Handle("/shutdown", adminOnly(cmdShutdown))
|
||||
b.Handle("/settings", adminOnly(cmdSettings))
|
||||
b.Handle("/preset", adminOnly(cmdPreset))
|
||||
b.Handle("/lang", cmdLang)
|
||||
b.Handle("/stats", cmdStats)
|
||||
b.Handle("/stat", cmdStat)
|
||||
b.Handle("/snake", cmdSnake)
|
||||
|
||||
b.Handle(tele.OnDocument, func(c tele.Context) error {
|
||||
if c.Message() == nil {
|
||||
return nil
|
||||
}
|
||||
doc := c.Message().Document
|
||||
if doc == nil {
|
||||
return nil
|
||||
}
|
||||
lowerName := strings.ToLower(doc.FileName)
|
||||
isTorrent := strings.HasSuffix(lowerName, ".torrent") ||
|
||||
strings.Contains(strings.ToLower(doc.MIME), "bittorrent")
|
||||
if isTorrent {
|
||||
err := addTorrentFromDocument(c, doc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return list(c)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
b.Handle(tele.OnText, func(c tele.Context) error {
|
||||
txt := c.Text()
|
||||
if handleSettingsInputReply(c) {
|
||||
return nil
|
||||
}
|
||||
lower := strings.ToLower(txt)
|
||||
if strings.HasPrefix(lower, "magnet:") || strings.HasPrefix(lower, "torrs://") ||
|
||||
strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") ||
|
||||
isHash(txt) {
|
||||
err := addTorrent(c, txt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return list(c)
|
||||
} else if c.Message().ReplyTo != nil && c.Message().ReplyTo.ReplyMarkup != nil && len(c.Message().ReplyTo.ReplyMarkup.InlineKeyboard) > 0 {
|
||||
var hash string
|
||||
for _, row := range c.Message().ReplyTo.ReplyMarkup.InlineKeyboard {
|
||||
for _, btn := range row {
|
||||
if btn.Data == "" {
|
||||
continue
|
||||
}
|
||||
if idx := strings.Index(btn.Data, "all|"); idx >= 0 {
|
||||
h := btn.Data[idx+4:]
|
||||
if len(h) >= 40 && isHash(h[:40]) {
|
||||
hash = h[:40]
|
||||
} else if isHash(h) {
|
||||
hash = h
|
||||
}
|
||||
} else if isHash(btn.Data) {
|
||||
hash = btn.Data
|
||||
}
|
||||
if hash != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
if hash != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
if hash != "" {
|
||||
from, to, err := ParseRange(c.Sender().ID, c.Message().Text)
|
||||
if err != nil {
|
||||
_ = c.Send(tr(c.Sender().ID, "range_error"))
|
||||
return err
|
||||
}
|
||||
up.AddRange(c, hash, from, to)
|
||||
}
|
||||
return nil
|
||||
} else {
|
||||
return c.Send(tr(c.Sender().ID, "add_magnet"))
|
||||
}
|
||||
})
|
||||
|
||||
b.Handle(tele.OnQuery, handleInlineQuery)
|
||||
|
||||
b.Handle(tele.OnCallback, func(c tele.Context) error {
|
||||
args := c.Args()
|
||||
if len(args) > 0 {
|
||||
cbInfo := strings.TrimPrefix(args[0], "\f")
|
||||
if len(args) >= 2 {
|
||||
cbInfo += " " + args[1]
|
||||
}
|
||||
cbInfo = logSafeStr(cbInfo, 80)
|
||||
log.TLogln("tg cb", logUser(c.Sender()), cbInfo)
|
||||
}
|
||||
err := handleCallback(c)
|
||||
if err != nil && len(args) > 0 {
|
||||
log.TLogln("tg cb err", logUser(c.Sender()), logSafeStr(args[0], 40), err)
|
||||
}
|
||||
return err
|
||||
})
|
||||
|
||||
up.Start()
|
||||
|
||||
go b.Start()
|
||||
return nil
|
||||
}
|
||||
|
||||
func help(c tele.Context) error {
|
||||
uid := c.Sender().ID
|
||||
id := strconv.FormatInt(uid, 10)
|
||||
var arr []string
|
||||
if c.Sender().Username != "" {
|
||||
arr = append(arr, c.Sender().Username)
|
||||
}
|
||||
if c.Sender().FirstName != "" {
|
||||
arr = append(arr, c.Sender().FirstName)
|
||||
}
|
||||
if c.Sender().LastName != "" {
|
||||
arr = append(arr, c.Sender().LastName)
|
||||
}
|
||||
msg := "🤖 <b>" + tr(uid, "help") + "</b>\n\n"
|
||||
msg += "📋 <b>" + tr(uid, "help_main") + "</b>\n"
|
||||
msg += " • /help — " + tr(uid, "help_help") + "\n"
|
||||
msg += " • " + tr(uid, "help_list") + "\n"
|
||||
msg += " • " + tr(uid, "help_clear") + "\n"
|
||||
msg += " • " + tr(uid, "help_add") + "\n"
|
||||
msg += " • " + tr(uid, "help_hash") + "\n"
|
||||
msg += " • /stats, /stat — " + tr(uid, "help_stats") + ", " + tr(uid, "help_stat") + "\n\n"
|
||||
msg += "🎛 <b>" + tr(uid, "help_manage") + "</b> " + tr(uid, "help_manage_desc") + "\n"
|
||||
msg += " • " + tr(uid, "help_remove") + "\n"
|
||||
msg += " • " + tr(uid, "help_links") + "\n\n"
|
||||
msg += "🔍 <b>" + tr(uid, "help_search") + "</b> " + tr(uid, "help_search_desc") + "\n"
|
||||
msg += " • " + tr(uid, "help_search_cmd") + "\n\n"
|
||||
msg += "📦 <b>" + tr(uid, "help_export_import") + "</b>\n"
|
||||
msg += " • " + tr(uid, "help_export") + "\n"
|
||||
msg += " • " + tr(uid, "help_import") + "\n\n"
|
||||
msg += "📁 <b>" + tr(uid, "help_categories_section") + "</b>\n"
|
||||
msg += " • " + tr(uid, "help_categories") + "\n\n"
|
||||
msg += "🖥 <b>" + tr(uid, "help_server") + "</b>\n"
|
||||
msg += " • " + tr(uid, "help_server_cmd") + "\n"
|
||||
msg += " • " + tr(uid, "help_echo") + "\n"
|
||||
msg += " • " + tr(uid, "help_db") + "\n\n"
|
||||
msg += "⚙️ <b>" + tr(uid, "help_other") + "</b>\n"
|
||||
msg += " • " + tr(uid, "help_other_cmd") + "\n"
|
||||
msg += " • " + tr(uid, "help_lang") + "\n"
|
||||
msg += " • " + tr(uid, "help_admin") + "\n\n"
|
||||
msg += "👤 " + tr(uid, "help_id") + ": <code>" + id + "</code>"
|
||||
if len(arr) > 0 {
|
||||
msg += " • " + strings.Join(arr, ", ")
|
||||
}
|
||||
return c.Send(msg)
|
||||
}
|
||||
|
||||
func isHash(txt string) bool {
|
||||
if len(txt) == 40 {
|
||||
for _, c := range strings.ToLower(txt) {
|
||||
switch c {
|
||||
case 'a', 'b', 'c', 'd', 'e', 'f', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func ParseRange(userID int64, rng string) (int, int, error) {
|
||||
parts := strings.Split(rng, "-")
|
||||
|
||||
if len(parts) != 2 {
|
||||
return -1, -1, errors.New(tr(userID, "parse_range_err"))
|
||||
}
|
||||
|
||||
num1, err1 := strconv.Atoi(strings.TrimSpace(parts[0]))
|
||||
if err1 != nil {
|
||||
return -1, -1, err1
|
||||
}
|
||||
|
||||
num2, err2 := strconv.Atoi(strings.TrimSpace(parts[1]))
|
||||
if err2 != nil {
|
||||
return -1, -1, err2
|
||||
}
|
||||
if num1 < 1 || num2 < 1 || num1 > num2 {
|
||||
return -1, -1, errors.New(tr(userID, "parse_range_err"))
|
||||
}
|
||||
return num1, num2, nil
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
tele "gopkg.in/telebot.v4"
|
||||
"server/torr"
|
||||
)
|
||||
|
||||
func cmdCache(c tele.Context) error {
|
||||
arg := ""
|
||||
if args := c.Args(); len(args) > 0 {
|
||||
arg = args[0]
|
||||
}
|
||||
hash := resolveHash(c, arg)
|
||||
if hash == "" {
|
||||
return c.Send(tr(c.Sender().ID, "cache_usage"))
|
||||
}
|
||||
|
||||
t := torr.GetTorrent(hash)
|
||||
if t == nil {
|
||||
return c.Send(tr(c.Sender().ID, "torrent_not_found") + ":\n<code>" + hash + "</code>")
|
||||
}
|
||||
|
||||
st := t.CacheState()
|
||||
if st == nil {
|
||||
return c.Send(fmt.Sprintf(tr(c.Sender().ID, "cache_unavailable"), hash))
|
||||
}
|
||||
|
||||
uid := c.Sender().ID
|
||||
txt := "💾 <b>" + escapeHtml(st.Torrent.Title) + "</b>\n\n"
|
||||
txt += fmt.Sprintf("%s: %s\n", tr(uid, "cache_capacity"), humanize.IBytes(uint64(st.Capacity)))
|
||||
txt += fmt.Sprintf("%s: %s\n", tr(uid, "cache_filled"), humanize.IBytes(uint64(st.Filled)))
|
||||
txt += fmt.Sprintf("%s: %d\n", tr(uid, "cache_pieces"), st.PiecesCount)
|
||||
txt += fmt.Sprintf("%s: %d\n", tr(uid, "cache_readers"), len(st.Readers))
|
||||
txt += fmt.Sprintf("<code>%s</code>", hash)
|
||||
return c.Send(txt)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package tgbot
|
||||
|
||||
import tele "gopkg.in/telebot.v4"
|
||||
|
||||
// handleCallback routes callback queries to appropriate handlers
|
||||
func handleCallback(c tele.Context) error {
|
||||
if c.Sender() == nil {
|
||||
return nil
|
||||
}
|
||||
args := c.Args()
|
||||
if len(args) == 0 {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "callback_unknown")})
|
||||
}
|
||||
|
||||
switch args[0] {
|
||||
case "\ffiles", "\fdelete", "\fupload", "\fuploadall", "\ffall", "\fcancel",
|
||||
"\ffstatus", "\ffm3u", "\fflink", "\ffdrop", "\ffstatusrefresh", "\ffstatusstop",
|
||||
"\fflist", "\ffrefresh", "\ffnop", "\ffpreload", "\ffitems", "\ffifresh",
|
||||
"\ffsnakerefresh", "\ffsnakestop":
|
||||
return handleCallbackTorrent(c, args)
|
||||
case "\ffadd", "\ffmore":
|
||||
return handleCallbackSearch(c, args)
|
||||
case "\ffexport", "\ffexportrefresh", "\ffhash", "\ffhashrefresh",
|
||||
"\ffstatusall", "\ffstatusallrefresh", "\ffdb", "\ffdbrefresh":
|
||||
return handleCallbackExport(c, args)
|
||||
case "\ffclear", "\ffshutdown", "\ffpreset", "\ffset":
|
||||
return handleCallbackAdmin(c, args)
|
||||
default:
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "callback_unknown")})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package tgbot
|
||||
|
||||
import tele "gopkg.in/telebot.v4"
|
||||
|
||||
func handleCallbackAdmin(c tele.Context, args []string) error {
|
||||
switch args[0] {
|
||||
case "\ffclear":
|
||||
if len(args) > 1 {
|
||||
return clearConfirm(c, args[1])
|
||||
}
|
||||
case "\ffshutdown":
|
||||
if len(args) > 1 {
|
||||
return shutdownConfirm(c, args[1])
|
||||
}
|
||||
case "\ffpreset":
|
||||
if len(args) > 1 {
|
||||
return presetConfirm(c, args[1])
|
||||
}
|
||||
case "\ffset":
|
||||
if len(args) > 1 {
|
||||
action := args[1]
|
||||
for i := 2; i < len(args); i++ {
|
||||
action += "|" + args[i]
|
||||
}
|
||||
return settingsCallback(c, action)
|
||||
}
|
||||
}
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "callback_unknown")})
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package tgbot
|
||||
|
||||
import tele "gopkg.in/telebot.v4"
|
||||
|
||||
func handleCallbackExport(c tele.Context, args []string) error {
|
||||
switch args[0] {
|
||||
case "\ffexport":
|
||||
data := ""
|
||||
if len(args) > 1 {
|
||||
data = args[1]
|
||||
}
|
||||
return callbackExportPage(c, data)
|
||||
case "\ffexportrefresh":
|
||||
data := ""
|
||||
if len(args) > 1 {
|
||||
data = args[1]
|
||||
}
|
||||
return callbackExportRefresh(c, data)
|
||||
case "\ffhash":
|
||||
data := ""
|
||||
if len(args) > 1 {
|
||||
data = args[1]
|
||||
}
|
||||
return callbackHashPage(c, data)
|
||||
case "\ffhashrefresh":
|
||||
data := ""
|
||||
if len(args) > 1 {
|
||||
data = args[1]
|
||||
}
|
||||
return callbackHashRefresh(c, data)
|
||||
case "\ffstatusall":
|
||||
data := ""
|
||||
if len(args) > 1 {
|
||||
data = args[1]
|
||||
}
|
||||
return callbackStatusAllPage(c, data)
|
||||
case "\ffstatusallrefresh":
|
||||
data := ""
|
||||
if len(args) > 1 {
|
||||
data = args[1]
|
||||
}
|
||||
return callbackStatusAllRefresh(c, data)
|
||||
case "\ffdb":
|
||||
data := ""
|
||||
if len(args) > 1 {
|
||||
data = args[1]
|
||||
}
|
||||
return callbackDbPage(c, data)
|
||||
case "\ffdbrefresh":
|
||||
data := ""
|
||||
if len(args) > 1 {
|
||||
data = args[1]
|
||||
}
|
||||
return callbackDbRefresh(c, data)
|
||||
}
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "callback_unknown")})
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package tgbot
|
||||
|
||||
import tele "gopkg.in/telebot.v4"
|
||||
|
||||
func handleCallbackSearch(c tele.Context, args []string) error {
|
||||
switch args[0] {
|
||||
case "\ffadd":
|
||||
if len(args) > 1 {
|
||||
return callbackSearchAdd(c, args[1])
|
||||
}
|
||||
case "\ffmore":
|
||||
if len(args) > 1 {
|
||||
return callbackSearchMore(c, args[1])
|
||||
}
|
||||
}
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "callback_unknown")})
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
tele "gopkg.in/telebot.v4"
|
||||
up "server/tgbot/upload"
|
||||
)
|
||||
|
||||
func handleCallbackTorrent(c tele.Context, args []string) error {
|
||||
switch args[0] {
|
||||
case "\ffiles":
|
||||
return files(c)
|
||||
case "\fdelete":
|
||||
if len(args) < 2 {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "callback_unknown")})
|
||||
}
|
||||
deleteTorrent(c)
|
||||
_ = c.Bot().Delete(c.Callback().Message)
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "deleted")})
|
||||
case "\fupload":
|
||||
return upload(c)
|
||||
case "\fuploadall", "\ffall":
|
||||
return uploadall(c)
|
||||
case "\fcancel":
|
||||
if len(args) > 1 {
|
||||
if num, err := strconv.Atoi(args[1]); err == nil {
|
||||
up.Cancel(num)
|
||||
_ = c.Bot().Delete(c.Callback().Message)
|
||||
return c.Respond(&tele.CallbackResponse{})
|
||||
}
|
||||
}
|
||||
return c.Respond(&tele.CallbackResponse{})
|
||||
case "\ffstatus", "\ffm3u", "\fflink", "\ffdrop", "\ffstatusrefresh", "\ffstatusstop":
|
||||
hash := ""
|
||||
if len(args) >= 2 {
|
||||
hash = args[1]
|
||||
}
|
||||
switch args[0] {
|
||||
case "\ffstatus":
|
||||
if hash == "" {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "callback_unknown")})
|
||||
}
|
||||
return callbackStatus(c, hash)
|
||||
case "\ffstatusrefresh":
|
||||
return callbackStatusRefresh(c, hash)
|
||||
case "\ffstatusstop":
|
||||
return callbackStatusStop(c, hash)
|
||||
case "\ffm3u":
|
||||
if hash == "" {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "callback_unknown")})
|
||||
}
|
||||
return callbackM3u(c, hash)
|
||||
case "\fflink":
|
||||
if len(args) < 2 {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "callback_unknown")})
|
||||
}
|
||||
return callbackLink(c, args[1])
|
||||
case "\ffdrop":
|
||||
if hash == "" {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "callback_unknown")})
|
||||
}
|
||||
return callbackDrop(c, hash)
|
||||
}
|
||||
case "\fflist":
|
||||
if len(args) > 1 {
|
||||
return callbackListPage(c, args[1])
|
||||
}
|
||||
case "\ffrefresh":
|
||||
if len(args) > 1 {
|
||||
return callbackListRefresh(c, args[1])
|
||||
}
|
||||
case "\ffitems":
|
||||
if len(args) >= 3 {
|
||||
return callbackFileListPage(c, args[1], args[2])
|
||||
}
|
||||
case "\ffifresh":
|
||||
if len(args) >= 3 {
|
||||
return callbackFileListRefresh(c, args[1], args[2])
|
||||
}
|
||||
case "\ffnop":
|
||||
return c.Respond(&tele.CallbackResponse{})
|
||||
case "\ffpreload":
|
||||
if len(args) >= 3 {
|
||||
return callbackPreload(c, args[1], args[2])
|
||||
}
|
||||
case "\ffsnakerefresh", "\ffsnakestop":
|
||||
data := ""
|
||||
if len(args) >= 2 {
|
||||
data = args[1]
|
||||
}
|
||||
switch args[0] {
|
||||
case "\ffsnakerefresh":
|
||||
return callbackSnakeRefresh(c, data)
|
||||
case "\ffsnakestop":
|
||||
return callbackSnakeStop(c, data)
|
||||
}
|
||||
}
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "callback_unknown")})
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
tele "gopkg.in/telebot.v4"
|
||||
"server/torr"
|
||||
)
|
||||
|
||||
func cmdCategories(c tele.Context) error {
|
||||
torrents := torr.ListTorrent()
|
||||
if len(torrents) == 0 {
|
||||
return c.Send(tr(c.Sender().ID, "no_torrents"))
|
||||
}
|
||||
uid := c.Sender().ID
|
||||
catCount := make(map[string]int)
|
||||
for _, t := range torrents {
|
||||
cat := t.Category
|
||||
if cat == "" {
|
||||
cat = tr(uid, "categories_uncategorized")
|
||||
}
|
||||
catCount[cat]++
|
||||
}
|
||||
var cats []string
|
||||
for c := range catCount {
|
||||
cats = append(cats, c)
|
||||
}
|
||||
sort.Strings(cats)
|
||||
var sb strings.Builder
|
||||
fmt.Fprintf(&sb, "📁 <b>%s</b>\n\n", tr(uid, "categories_title"))
|
||||
for _, cat := range cats {
|
||||
fmt.Fprintf(&sb, "• %s: %d\n", escapeHtml(cat), catCount[cat])
|
||||
}
|
||||
return c.Send(strings.TrimSuffix(sb.String(), "\n"))
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"server/log"
|
||||
"server/settings"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
HostTG string
|
||||
HostWeb string
|
||||
Socks5 string
|
||||
WhiteIds []int64
|
||||
BlackIds []int64
|
||||
}
|
||||
|
||||
var Cfg *Config
|
||||
|
||||
func LoadConfig() {
|
||||
Cfg = &Config{}
|
||||
fn := filepath.Join(settings.Path, "tg.cfg")
|
||||
buf, err := os.ReadFile(fn)
|
||||
if err != nil {
|
||||
Cfg.WhiteIds = []int64{}
|
||||
Cfg.BlackIds = []int64{}
|
||||
Cfg.HostTG = "https://api.telegram.org"
|
||||
buf, _ = json.MarshalIndent(Cfg, "", " ")
|
||||
if buf != nil {
|
||||
os.WriteFile(fn, buf, 0o600)
|
||||
}
|
||||
return
|
||||
}
|
||||
err = json.Unmarshal(buf, &Cfg)
|
||||
if err != nil {
|
||||
log.TLogln("tg config read err", err)
|
||||
Cfg.WhiteIds = []int64{}
|
||||
Cfg.BlackIds = []int64{}
|
||||
}
|
||||
if Cfg.HostTG == "" || (!strings.HasPrefix(Cfg.HostTG, "http://") && !strings.HasPrefix(Cfg.HostTG, "https://")) {
|
||||
Cfg.HostTG = "https://api.telegram.org"
|
||||
}
|
||||
if Cfg.WhiteIds == nil {
|
||||
Cfg.WhiteIds = []int64{}
|
||||
}
|
||||
if Cfg.BlackIds == nil {
|
||||
Cfg.BlackIds = []int64{}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
tele "gopkg.in/telebot.v4"
|
||||
"server/log"
|
||||
sets "server/settings"
|
||||
)
|
||||
|
||||
const dbPageSize = 10
|
||||
|
||||
func cmdDb(c tele.Context) error {
|
||||
return sendDbPage(c, 0)
|
||||
}
|
||||
|
||||
func sendDbPage(c tele.Context, page int) error {
|
||||
uid := c.Sender().ID
|
||||
dbList := sets.ListTorrent()
|
||||
if len(dbList) == 0 {
|
||||
return c.Send(tr(uid, "db_empty"))
|
||||
}
|
||||
|
||||
totalPages := (len(dbList) + dbPageSize - 1) / dbPageSize
|
||||
if page < 0 {
|
||||
page = 0
|
||||
}
|
||||
if page >= totalPages {
|
||||
page = totalPages - 1
|
||||
}
|
||||
start := page * dbPageSize
|
||||
end := start + dbPageSize
|
||||
if end > len(dbList) {
|
||||
end = len(dbList)
|
||||
}
|
||||
pageList := dbList[start:end]
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("📁 <b>" + tr(uid, "db_title") + "</b> (" + strconv.Itoa(len(dbList)) + ")\n\n")
|
||||
for i, t := range pageList {
|
||||
hash := t.InfoHash.HexString()
|
||||
sb.WriteString(strconv.Itoa(start+i+1) + ". <b>" + escapeHtml(t.Title) + "</b>")
|
||||
if t.Size > 0 {
|
||||
sb.WriteString(" <i>" + humanize.IBytes(uint64(t.Size)) + "</i>")
|
||||
}
|
||||
sb.WriteString("\n<code>" + hash + "</code>\n\n")
|
||||
}
|
||||
msg := strings.TrimSuffix(sb.String(), "\n\n")
|
||||
|
||||
navRow := []tele.InlineButton{}
|
||||
if totalPages > 1 {
|
||||
if page > 0 {
|
||||
navRow = append(navRow, tele.InlineButton{Text: "◀️", Unique: "fdb", Data: strconv.Itoa(page - 1)})
|
||||
}
|
||||
navRow = append(navRow, tele.InlineButton{Text: strconv.Itoa(page+1) + "/" + strconv.Itoa(totalPages), Unique: "fnop", Data: ""})
|
||||
if page < totalPages-1 {
|
||||
navRow = append(navRow, tele.InlineButton{Text: "▶️", Unique: "fdb", Data: strconv.Itoa(page + 1)})
|
||||
}
|
||||
}
|
||||
navRow = append(navRow, tele.InlineButton{Text: "🔄", Unique: "fdbrefresh", Data: strconv.Itoa(page)})
|
||||
|
||||
kbd := &tele.ReplyMarkup{InlineKeyboard: [][]tele.InlineButton{navRow}}
|
||||
if err := c.Send(msg, kbd); err != nil {
|
||||
log.TLogln("tg db send err", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func callbackDbPage(c tele.Context, data string) error {
|
||||
page := 0
|
||||
if data != "" {
|
||||
if p, err := strconv.Atoi(data); err == nil {
|
||||
page = p
|
||||
}
|
||||
}
|
||||
_ = c.Respond(&tele.CallbackResponse{})
|
||||
if c.Callback().Message != nil {
|
||||
_ = c.Bot().Delete(c.Callback().Message)
|
||||
}
|
||||
return sendDbPage(c, page)
|
||||
}
|
||||
|
||||
func callbackDbRefresh(c tele.Context, data string) error {
|
||||
page := 0
|
||||
if data != "" {
|
||||
if p, err := strconv.Atoi(data); err == nil {
|
||||
page = p
|
||||
}
|
||||
}
|
||||
_ = c.Respond(&tele.CallbackResponse{Text: "🔄"})
|
||||
if c.Callback().Message != nil {
|
||||
_ = c.Bot().Delete(c.Callback().Message)
|
||||
}
|
||||
return sendDbPage(c, page)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
tele "gopkg.in/telebot.v4"
|
||||
"server/torr"
|
||||
)
|
||||
|
||||
func deleteTorrent(c tele.Context) {
|
||||
args := c.Args()
|
||||
if len(args) < 2 {
|
||||
return
|
||||
}
|
||||
hash := args[1]
|
||||
if !isHash(hash) {
|
||||
return
|
||||
}
|
||||
torr.RemTorrent(hash)
|
||||
}
|
||||
|
||||
func clear(c tele.Context) error {
|
||||
torrents := torr.ListTorrent()
|
||||
count := len(torrents)
|
||||
if count == 0 {
|
||||
return c.Send(tr(c.Sender().ID, "no_torrents"))
|
||||
}
|
||||
uid := c.Sender().ID
|
||||
btnYes := tele.InlineButton{Text: tr(uid, "btn_yes"), Unique: "fclear", Data: "1"}
|
||||
btnNo := tele.InlineButton{Text: tr(uid, "btn_no"), Unique: "fclear", Data: "0"}
|
||||
kbd := &tele.ReplyMarkup{InlineKeyboard: [][]tele.InlineButton{{btnYes, btnNo}}}
|
||||
return c.Send(fmt.Sprintf(tr(uid, "clear_confirm"), count), kbd)
|
||||
}
|
||||
|
||||
func clearConfirm(c tele.Context, confirm string) error {
|
||||
uid := c.Sender().ID
|
||||
if confirm != "1" {
|
||||
_ = c.Respond(&tele.CallbackResponse{Text: tr(uid, "canceled")})
|
||||
return c.Bot().Delete(c.Callback().Message)
|
||||
}
|
||||
torrents := torr.ListTorrent()
|
||||
count := len(torrents)
|
||||
for _, t := range torrents {
|
||||
torr.RemTorrent(t.Hash().HexString())
|
||||
}
|
||||
_ = c.Respond(&tele.CallbackResponse{Text: tr(uid, "deleted")})
|
||||
_ = c.Bot().Delete(c.Callback().Message)
|
||||
return c.Send(fmt.Sprintf(tr(uid, "clear_done"), count))
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
tele "gopkg.in/telebot.v4"
|
||||
"server/torr"
|
||||
)
|
||||
|
||||
func callbackDrop(c tele.Context, hash string) error {
|
||||
torr.DropTorrent(hash)
|
||||
_ = c.Bot().Delete(c.Callback().Message)
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "drop_done")})
|
||||
}
|
||||
|
||||
func cmdDrop(c tele.Context) error {
|
||||
arg := ""
|
||||
if args := c.Args(); len(args) > 0 {
|
||||
arg = args[0]
|
||||
}
|
||||
hash := resolveHash(c, arg)
|
||||
if hash == "" {
|
||||
return c.Send(tr(c.Sender().ID, "remove_usage"))
|
||||
}
|
||||
|
||||
torr.DropTorrent(hash)
|
||||
return c.Send(fmt.Sprintf(tr(c.Sender().ID, "drop_done_hash"), hash))
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
tele "gopkg.in/telebot.v4"
|
||||
"server/version"
|
||||
)
|
||||
|
||||
func cmdEcho(c tele.Context) error {
|
||||
v := version.Version
|
||||
if v == "" {
|
||||
v = "unknown"
|
||||
}
|
||||
return c.Send(fmt.Sprintf("🔄 TorrServer %s", v))
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
tele "gopkg.in/telebot.v4"
|
||||
"server/log"
|
||||
"server/torr"
|
||||
)
|
||||
|
||||
const exportPageSize = 10
|
||||
|
||||
func cmdExport(c tele.Context) error {
|
||||
torrents := torr.ListTorrent()
|
||||
if len(torrents) == 0 {
|
||||
return c.Send(tr(c.Sender().ID, "no_torrents"))
|
||||
}
|
||||
uid := c.Sender().ID
|
||||
|
||||
var magnets strings.Builder
|
||||
for _, t := range torrents {
|
||||
hash := t.Hash().HexString()
|
||||
title := t.Title
|
||||
if title == "" {
|
||||
title = t.Name()
|
||||
}
|
||||
magnet := fmt.Sprintf("magnet:?xt=urn:btih:%s", hash)
|
||||
if title != "" {
|
||||
magnet += "&dn=" + url.QueryEscape(title)
|
||||
}
|
||||
magnets.WriteString(magnet + "\n")
|
||||
}
|
||||
|
||||
doc := &tele.Document{}
|
||||
doc.FileName = "torrents.txt"
|
||||
doc.FileReader = bytes.NewReader([]byte(strings.TrimSuffix(magnets.String(), "\n")))
|
||||
doc.Caption = "📁 " + tr(uid, "export_file_caption")
|
||||
if err := c.Send(doc); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return sendExportPage(c, 0)
|
||||
}
|
||||
|
||||
func sendExportPage(c tele.Context, page int) error {
|
||||
torrents := torr.ListTorrent()
|
||||
if len(torrents) == 0 {
|
||||
return c.Send(tr(c.Sender().ID, "no_torrents"))
|
||||
}
|
||||
|
||||
totalPages := (len(torrents) + exportPageSize - 1) / exportPageSize
|
||||
if page < 0 {
|
||||
page = 0
|
||||
}
|
||||
if page >= totalPages {
|
||||
page = totalPages - 1
|
||||
}
|
||||
start := page * exportPageSize
|
||||
end := start + exportPageSize
|
||||
if end > len(torrents) {
|
||||
end = len(torrents)
|
||||
}
|
||||
pageTorrents := torrents[start:end]
|
||||
|
||||
uid := c.Sender().ID
|
||||
var hashes strings.Builder
|
||||
fmt.Fprintf(&hashes, "📁 <b>%s</b> (%d)\n\n", tr(uid, "export_title"), len(torrents))
|
||||
for i, t := range pageTorrents {
|
||||
hash := t.Hash().HexString()
|
||||
title := t.Title
|
||||
if title == "" {
|
||||
title = t.Name()
|
||||
}
|
||||
fmt.Fprintf(&hashes, "%d. %s\n<code>%s</code>\n\n", start+i+1, escapeHtml(title), hash)
|
||||
}
|
||||
msg := strings.TrimSuffix(hashes.String(), "\n\n")
|
||||
|
||||
navRow := []tele.InlineButton{}
|
||||
if totalPages > 1 {
|
||||
if page > 0 {
|
||||
navRow = append(navRow, tele.InlineButton{Text: "◀️", Unique: "fexport", Data: strconv.Itoa(page - 1)})
|
||||
}
|
||||
navRow = append(navRow, tele.InlineButton{Text: strconv.Itoa(page+1) + "/" + strconv.Itoa(totalPages), Unique: "fnop", Data: ""})
|
||||
if page < totalPages-1 {
|
||||
navRow = append(navRow, tele.InlineButton{Text: "▶️", Unique: "fexport", Data: strconv.Itoa(page + 1)})
|
||||
}
|
||||
}
|
||||
navRow = append(navRow, tele.InlineButton{Text: "🔄", Unique: "fexportrefresh", Data: strconv.Itoa(page)})
|
||||
|
||||
kbd := &tele.ReplyMarkup{InlineKeyboard: [][]tele.InlineButton{navRow}}
|
||||
if err := c.Send(msg, kbd); err != nil {
|
||||
log.TLogln("tg export send err", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func callbackExportPage(c tele.Context, data string) error {
|
||||
page := 0
|
||||
if data != "" {
|
||||
if p, err := strconv.Atoi(data); err == nil {
|
||||
page = p
|
||||
}
|
||||
}
|
||||
_ = c.Respond(&tele.CallbackResponse{})
|
||||
if c.Callback().Message != nil {
|
||||
_ = c.Bot().Delete(c.Callback().Message)
|
||||
}
|
||||
return sendExportPage(c, page)
|
||||
}
|
||||
|
||||
func callbackExportRefresh(c tele.Context, data string) error {
|
||||
page := 0
|
||||
if data != "" {
|
||||
if p, err := strconv.Atoi(data); err == nil {
|
||||
page = p
|
||||
}
|
||||
}
|
||||
_ = c.Respond(&tele.CallbackResponse{Text: "🔄"})
|
||||
if c.Callback().Message != nil {
|
||||
_ = c.Bot().Delete(c.Callback().Message)
|
||||
}
|
||||
return sendExportPage(c, page)
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"server/ffprobe"
|
||||
"server/settings"
|
||||
"server/torr"
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
tele "gopkg.in/telebot.v4"
|
||||
ffp "gopkg.in/vansante/go-ffprobe.v2"
|
||||
)
|
||||
|
||||
// TODO: Use internal API for ffp
|
||||
|
||||
func cmdFfp(c tele.Context) error {
|
||||
uid := c.Sender().ID
|
||||
args := c.Args()
|
||||
if len(args) < 2 {
|
||||
return c.Send(tr(uid, "ffp_usage"))
|
||||
}
|
||||
hash := resolveHash(c, args[0])
|
||||
if hash == "" {
|
||||
return c.Send(tr(uid, "invalid_hash"))
|
||||
}
|
||||
id, err := strconv.Atoi(args[1])
|
||||
if err != nil || id < 1 {
|
||||
return c.Send(tr(uid, "ffp_file_index"))
|
||||
}
|
||||
|
||||
asJSON := false
|
||||
if len(args) >= 3 {
|
||||
last := strings.ToLower(strings.TrimSpace(args[len(args)-1]))
|
||||
if last == "json" || last == "--json" || last == "-j" {
|
||||
asJSON = true
|
||||
}
|
||||
}
|
||||
|
||||
t := torr.GetTorrent(hash)
|
||||
if t == nil {
|
||||
return c.Send(tr(uid, "torrent_not_found"))
|
||||
}
|
||||
|
||||
proto := "http"
|
||||
port := settings.Port
|
||||
if settings.Ssl {
|
||||
proto = "https"
|
||||
port = settings.SslPort
|
||||
}
|
||||
link := fmt.Sprintf("%s://127.0.0.1:%s/play/%s/%d", proto, port, hash, id)
|
||||
|
||||
data, err := ffprobe.ProbeUrl(link)
|
||||
if err != nil {
|
||||
return c.Send(fmt.Sprintf(tr(uid, "ffp_error"), err.Error()))
|
||||
}
|
||||
|
||||
var msg string
|
||||
if asJSON {
|
||||
buf, _ := json.MarshalIndent(data, "", " ")
|
||||
msg = "<pre>" + strings.ReplaceAll(string(buf), "<", "<") + "</pre>"
|
||||
if len(msg) > 4000 {
|
||||
msg = msg[:4000] + "\n...</pre>"
|
||||
}
|
||||
} else {
|
||||
msg = formatFfpHuman(data, uid)
|
||||
if len(msg) > 4000 {
|
||||
msg = msg[:4000] + "\n..."
|
||||
}
|
||||
}
|
||||
return c.Send(msg)
|
||||
}
|
||||
|
||||
func formatFfpHuman(data *ffp.ProbeData, uid int64) string {
|
||||
var sb strings.Builder
|
||||
|
||||
if data.Format != nil {
|
||||
f := data.Format
|
||||
sb.WriteString("<b>📁 " + tr(uid, "ffp_format") + "</b>\n")
|
||||
fmt.Fprintf(&sb, " %s: %s\n", tr(uid, "ffp_container"), f.FormatLongName)
|
||||
if f.DurationSeconds > 0 {
|
||||
d := int(f.DurationSeconds)
|
||||
h, m, s := d/3600, (d%3600)/60, d%60
|
||||
fmt.Fprintf(&sb, " %s: %02d:%02d:%02d\n", tr(uid, "ffp_duration"), h, m, s)
|
||||
}
|
||||
if f.Size != "" {
|
||||
if size, err := strconv.ParseInt(f.Size, 10, 64); err == nil {
|
||||
fmt.Fprintf(&sb, " %s: %s\n", tr(uid, "ffp_size"), humanize.IBytes(uint64(size)))
|
||||
} else {
|
||||
fmt.Fprintf(&sb, " %s: %s\n", tr(uid, "ffp_size"), f.Size)
|
||||
}
|
||||
}
|
||||
if f.BitRate != "" {
|
||||
if br, err := strconv.ParseInt(f.BitRate, 10, 64); err == nil {
|
||||
fmt.Fprintf(&sb, " %s: %s/s\n", tr(uid, "ffp_bitrate"), humanize.IBytes(uint64(br)))
|
||||
} else {
|
||||
fmt.Fprintf(&sb, " %s: %s\n", tr(uid, "ffp_bitrate"), f.BitRate)
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
sb.WriteString("<b>🎬 " + tr(uid, "ffp_streams") + "</b>\n\n")
|
||||
for i, s := range data.Streams {
|
||||
title := getTag(s.TagList, "title")
|
||||
lang := getTag(s.TagList, "language")
|
||||
if lang != "" {
|
||||
lang = " [" + lang + "]"
|
||||
}
|
||||
|
||||
switch s.CodecType {
|
||||
case "video":
|
||||
fmt.Fprintf(&sb, "<b>#%d %s</b>%s\n", i, tr(uid, "ffp_video"), lang)
|
||||
fmt.Fprintf(&sb, " %s: %s", tr(uid, "ffp_codec"), s.CodecLongName)
|
||||
if s.Profile != "" {
|
||||
fmt.Fprintf(&sb, " (%s)", s.Profile)
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
if s.Width > 0 && s.Height > 0 {
|
||||
fmt.Fprintf(&sb, " %s: %d×%d", tr(uid, "ffp_resolution"), s.Width, s.Height)
|
||||
if s.DisplayAspectRatio != "" && s.DisplayAspectRatio != "0:0" {
|
||||
fmt.Fprintf(&sb, " (%s)", s.DisplayAspectRatio)
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
if s.PixFmt != "" {
|
||||
fmt.Fprintf(&sb, " %s: %s\n", tr(uid, "ffp_pixel"), s.PixFmt)
|
||||
}
|
||||
if s.RFrameRate != "" && s.RFrameRate != "0/0" {
|
||||
fmt.Fprintf(&sb, " %s: %s\n", tr(uid, "ffp_fps"), s.RFrameRate)
|
||||
}
|
||||
if s.BitRate != "" {
|
||||
if br, err := strconv.ParseInt(s.BitRate, 10, 64); err == nil {
|
||||
fmt.Fprintf(&sb, " %s: %s/s\n", tr(uid, "ffp_bitrate"), humanize.IBytes(uint64(br)))
|
||||
}
|
||||
}
|
||||
if s.ColorSpace != "" || s.ColorTransfer != "" {
|
||||
fmt.Fprintf(&sb, " %s: %s / %s / %s\n", tr(uid, "ffp_color"), s.ColorSpace, s.ColorTransfer, s.ColorPrimaries)
|
||||
}
|
||||
if title != "" {
|
||||
fmt.Fprintf(&sb, " %s: %s\n", tr(uid, "ffp_title"), escapeHtml(title))
|
||||
}
|
||||
|
||||
case "audio":
|
||||
fmt.Fprintf(&sb, "<b>#%d %s</b>%s\n", i, tr(uid, "ffp_audio"), lang)
|
||||
fmt.Fprintf(&sb, " %s: %s", tr(uid, "ffp_codec"), s.CodecLongName)
|
||||
if s.Profile != "" {
|
||||
fmt.Fprintf(&sb, " (%s)", s.Profile)
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
if s.SampleRate != "" {
|
||||
fmt.Fprintf(&sb, " %s: %s Hz\n", tr(uid, "ffp_samplerate"), s.SampleRate)
|
||||
}
|
||||
if s.Channels > 0 {
|
||||
ch := s.ChannelLayout
|
||||
if ch == "" {
|
||||
ch = fmt.Sprintf("%d ch", s.Channels)
|
||||
}
|
||||
fmt.Fprintf(&sb, " %s: %s\n", tr(uid, "ffp_channels"), ch)
|
||||
}
|
||||
if s.BitRate != "" {
|
||||
if br, err := strconv.ParseInt(s.BitRate, 10, 64); err == nil {
|
||||
fmt.Fprintf(&sb, " %s: %s/s\n", tr(uid, "ffp_bitrate"), humanize.IBytes(uint64(br)))
|
||||
}
|
||||
}
|
||||
if title != "" {
|
||||
fmt.Fprintf(&sb, " %s: %s\n", tr(uid, "ffp_title"), escapeHtml(title))
|
||||
}
|
||||
|
||||
case "subtitle":
|
||||
fmt.Fprintf(&sb, "<b>#%d %s</b>%s\n", i, tr(uid, "ffp_subtitle"), lang)
|
||||
fmt.Fprintf(&sb, " %s: %s\n", tr(uid, "ffp_codec"), s.CodecLongName)
|
||||
if title != "" {
|
||||
fmt.Fprintf(&sb, " %s: %s\n", tr(uid, "ffp_title"), escapeHtml(title))
|
||||
}
|
||||
|
||||
default:
|
||||
fmt.Fprintf(&sb, "<b>#%d %s</b>\n", i, s.CodecType)
|
||||
fmt.Fprintf(&sb, " %s: %s\n", tr(uid, "ffp_codec"), s.CodecLongName)
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
return strings.TrimSuffix(sb.String(), "\n\n")
|
||||
}
|
||||
|
||||
func getTag(tags ffp.Tags, key string) string {
|
||||
if tags == nil {
|
||||
return ""
|
||||
}
|
||||
if v, ok := tags[key]; ok && v != nil {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
for k, v := range tags {
|
||||
if strings.HasPrefix(k, key+"-") && v != nil {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
tele "gopkg.in/telebot.v4"
|
||||
|
||||
"server/log"
|
||||
sets "server/settings"
|
||||
"server/torr"
|
||||
)
|
||||
|
||||
// Telegram limits the serialized reply_markup size; many file rows with long
|
||||
// labels/URLs would exceed it (e.g. "reply markup is too long").
|
||||
const filesPageSize = 5
|
||||
|
||||
// Inline button text is limited to 64 characters in the Bot API.
|
||||
func truncateBtnText(s string) string {
|
||||
const max = 64
|
||||
r := []rune(s)
|
||||
if len(r) <= max {
|
||||
return s
|
||||
}
|
||||
if max <= 1 {
|
||||
return string(r[:max])
|
||||
}
|
||||
return string(r[:max-1]) + "…"
|
||||
}
|
||||
|
||||
func files(c tele.Context) error {
|
||||
args := c.Args()
|
||||
if len(args) < 2 {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "callback_unknown")})
|
||||
}
|
||||
hash := args[1]
|
||||
if !isHash(hash) {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "callback_unknown")})
|
||||
}
|
||||
msg, err := c.Bot().Send(c.Sender(), tr(c.Sender().ID, "connecting"))
|
||||
t := torr.GetTorrent(hash)
|
||||
if t == nil {
|
||||
if err == nil {
|
||||
_, _ = c.Bot().Edit(msg, tr(c.Sender().ID, "torrent_not_found")+":\n<code>"+hash+"</code>")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err == nil {
|
||||
api := c.Bot()
|
||||
recipient := c.Sender()
|
||||
uid := c.Sender().ID
|
||||
go sendFilesList(api, recipient, msg, hash, uid, 0)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// sendFilesList shows one page of per-file actions; fitems / fifresh change the page in-place.
|
||||
func sendFilesList(api tele.API, recipient tele.Recipient, statusMsg *tele.Message, hash string, uid int64, page int) {
|
||||
t := torr.GetTorrent(hash)
|
||||
for t != nil && !t.WaitInfo() {
|
||||
time.Sleep(time.Second)
|
||||
t = torr.GetTorrent(hash)
|
||||
}
|
||||
_ = api.Delete(statusMsg)
|
||||
t = torr.GetTorrent(hash)
|
||||
if t == nil {
|
||||
return
|
||||
}
|
||||
ti := t.Status()
|
||||
if ti == nil {
|
||||
return
|
||||
}
|
||||
|
||||
host := getHost()
|
||||
txt, kbd := buildFilesListView(t, host, uid, page)
|
||||
if kbd == nil {
|
||||
return
|
||||
}
|
||||
if _, err := api.Send(recipient, txt, kbd, tele.ModeHTML); err != nil {
|
||||
log.TLogln("tg files send err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func buildFilesListView(t *torr.Torrent, host string, uid int64, page int) (string, *tele.ReplyMarkup) {
|
||||
ti := t.Status()
|
||||
if ti == nil {
|
||||
return "", nil
|
||||
}
|
||||
hex := t.Hash().HexString()
|
||||
n := len(ti.FileStats)
|
||||
if n == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
totalPages := (n + filesPageSize - 1) / filesPageSize
|
||||
if page < 0 {
|
||||
page = 0
|
||||
}
|
||||
if page >= totalPages {
|
||||
page = totalPages - 1
|
||||
}
|
||||
start := page * filesPageSize
|
||||
end := start + filesPageSize
|
||||
if end > n {
|
||||
end = n
|
||||
}
|
||||
pageFiles := ti.FileStats[start:end]
|
||||
|
||||
viewedSet := make(map[int]struct{})
|
||||
for _, v := range sets.ListViewed(ti.Hash) {
|
||||
viewedSet[v.FileIndex] = struct{}{}
|
||||
}
|
||||
|
||||
txt := "📁 <b>" + escapeHtml(ti.Title) + "</b> " +
|
||||
"<i>" + humanize.IBytes(uint64(ti.TorrentSize)) + "</i>\n\n" +
|
||||
"<code>" + ti.Hash + "</code>"
|
||||
if totalPages > 1 {
|
||||
txt += "\n\n" + tr(uid, "page") + " " + strconv.Itoa(page+1) + "/" + strconv.Itoa(totalPages)
|
||||
}
|
||||
if n > 1 {
|
||||
txt += "\n\n" + fmt.Sprintf(tr(uid, "files_range_hint"), n)
|
||||
}
|
||||
|
||||
m := &tele.ReplyMarkup{}
|
||||
var rows []tele.Row
|
||||
|
||||
for _, f := range pageFiles {
|
||||
viewedMark := ""
|
||||
if _, ok := viewedSet[f.Id]; ok {
|
||||
viewedMark = "✓ "
|
||||
}
|
||||
baseName := filepath.Base(f.Path)
|
||||
mline := viewedMark + "#" + strconv.Itoa(f.Id) + ": " + humanize.IBytes(uint64(f.Length)) + " — " + baseName
|
||||
fileLabel := truncateBtnText(mline)
|
||||
idStr := strconv.Itoa(f.Id)
|
||||
streamURL := host + "/stream/" + filepath.Base(f.Path) + "?link=" + hex + "&index=" + idStr + "&play"
|
||||
rows = append(rows, m.Row(
|
||||
m.Data(fileLabel, "upload", ti.Hash, idStr),
|
||||
m.URL(tr(uid, "files_link"), streamURL),
|
||||
m.Data("⏳", "fpreload", ti.Hash, idStr),
|
||||
))
|
||||
}
|
||||
|
||||
if totalPages > 1 {
|
||||
var nav []tele.Btn
|
||||
if page > 0 {
|
||||
nav = append(nav, m.Data("◀️", "fitems", strconv.Itoa(page-1), ti.Hash))
|
||||
}
|
||||
nav = append(nav, m.Data(strconv.Itoa(page+1)+"/"+strconv.Itoa(totalPages), "fnop"))
|
||||
if page < totalPages-1 {
|
||||
nav = append(nav, m.Data("▶️", "fitems", strconv.Itoa(page+1), ti.Hash))
|
||||
}
|
||||
nav = append(nav, m.Data("🔄", "fifresh", strconv.Itoa(page), ti.Hash))
|
||||
rows = append(rows, m.Row(nav...))
|
||||
} else {
|
||||
rows = append(rows, m.Row(m.Data("🔄", "fifresh", strconv.Itoa(page), ti.Hash)))
|
||||
}
|
||||
if n > 1 {
|
||||
rows = append(rows, m.Row(m.Data(tr(uid, "files_download_all"), "fall", "all", ti.Hash)))
|
||||
}
|
||||
m.Inline(rows...)
|
||||
return txt, m
|
||||
}
|
||||
|
||||
func callbackFileListPage(c tele.Context, pageStr, hash string) error {
|
||||
page, err := strconv.Atoi(pageStr)
|
||||
if err != nil {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "callback_unknown")})
|
||||
}
|
||||
if !isHash(hash) {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "callback_unknown")})
|
||||
}
|
||||
_ = c.Respond(&tele.CallbackResponse{})
|
||||
|
||||
return editFilesListMessage(c, hash, c.Sender().ID, page)
|
||||
}
|
||||
|
||||
func callbackFileListRefresh(c tele.Context, pageStr, hash string) error {
|
||||
if !isHash(hash) {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "callback_unknown")})
|
||||
}
|
||||
page, err := strconv.Atoi(pageStr)
|
||||
if err != nil {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "callback_unknown")})
|
||||
}
|
||||
_ = c.Respond(&tele.CallbackResponse{Text: "🔄"})
|
||||
return editFilesListMessage(c, hash, c.Sender().ID, page)
|
||||
}
|
||||
|
||||
func editFilesListMessage(c tele.Context, hash string, uid int64, page int) error {
|
||||
t := torr.GetTorrent(hash)
|
||||
if t == nil {
|
||||
_ = c.Send(tr(uid, "torrent_not_found") + ":\n<code>" + hash + "</code>")
|
||||
return nil
|
||||
}
|
||||
for t != nil && !t.WaitInfo() {
|
||||
time.Sleep(time.Second)
|
||||
t = torr.GetTorrent(hash)
|
||||
}
|
||||
t = torr.GetTorrent(hash)
|
||||
if t == nil {
|
||||
_ = c.Send(tr(uid, "torrent_not_found") + ":\n<code>" + hash + "</code>")
|
||||
return nil
|
||||
}
|
||||
host := getHost()
|
||||
txt, kbd := buildFilesListView(t, host, uid, page)
|
||||
if kbd == nil {
|
||||
log.TLogln("tg files: empty kbd for hash", logSafeStr(hash, 20))
|
||||
return nil
|
||||
}
|
||||
if c.Callback() == nil || c.Callback().Message == nil {
|
||||
_, err := c.Bot().Send(c.Sender(), txt, kbd, tele.ModeHTML)
|
||||
return err
|
||||
}
|
||||
_, err := c.Bot().Edit(c.Callback().Message, txt, kbd, tele.ModeHTML)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "message is not modified") {
|
||||
return nil
|
||||
}
|
||||
log.TLogln("tg files edit err", err)
|
||||
_, _ = c.Bot().Send(c.Sender(), tr(uid, "error")+":\n"+escapeHtml(err.Error()), tele.ModeHTML)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
tele "gopkg.in/telebot.v4"
|
||||
"server/log"
|
||||
"server/torr"
|
||||
)
|
||||
|
||||
// resolveHash returns hash from: 1) full hash string, 2) numeric index from list, 3) reply-to message
|
||||
func resolveHash(c tele.Context, arg string) string {
|
||||
arg = strings.TrimSpace(arg)
|
||||
if arg == "" {
|
||||
return extractHashFromReply(c)
|
||||
}
|
||||
if isHash(arg) {
|
||||
return arg
|
||||
}
|
||||
if idx, err := strconv.Atoi(arg); err == nil && idx > 0 {
|
||||
torrents := torr.ListTorrent()
|
||||
if idx <= len(torrents) {
|
||||
return torrents[idx-1].Hash().HexString()
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func extractHashFromReply(c tele.Context) string {
|
||||
if c.Message() == nil || c.Message().ReplyTo == nil {
|
||||
return ""
|
||||
}
|
||||
reply := c.Message().ReplyTo
|
||||
if reply.ReplyMarkup == nil || len(reply.ReplyMarkup.InlineKeyboard) == 0 {
|
||||
return ""
|
||||
}
|
||||
for _, row := range reply.ReplyMarkup.InlineKeyboard {
|
||||
for _, btn := range row {
|
||||
if btn.Data == "" {
|
||||
continue
|
||||
}
|
||||
if isHash(btn.Data) {
|
||||
return btn.Data
|
||||
}
|
||||
if idx := strings.Index(btn.Data, "all|"); idx >= 0 {
|
||||
h := btn.Data[idx+4:]
|
||||
if len(h) >= 40 && isHash(h[:40]) {
|
||||
return h[:40]
|
||||
}
|
||||
if isHash(h) {
|
||||
return h
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
const hashPageSize = 10
|
||||
|
||||
func cmdHash(c tele.Context) error {
|
||||
args := c.Args()
|
||||
torrents := torr.ListTorrent()
|
||||
if len(torrents) == 0 {
|
||||
return c.Send(tr(c.Sender().ID, "no_torrents"))
|
||||
}
|
||||
|
||||
if len(args) > 0 {
|
||||
idx, err := strconv.Atoi(strings.TrimSpace(args[0]))
|
||||
if err != nil || idx < 1 || idx > len(torrents) {
|
||||
return c.Send(tr(c.Sender().ID, "invalid_index"))
|
||||
}
|
||||
hash := torrents[idx-1].Hash().HexString()
|
||||
return c.Send("🔑 <code>" + hash + "</code>")
|
||||
}
|
||||
|
||||
return sendHashPage(c, 0)
|
||||
}
|
||||
|
||||
func sendHashPage(c tele.Context, page int) error {
|
||||
torrents := torr.ListTorrent()
|
||||
if len(torrents) == 0 {
|
||||
return c.Send(tr(c.Sender().ID, "no_torrents"))
|
||||
}
|
||||
|
||||
totalPages := (len(torrents) + hashPageSize - 1) / hashPageSize
|
||||
if page < 0 {
|
||||
page = 0
|
||||
}
|
||||
if page >= totalPages {
|
||||
page = totalPages - 1
|
||||
}
|
||||
start := page * hashPageSize
|
||||
end := start + hashPageSize
|
||||
if end > len(torrents) {
|
||||
end = len(torrents)
|
||||
}
|
||||
pageTorrents := torrents[start:end]
|
||||
|
||||
uid := c.Sender().ID
|
||||
var sb strings.Builder
|
||||
sb.WriteString("🔑 <b>" + tr(uid, "hash_title") + "</b> (" + strconv.Itoa(len(torrents)) + ")\n\n")
|
||||
for i, t := range pageTorrents {
|
||||
sb.WriteString(strconv.Itoa(start+i+1) + ". <code>" + t.Hash().HexString() + "</code>\n")
|
||||
sb.WriteString(" " + escapeHtml(t.Title) + "\n\n")
|
||||
}
|
||||
msg := strings.TrimSuffix(sb.String(), "\n\n")
|
||||
|
||||
navRow := []tele.InlineButton{}
|
||||
if totalPages > 1 {
|
||||
if page > 0 {
|
||||
navRow = append(navRow, tele.InlineButton{Text: "◀️", Unique: "fhash", Data: strconv.Itoa(page - 1)})
|
||||
}
|
||||
navRow = append(navRow, tele.InlineButton{Text: strconv.Itoa(page+1) + "/" + strconv.Itoa(totalPages), Unique: "fnop", Data: ""})
|
||||
if page < totalPages-1 {
|
||||
navRow = append(navRow, tele.InlineButton{Text: "▶️", Unique: "fhash", Data: strconv.Itoa(page + 1)})
|
||||
}
|
||||
}
|
||||
navRow = append(navRow, tele.InlineButton{Text: "🔄", Unique: "fhashrefresh", Data: strconv.Itoa(page)})
|
||||
|
||||
kbd := &tele.ReplyMarkup{InlineKeyboard: [][]tele.InlineButton{navRow}}
|
||||
if err := c.Send(msg, kbd); err != nil {
|
||||
log.TLogln("tg hash send err", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func callbackHashPage(c tele.Context, data string) error {
|
||||
page := 0
|
||||
if data != "" {
|
||||
if p, err := strconv.Atoi(data); err == nil {
|
||||
page = p
|
||||
}
|
||||
}
|
||||
_ = c.Respond(&tele.CallbackResponse{})
|
||||
if c.Callback().Message != nil {
|
||||
_ = c.Bot().Delete(c.Callback().Message)
|
||||
}
|
||||
return sendHashPage(c, page)
|
||||
}
|
||||
|
||||
func callbackHashRefresh(c tele.Context, data string) error {
|
||||
page := 0
|
||||
if data != "" {
|
||||
if p, err := strconv.Atoi(data); err == nil {
|
||||
page = p
|
||||
}
|
||||
}
|
||||
_ = c.Respond(&tele.CallbackResponse{Text: "🔄"})
|
||||
if c.Callback().Message != nil {
|
||||
_ = c.Bot().Delete(c.Callback().Message)
|
||||
}
|
||||
return sendHashPage(c, page)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
tele "gopkg.in/telebot.v4"
|
||||
)
|
||||
|
||||
var magnetRegex = regexp.MustCompile(`magnet:\?[^\s]+`)
|
||||
var torrsRegex = regexp.MustCompile(`torrs://[^\s]+`)
|
||||
var hashRegex = regexp.MustCompile(`\b([a-fA-F0-9]{40})\b`)
|
||||
|
||||
func cmdImport(c tele.Context) error {
|
||||
text := ""
|
||||
if c.Message() != nil && c.Message().Text != "" {
|
||||
text = strings.TrimPrefix(strings.TrimSpace(c.Message().Text), "/import")
|
||||
text = strings.TrimSpace(text)
|
||||
}
|
||||
if text == "" {
|
||||
return c.Send(tr(c.Sender().ID, "import_usage"))
|
||||
}
|
||||
var links []string
|
||||
seen := make(map[string]bool)
|
||||
for _, m := range magnetRegex.FindAllString(text, -1) {
|
||||
m = strings.TrimSpace(m)
|
||||
if m != "" && !seen[m] {
|
||||
seen[m] = true
|
||||
links = append(links, m)
|
||||
}
|
||||
}
|
||||
for _, m := range torrsRegex.FindAllString(text, -1) {
|
||||
m = strings.TrimSpace(m)
|
||||
if m != "" && !seen[m] {
|
||||
seen[m] = true
|
||||
links = append(links, m)
|
||||
}
|
||||
}
|
||||
for _, m := range hashRegex.FindAllString(text, -1) {
|
||||
h := strings.ToLower(strings.TrimSpace(m))
|
||||
if h != "" && !seen[h] {
|
||||
seen[h] = true
|
||||
links = append(links, h)
|
||||
}
|
||||
}
|
||||
if len(links) == 0 {
|
||||
return c.Send(tr(c.Sender().ID, "import_no_links"))
|
||||
}
|
||||
uid := c.Sender().ID
|
||||
added := 0
|
||||
for _, link := range links {
|
||||
if err := addTorrent(c, link); err != nil {
|
||||
_ = c.Send(fmt.Sprintf(tr(uid, "add_error"), err.Error()))
|
||||
continue
|
||||
}
|
||||
added++
|
||||
}
|
||||
return c.Send(fmt.Sprintf(tr(uid, "import_done"), added, len(links)))
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
tele "gopkg.in/telebot.v4"
|
||||
"server/rutor"
|
||||
"server/rutor/models"
|
||||
sets "server/settings"
|
||||
"server/torr"
|
||||
"server/torznab"
|
||||
)
|
||||
|
||||
const inlineMaxResults = 20
|
||||
|
||||
func handleInlineQuery(c tele.Context) error {
|
||||
query := strings.TrimSpace(c.Query().Text)
|
||||
uid := int64(0)
|
||||
if c.Query().Sender != nil {
|
||||
uid = c.Query().Sender.ID
|
||||
}
|
||||
|
||||
var results tele.Results
|
||||
id := 0
|
||||
|
||||
if query == "" || strings.ToLower(query) == "list" || strings.ToLower(query) == "play" {
|
||||
torrents := torr.ListTorrent()
|
||||
host := getHost()
|
||||
for _, t := range torrents {
|
||||
if id >= inlineMaxResults {
|
||||
break
|
||||
}
|
||||
hash := t.Hash().HexString()
|
||||
url := fmt.Sprintf("%s/play/%s/1", host, hash)
|
||||
title := t.Title
|
||||
if len(title) > 60 {
|
||||
title = title[:57] + "..."
|
||||
}
|
||||
results = append(results, &tele.ArticleResult{
|
||||
ResultBase: tele.ResultBase{ID: strconv.Itoa(id)},
|
||||
Title: "▶ " + title,
|
||||
Description: hash[:8] + "...",
|
||||
URL: url,
|
||||
Text: url,
|
||||
})
|
||||
id++
|
||||
}
|
||||
}
|
||||
|
||||
if len(query) >= 2 && sets.BTsets != nil && (sets.BTsets.EnableRutorSearch || sets.BTsets.EnableTorznabSearch) {
|
||||
var list []*models.TorrentDetails
|
||||
if sets.BTsets.EnableRutorSearch {
|
||||
list = append(list, rutor.Search(query)...)
|
||||
}
|
||||
if sets.BTsets.EnableTorznabSearch {
|
||||
list = append(list, torznab.Search(query, -1)...)
|
||||
}
|
||||
for _, item := range list {
|
||||
if id >= inlineMaxResults {
|
||||
break
|
||||
}
|
||||
link := item.Magnet
|
||||
if link == "" {
|
||||
link = item.Link
|
||||
}
|
||||
if link == "" {
|
||||
continue
|
||||
}
|
||||
title := item.Title
|
||||
if len(title) > 60 {
|
||||
title = title[:57] + "..."
|
||||
}
|
||||
size := item.Size
|
||||
if size == "" {
|
||||
size = "?"
|
||||
}
|
||||
results = append(results, &tele.ArticleResult{
|
||||
ResultBase: tele.ResultBase{ID: strconv.Itoa(id)},
|
||||
Title: "➕ " + title,
|
||||
Description: fmt.Sprintf("%s S:%d P:%d", size, item.Seed, item.Peer),
|
||||
Text: link,
|
||||
})
|
||||
id++
|
||||
}
|
||||
}
|
||||
|
||||
if len(results) == 0 {
|
||||
results = append(results, &tele.ArticleResult{
|
||||
ResultBase: tele.ResultBase{ID: "0"},
|
||||
Title: tr(uid, "no_torrents"),
|
||||
Description: tr(uid, "add_magnet"),
|
||||
Text: "",
|
||||
})
|
||||
}
|
||||
|
||||
return c.Answer(&tele.QueryResponse{
|
||||
Results: results,
|
||||
CacheTime: 60,
|
||||
IsPersonal: true,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
tele "gopkg.in/telebot.v4"
|
||||
"server/settings"
|
||||
)
|
||||
|
||||
const (
|
||||
LangRU = "ru"
|
||||
LangEN = "en"
|
||||
saveUserLangsWait = 2 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
userLang = make(map[int64]string)
|
||||
userLangMu sync.RWMutex
|
||||
saveUserLangsMu sync.Mutex
|
||||
saveUserLangsTimer *time.Timer
|
||||
)
|
||||
|
||||
func getUserLang(userID int64) string {
|
||||
userLangMu.RLock()
|
||||
defer userLangMu.RUnlock()
|
||||
if lang, ok := userLang[userID]; ok {
|
||||
return lang
|
||||
}
|
||||
return LangRU
|
||||
}
|
||||
|
||||
func setUserLang(userID int64, lang string) {
|
||||
if lang != LangRU && lang != LangEN {
|
||||
return
|
||||
}
|
||||
userLangMu.Lock()
|
||||
userLang[userID] = lang
|
||||
userLangMu.Unlock()
|
||||
scheduleSaveUserLangs()
|
||||
}
|
||||
|
||||
func scheduleSaveUserLangs() {
|
||||
saveUserLangsMu.Lock()
|
||||
defer saveUserLangsMu.Unlock()
|
||||
if saveUserLangsTimer != nil {
|
||||
saveUserLangsTimer.Stop()
|
||||
}
|
||||
saveUserLangsTimer = time.AfterFunc(saveUserLangsWait, func() {
|
||||
saveUserLangsMu.Lock()
|
||||
saveUserLangsTimer = nil
|
||||
saveUserLangsMu.Unlock()
|
||||
saveUserLangs()
|
||||
})
|
||||
}
|
||||
|
||||
func loadUserLangs() {
|
||||
fn := filepath.Join(settings.Path, "tg_langs.json")
|
||||
buf, err := os.ReadFile(fn)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var m map[string]string
|
||||
if err := json.Unmarshal(buf, &m); err != nil {
|
||||
return
|
||||
}
|
||||
userLangMu.Lock()
|
||||
for k, v := range m {
|
||||
if v == LangRU || v == LangEN {
|
||||
if id, parseErr := strconv.ParseInt(k, 10, 64); parseErr == nil {
|
||||
userLang[id] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
userLangMu.Unlock()
|
||||
}
|
||||
|
||||
func saveUserLangs() {
|
||||
userLangMu.RLock()
|
||||
m := make(map[string]string)
|
||||
for k, v := range userLang {
|
||||
m[strconv.FormatInt(k, 10)] = v
|
||||
}
|
||||
userLangMu.RUnlock()
|
||||
buf, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
fn := filepath.Join(settings.Path, "tg_langs.json")
|
||||
_ = os.WriteFile(fn, buf, 0o600)
|
||||
}
|
||||
|
||||
func cmdLang(c tele.Context) error {
|
||||
uid := c.Sender().ID
|
||||
args := c.Args()
|
||||
if len(args) == 0 {
|
||||
lang := getUserLang(uid)
|
||||
if lang == LangEN {
|
||||
return c.Send(tr(uid, "lang_current_en") + "\n/lang RU — " + tr(uid, "lang_switch_ru"))
|
||||
}
|
||||
return c.Send(tr(uid, "lang_current_ru") + "\n/lang EN — " + tr(uid, "lang_switch_en"))
|
||||
}
|
||||
lang := strings.ToUpper(strings.TrimSpace(args[0]))
|
||||
if lang == "EN" {
|
||||
setUserLang(uid, LangEN)
|
||||
return c.Send(tr(uid, "lang_set_en"))
|
||||
}
|
||||
if lang == "RU" || lang == "РУ" {
|
||||
setUserLang(uid, LangRU)
|
||||
return c.Send(tr(uid, "lang_set"))
|
||||
}
|
||||
return c.Send(tr(uid, "lang_usage"))
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
tele "gopkg.in/telebot.v4"
|
||||
"server/torr"
|
||||
)
|
||||
|
||||
func callbackLink(c tele.Context, data string) error {
|
||||
uid := c.Sender().ID
|
||||
index := 1
|
||||
hash := data
|
||||
if idx := strings.Index(data, "|"); idx >= 0 && idx+1 < len(data) {
|
||||
if i, err := strconv.Atoi(data[idx+1:]); err == nil && i > 0 {
|
||||
index = i
|
||||
hash = data[:idx]
|
||||
}
|
||||
}
|
||||
t := torr.GetTorrent(hash)
|
||||
if t == nil {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "torrent_not_found")})
|
||||
}
|
||||
if !strings.Contains(data, "|") && t.WaitInfo() {
|
||||
st := t.Status()
|
||||
if st != nil && len(st.FileStats) > 1 {
|
||||
maxFiles := 5
|
||||
if len(st.FileStats) < maxFiles {
|
||||
maxFiles = len(st.FileStats)
|
||||
}
|
||||
var rows [][]tele.InlineButton
|
||||
for i := 0; i < maxFiles; i++ {
|
||||
f := st.FileStats[i]
|
||||
btn := tele.InlineButton{Text: fmt.Sprintf("#%d", f.Id), Unique: "flink", Data: hash + "|" + strconv.Itoa(f.Id)}
|
||||
rows = append(rows, []tele.InlineButton{btn})
|
||||
}
|
||||
kbd := &tele.ReplyMarkup{InlineKeyboard: rows}
|
||||
_ = c.Respond(&tele.CallbackResponse{})
|
||||
return c.Send("🔗 "+tr(uid, "btn_link")+":", kbd)
|
||||
}
|
||||
}
|
||||
host := getHost()
|
||||
url := fmt.Sprintf("%s/play/%s/%d", host, hash, index)
|
||||
_ = c.Respond(&tele.CallbackResponse{})
|
||||
return c.Send(fmt.Sprintf(tr(uid, "link_play"), url))
|
||||
}
|
||||
|
||||
func cmdLink(c tele.Context) error {
|
||||
args := c.Args()
|
||||
arg := ""
|
||||
if len(args) > 0 {
|
||||
arg = args[0]
|
||||
}
|
||||
hash := resolveHash(c, arg)
|
||||
if hash == "" {
|
||||
return c.Send(tr(c.Sender().ID, "link_usage"))
|
||||
}
|
||||
|
||||
index := 1
|
||||
if len(args) > 1 {
|
||||
if i, err := strconv.Atoi(args[1]); err == nil && i > 0 {
|
||||
index = i
|
||||
}
|
||||
}
|
||||
|
||||
t := torr.GetTorrent(hash)
|
||||
if t == nil {
|
||||
return c.Send(tr(c.Sender().ID, "torrent_not_found") + ":\n<code>" + hash + "</code>")
|
||||
}
|
||||
|
||||
host := getHost()
|
||||
url := fmt.Sprintf("%s/play/%s/%d", host, hash, index)
|
||||
return c.Send(fmt.Sprintf(tr(c.Sender().ID, "link_play"), url))
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
tele "gopkg.in/telebot.v4"
|
||||
"server/log"
|
||||
"server/torr"
|
||||
)
|
||||
|
||||
const listPageSize = 5
|
||||
|
||||
func list(c tele.Context) error {
|
||||
args := c.Args()
|
||||
compact := len(args) > 0 && strings.ToLower(args[0]) == "compact"
|
||||
return sendListPage(c, 0, compact)
|
||||
}
|
||||
|
||||
func sendListPage(c tele.Context, page int, compact bool) error {
|
||||
torrents := torr.ListTorrent()
|
||||
if len(torrents) == 0 {
|
||||
return c.Send(tr(c.Sender().ID, "no_torrents"))
|
||||
}
|
||||
|
||||
totalPages := (len(torrents) + listPageSize - 1) / listPageSize
|
||||
if page < 0 {
|
||||
page = 0
|
||||
}
|
||||
if page >= totalPages {
|
||||
page = totalPages - 1
|
||||
}
|
||||
start := page * listPageSize
|
||||
end := start + listPageSize
|
||||
if end > len(torrents) {
|
||||
end = len(torrents)
|
||||
}
|
||||
pageTorrents := torrents[start:end]
|
||||
|
||||
uid := c.Sender().ID
|
||||
for _, t := range pageTorrents {
|
||||
hash := t.Hash().HexString()
|
||||
var rows [][]tele.InlineButton
|
||||
if compact {
|
||||
rows = [][]tele.InlineButton{
|
||||
{
|
||||
tele.InlineButton{Text: tr(uid, "btn_files"), Unique: "files", Data: hash},
|
||||
tele.InlineButton{Text: tr(uid, "btn_status"), Unique: "fstatus", Data: hash},
|
||||
tele.InlineButton{Text: tr(uid, "btn_delete"), Unique: "delete", Data: hash},
|
||||
},
|
||||
}
|
||||
} else {
|
||||
rows = [][]tele.InlineButton{
|
||||
{
|
||||
tele.InlineButton{Text: tr(uid, "btn_files"), Unique: "files", Data: hash},
|
||||
tele.InlineButton{Text: tr(uid, "btn_delete"), Unique: "delete", Data: hash},
|
||||
tele.InlineButton{Text: tr(uid, "btn_status"), Unique: "fstatus", Data: hash},
|
||||
tele.InlineButton{Text: tr(uid, "btn_m3u"), Unique: "fm3u", Data: hash},
|
||||
},
|
||||
{
|
||||
tele.InlineButton{Text: tr(uid, "btn_link"), Unique: "flink", Data: hash},
|
||||
tele.InlineButton{Text: tr(uid, "btn_drop"), Unique: "fdrop", Data: hash},
|
||||
},
|
||||
}
|
||||
}
|
||||
torrKbd := &tele.ReplyMarkup{InlineKeyboard: rows}
|
||||
msg := "<b>" + escapeHtml(t.Title) + "</b>"
|
||||
if t.Size > 0 {
|
||||
msg += " <i>" + humanize.IBytes(uint64(t.Size)) + "</i>"
|
||||
}
|
||||
msg += "\n<code>" + hash + "</code>"
|
||||
if err := c.Send(msg, torrKbd); err != nil {
|
||||
log.TLogln("tg list send err", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
compactStr := "0"
|
||||
if compact {
|
||||
compactStr = "1"
|
||||
}
|
||||
navRow := []tele.InlineButton{}
|
||||
if totalPages > 1 {
|
||||
if page > 0 {
|
||||
navRow = append(navRow, tele.InlineButton{Text: "◀️", Unique: "flist", Data: strconv.Itoa(page-1) + "|" + compactStr})
|
||||
}
|
||||
navRow = append(navRow, tele.InlineButton{Text: strconv.Itoa(page+1) + "/" + strconv.Itoa(totalPages), Unique: "fnop", Data: ""})
|
||||
if page < totalPages-1 {
|
||||
navRow = append(navRow, tele.InlineButton{Text: "▶️", Unique: "flist", Data: strconv.Itoa(page+1) + "|" + compactStr})
|
||||
}
|
||||
}
|
||||
navRow = append(navRow, tele.InlineButton{Text: "🔄", Unique: "frefresh", Data: strconv.Itoa(page) + "|" + compactStr})
|
||||
if len(navRow) > 1 || totalPages == 1 {
|
||||
if err := c.Send(tr(uid, "page")+" "+strconv.Itoa(page+1)+"/"+strconv.Itoa(totalPages), &tele.ReplyMarkup{InlineKeyboard: [][]tele.InlineButton{navRow}}); err != nil {
|
||||
log.TLogln("tg list nav err", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func callbackListPage(c tele.Context, data string) error {
|
||||
parts := strings.Split(data, "|")
|
||||
page := 0
|
||||
compact := false
|
||||
if len(parts) > 0 && parts[0] != "" {
|
||||
if p, err := strconv.Atoi(parts[0]); err == nil {
|
||||
page = p
|
||||
}
|
||||
}
|
||||
if len(parts) > 1 && parts[1] == "1" {
|
||||
compact = true
|
||||
}
|
||||
_ = c.Respond(&tele.CallbackResponse{})
|
||||
if c.Callback().Message != nil {
|
||||
_ = c.Bot().Delete(c.Callback().Message)
|
||||
}
|
||||
return sendListPage(c, page, compact)
|
||||
}
|
||||
|
||||
func callbackListRefresh(c tele.Context, data string) error {
|
||||
parts := strings.Split(data, "|")
|
||||
page := 0
|
||||
compact := false
|
||||
if len(parts) > 0 && parts[0] != "" {
|
||||
if p, err := strconv.Atoi(parts[0]); err == nil {
|
||||
page = p
|
||||
}
|
||||
}
|
||||
if len(parts) > 1 && parts[1] == "1" {
|
||||
compact = true
|
||||
}
|
||||
_ = c.Respond(&tele.CallbackResponse{Text: "🔄"})
|
||||
if c.Callback().Message != nil {
|
||||
_ = c.Bot().Delete(c.Callback().Message)
|
||||
}
|
||||
return sendListPage(c, page, compact)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package tgbot
|
||||
|
||||
func tr(userID int64, key string) string {
|
||||
lang := getUserLang(userID)
|
||||
if lang == LangEN {
|
||||
if s, ok := msgEN[key]; ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
if s, ok := msgRU[key]; ok {
|
||||
return s
|
||||
}
|
||||
return key
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package tgbot
|
||||
|
||||
var msgEN = map[string]string{
|
||||
"help": "TorrServer management bot",
|
||||
"help_main": "Main",
|
||||
"help_manage": "Management",
|
||||
"help_status": "Status & links",
|
||||
"help_search": "Search",
|
||||
"help_other": "Other",
|
||||
"help_server": "Server",
|
||||
"help_use_index": "Use number from /list: /remove 1, /status 2",
|
||||
"help_reply": "Or reply to torrent message with command",
|
||||
"help_id": "Your id",
|
||||
"no_torrents": "📭 No torrents",
|
||||
"torrent_not_found": "❌ Torrent not found",
|
||||
"invalid_hash": "❌ Invalid hash. Use 40 chars (a-f, 0-9)",
|
||||
"invalid_index": "❌ Invalid index. Use number from /list",
|
||||
"connecting": "⏳ Connecting to torrent...",
|
||||
"add_magnet": "ℹ️ Paste magnet/hash/torrs:// to add torrent",
|
||||
"range_error": "❌ Error, use numbers, e.g. 2-12",
|
||||
"lang_set": "🌐 Language set: Russian",
|
||||
"lang_set_en": "🌐 Language set: English",
|
||||
"lang_current_ru": "🌐 Current language: Russian",
|
||||
"lang_current_en": "🌐 Current language: English",
|
||||
"lang_switch_ru": "switch to Russian",
|
||||
"lang_switch_en": "switch to English",
|
||||
"lang_usage": "ℹ️ Usage: /lang RU | /lang EN",
|
||||
"admin_only": "🔒 Admin only",
|
||||
"server_stopped": "🛑 Server stopped",
|
||||
"searching": "🔍 Searching...",
|
||||
"search_not_found": "🔍 Nothing found for «%s» (%s)",
|
||||
"search_disabled_rutor": "ℹ️ RuTor search disabled in settings",
|
||||
"search_disabled_torznab": "ℹ️ Torznab search disabled in settings",
|
||||
"search_usage": "ℹ️ Usage: /search <query>",
|
||||
"rutor_usage": "ℹ️ Usage: /rutor <query>",
|
||||
"torznab_usage": "ℹ️ Usage: /torznab <query> [index]",
|
||||
"clear_confirm": "🗑 Delete all %d torrents?",
|
||||
"clear_done": "🗑 Deleted torrents: %d",
|
||||
"shutdown_confirm": "⚠️ Shut down server?",
|
||||
"canceled": "👌 Canceled",
|
||||
"deleted": "✅ Deleted",
|
||||
"callback_unknown": "❌ Error: unknown button",
|
||||
"stats_title": "Summary statistics",
|
||||
"page": "📄 Page",
|
||||
"btn_add": "➕ Add",
|
||||
"btn_files": "Files",
|
||||
"btn_delete": "Delete",
|
||||
"btn_status": "Status",
|
||||
"btn_m3u": "M3U",
|
||||
"btn_link": "Link",
|
||||
"btn_drop": "Drop",
|
||||
"btn_yes": "Yes",
|
||||
"btn_no": "No",
|
||||
"help_help": "This help",
|
||||
"help_list": "/list [compact] - List (compact — fewer buttons)",
|
||||
"help_clear": "/clear - Delete all torrents",
|
||||
"help_add": "/add <link> - Add torrent",
|
||||
"help_hash": "/hash [N] - Show torrent hashes",
|
||||
"help_manage_desc": "(hash or number from /list)",
|
||||
"help_remove": "/remove, /drop, /set, /status, /cache, /queue",
|
||||
"help_links": "/link, /play, /m3u, /m3uall",
|
||||
"help_server_cmd": "/server - Server info",
|
||||
"help_echo": "/echo - Version",
|
||||
"help_db": "/db - Torrents in DB",
|
||||
"help_search_desc": "(with Add button)",
|
||||
"help_search_cmd": "/search, /rutor, /torznab",
|
||||
"help_other_cmd": "/viewed, /ffp, /speedtest, /preload, /snake",
|
||||
"help_lang": "/lang RU|EN - Language",
|
||||
"help_admin": "/shutdown, /settings, /preset - Admin",
|
||||
"help_stats": "/stats - Summary statistics",
|
||||
"help_stat": "/stat - Detailed status",
|
||||
"help_export": "/export - Export magnet links",
|
||||
"help_import": "/import <text> - Import from list",
|
||||
"help_categories": "/categories - Torrent categories",
|
||||
"help_rutor": "/rutor - Search RuTor",
|
||||
"help_m3uall": "/m3uall - M3U of all torrents",
|
||||
"help_play": "/play - Alias for /link",
|
||||
"help_export_import": "Export / Import",
|
||||
"help_categories_section": "Categories",
|
||||
"settings_title": "Server settings",
|
||||
"settings_error": "❌ Error: %s",
|
||||
"settings_not_loaded": "❌ Settings not loaded",
|
||||
"settings_export": "Export",
|
||||
"settings_nav_cache": "Cache",
|
||||
"settings_nav_paths": "Paths",
|
||||
"settings_nav_storage": "Storage",
|
||||
"settings_export_caption": "TorrServer settings",
|
||||
"settings_exported": "✅ Settings exported",
|
||||
"settings_saved": "✅ Saved",
|
||||
"settings_readonly": "⚠️ Read-only mode",
|
||||
"settings_more": "More",
|
||||
"settings_back": "Back",
|
||||
"settings_to_page2": "Cache",
|
||||
"settings_page2": "Cache & limits",
|
||||
"settings_page3": "Text parameters",
|
||||
"settings_section_search": "Search",
|
||||
"settings_section_network": "Network",
|
||||
"settings_section_other": "Other",
|
||||
"settings_section_limits": "Limits",
|
||||
"settings_limits_cache": "Cache",
|
||||
"settings_limits_connections": "Connections",
|
||||
"settings_limits_speed": "Speed",
|
||||
"settings_section_paths": "Paths & keys",
|
||||
"settings_input_reply": "Reply to this message with new value",
|
||||
"settings_input_done": "✅ %s: %s",
|
||||
"settings_input_error": "❌ Error: %s",
|
||||
"settings_input_torznab_usage": "Format: URL or URL|Key or URL|Key|Name",
|
||||
"settings_input_torznab_added": "✅ Torznab added: %s",
|
||||
"settings_set_friendlyname": "FriendlyName (DLNA)",
|
||||
"settings_set_path": "TorrentsSavePath",
|
||||
"settings_set_sslcert": "SslCert",
|
||||
"settings_set_sslkey": "SslKey",
|
||||
"settings_set_tmdbkey": "TMDB API Key",
|
||||
"settings_add_torznab": "Add Torznab",
|
||||
"settings_clear_torznab": "Clear Torznab",
|
||||
"settings_set_proxyhosts": "ProxyHosts",
|
||||
"settings_hint_friendlyname": "DLNA server name. clear — to clear",
|
||||
"settings_hint_path": "Path to cache folder on server. clear — disable UseDisk",
|
||||
"settings_hint_sslcert": "Path to SSL certificate. clear — to clear",
|
||||
"settings_hint_sslkey": "Path to SSL key. clear — to clear",
|
||||
"settings_hint_tmdbkey": "TMDB API Key. clear — to clear",
|
||||
"settings_hint_proxyhosts": "Hosts comma-separated: host1, host2. clear — reset",
|
||||
"settings_hint_torznab": "URL or URL|Key or URL|Key|Name",
|
||||
"settings_page4": "Storage & TMDB",
|
||||
"settings_section_storage": "Storage",
|
||||
"settings_section_tmdb": "TMDB (read-only)",
|
||||
"settings_storage_settings": "Settings",
|
||||
"settings_storage_viewed": "Viewed",
|
||||
"settings_torznab_test": "Test Torznab",
|
||||
"settings_hint_torznab_test": "URL|Key — test indexer before adding",
|
||||
"settings_torznab_test_ok": "✅ Torznab: connection successful",
|
||||
"settings_torznab_test_fail": "❌ Torznab: %s",
|
||||
"settings_reset": "Reset to defaults",
|
||||
"settings_reset_confirm": "Reset to factory defaults?",
|
||||
"settings_reset_done": "✅ Settings reset",
|
||||
"preset_usage": "⚙️ /preset <name> or /preset <key> <value> ...\n\nNamed: performance, storage, streaming, low, default\n\nExamples:\n/preset performance\n/preset cache 256 preload 50\n/preset cache 512 conn 100 down 0",
|
||||
"preset_confirm": "⚠️ Applying preset will reload TorrServer (torrents will be disconnected). Continue?",
|
||||
"preset_applied": "✅ Preset applied: ",
|
||||
"add_error": "❌ Connection error: %s",
|
||||
"add_not_created": "❌ Error: torrent not created",
|
||||
"add_timeout": "❌ Error adding torrent: timeout connection get torrent info",
|
||||
"add_getting_meta": "⏳ Getting metadata...",
|
||||
"add_success": "✅ Torrent added:\n<code>%s</code>",
|
||||
"stats_torrents": "Torrents",
|
||||
"stats_total_size": "Total size",
|
||||
"stats_loaded": "Loaded",
|
||||
"stats_peers": "Peers",
|
||||
"stats_active": "active",
|
||||
"stats_seeds": "seeders",
|
||||
"stats_streams": "Streams",
|
||||
"error": "❌ Error",
|
||||
"search_expired": "ℹ️ Result expired, search again",
|
||||
"search_more": "More",
|
||||
"search_more_hint": "ℹ️ Showing %d of %d. Click for more results",
|
||||
"search_no_link": "ℹ️ No link",
|
||||
"search_adding": "⏳ Adding...",
|
||||
"add_usage": "ℹ️ Usage: /add <magnet|hash|torrs://|url>\nPaste torrent link",
|
||||
"add_no_link": "ℹ️ Specify torrent link",
|
||||
"remove_usage": "ℹ️ Usage: /remove <hash|number>\nOr reply to torrent message",
|
||||
"remove_done": "✅ Torrent removed:\n<code>%s</code>",
|
||||
"status_waiting": "⏳ Waiting for torrent info...",
|
||||
"status_stopped": "🛑 Auto-refresh stopped",
|
||||
"status_stop_btn": "🛑 Stop",
|
||||
"status_refresh_btn": "🔄 Refresh",
|
||||
"status_auto_ended": "Auto-refresh ended",
|
||||
"status_torrent_gone": "Torrent removed or disconnected",
|
||||
"status_no_active": "📭 No active torrents",
|
||||
"status_label": "Status",
|
||||
"status_size": "Size",
|
||||
"status_cache": "Cache",
|
||||
"status_streams": "streams",
|
||||
"status_download": "Download",
|
||||
"status_upload": "Upload",
|
||||
"status_peers": "Peers",
|
||||
"speed_bps": "bps",
|
||||
"speed_kbps": "kbps",
|
||||
"speed_Mbps": "Mbps",
|
||||
"speed_Gbps": "Gbps",
|
||||
"speed_Tbps": "Tbps",
|
||||
"link_usage": "ℹ️ Usage: /link <hash|number> [index]\nOr reply to torrent message",
|
||||
"link_play": "🔗 Playback link:\n<code>%s</code>",
|
||||
"server_title": "TorrServer",
|
||||
"server_url": "URL",
|
||||
"server_port": "Port",
|
||||
"server_streams": "Active streams",
|
||||
"m3u_usage": "ℹ️ Usage: /m3u <hash|number> [fromlast]\nOr reply to torrent message",
|
||||
"m3u_playlist": "🎵 M3U playlist:\n<code>%s</code>",
|
||||
"m3u_all": "🎵 All torrents M3U:\n<code>%s</code>",
|
||||
"drop_done": "✅ Torrent disconnected",
|
||||
"drop_done_hash": "✅ Torrent disconnected:\n<code>%s</code>",
|
||||
"preload_usage": "ℹ️ Usage: /preload <hash|number> <index>\nOr reply to torrent message",
|
||||
"preload_invalid": "❌ Specify valid file number (integer >= 1)",
|
||||
"preload_started": "⏳ Preload started for file #%s",
|
||||
"preload_btn": "Preload #%s",
|
||||
"hash_title": "Torrent hashes",
|
||||
"files_link": "Link",
|
||||
"files_download_all": "Download all files",
|
||||
"files_range_hint": "To download multiple files, reply with range, e.g. 2-12\n\nDownload all files? Total: %d",
|
||||
"upload_queue_full": "⚠️ Queue full, try later\n\nItems in queue: %d",
|
||||
"upload_connecting": "⏳ <b>Connecting to torrent</b>\n<code>%s</code>",
|
||||
"upload_cancel": "Cancel",
|
||||
"upload_queue_pos": "📋 Queue position %d",
|
||||
"upload_error": "❌ Telegram upload error: %v",
|
||||
"parse_range_err": "❌ Invalid format",
|
||||
"cache_usage": "ℹ️ Usage: /cache <hash|number>\nOr reply to torrent message",
|
||||
"cache_capacity": "Capacity",
|
||||
"cache_filled": "Filled",
|
||||
"cache_pieces": "Pieces",
|
||||
"cache_readers": "Readers",
|
||||
"cache_unavailable": "⚠️ Cache unavailable for torrent:\n<code>%s</code>",
|
||||
"snake_usage": "ℹ️ Usage: /snake <hash|number> [cols] [rows]\n\nCache visualization. Position moves along snake.\nDefault: 20×3 (up to 50×15)",
|
||||
"snake_cache": "Preload / Cache",
|
||||
"snake_cached": "cached",
|
||||
"snake_range": "buffer",
|
||||
"snake_empty": "empty",
|
||||
"snake_reader": "reader",
|
||||
"snake_legend": "🟩cache 🟦buff 🔵pos ⬜empt",
|
||||
"snake_pieces": "pieces",
|
||||
"snake_no_data": "No cache data",
|
||||
"set_done": "✅ Title updated:\n<code>%s</code>",
|
||||
"set_usage": "ℹ️ Usage: /set <hash|index> <title>\nOr reply to torrent message",
|
||||
"set_title_required": "❌ Specify new title",
|
||||
"viewed_marked": "✅ Marked: <code>%s</code> file #%d",
|
||||
"viewed_unmarked": "✅ Unmarked: <code>%s</code> file #%d",
|
||||
"viewed_cleared": "✅ All marks cleared: <code>%s</code>",
|
||||
"viewed_list": "📺 Viewed files",
|
||||
"viewed_usage": "ℹ️ Usage:\n/viewed <hash|index> — list\n/viewed set <hash|index> <file> — mark\n/viewed rem <hash|index> [file] — unmark",
|
||||
"viewed_usage_action": "ℹ️ Usage: /viewed %s <hash|index> [file]",
|
||||
"viewed_usage_set": "ℹ️ Usage: /viewed set <hash|index> <file>",
|
||||
"viewed_file_index": "❌ Specify file number (integer >= 1)",
|
||||
"viewed_empty": "📭 No viewed files for this torrent",
|
||||
"speedtest_msg": "⚡ Download test %d MB:\n<code>%s</code>\n\nDownload the file and measure speed",
|
||||
"ffp_usage": "ℹ️ Usage: /ffp <hash|number> <id> [json]\nid — file number. json — raw output",
|
||||
"ffp_file_index": "❌ Specify valid file number",
|
||||
"ffp_error": "❌ FFprobe error: %s",
|
||||
"ffp_format": "Format",
|
||||
"ffp_container": "Container",
|
||||
"ffp_duration": "Duration",
|
||||
"ffp_size": "Size",
|
||||
"ffp_bitrate": "Bitrate",
|
||||
"ffp_streams": "Streams",
|
||||
"ffp_video": "Video",
|
||||
"ffp_audio": "Audio",
|
||||
"ffp_subtitle": "Subtitle",
|
||||
"ffp_codec": "Codec",
|
||||
"ffp_resolution": "Resolution",
|
||||
"ffp_pixel": "Pixel format",
|
||||
"ffp_fps": "FPS",
|
||||
"ffp_color": "Color",
|
||||
"ffp_samplerate": "Sample rate",
|
||||
"ffp_channels": "Channels",
|
||||
"ffp_title": "Title",
|
||||
"db_empty": "📭 Torrent database is empty",
|
||||
"db_title": "Torrents in DB",
|
||||
"export_title": "Export torrents",
|
||||
"export_file_caption": "Magnet links in file",
|
||||
"import_usage": "ℹ️ Usage: /import <text with magnet/hash/torrs>\nPaste multiple links separated by space or newline",
|
||||
"import_no_links": "ℹ️ No links found. Paste magnet, hash or torrs://",
|
||||
"import_done": "✅ Added: %d of %d",
|
||||
"categories_title": "Categories",
|
||||
"categories_uncategorized": "(uncategorized)",
|
||||
"queue_empty": "📭 Queue empty",
|
||||
"upload_working": "📥 Downloading",
|
||||
"upload_in_queue": "📋 In queue",
|
||||
"upload_stopping": "⏹ Stopping...",
|
||||
"upload_title": "Downloading torrent",
|
||||
"upload_hash": "Hash",
|
||||
"upload_speed": "Speed",
|
||||
"upload_remaining": "Remaining",
|
||||
"upload_peers": "Peers",
|
||||
"upload_progress": "Progress",
|
||||
"upload_files": "Files",
|
||||
"upload_finishing": "Finishing download, this may take a while",
|
||||
"upload_file_too_large_2gb": "❌ File size must not exceed 2GB",
|
||||
"upload_file_too_large_50mb": "❌ File size must not exceed 50MB. To upload files up to 2GB, specify host in tg.cfg to <a href='https://github.com/tdlib/telegram-bot-api'>telegram bot-api</a>",
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package tgbot
|
||||
|
||||
var msgRU = map[string]string{
|
||||
"help": "Бот для управления TorrServer",
|
||||
"help_main": "Основные",
|
||||
"help_manage": "Управление",
|
||||
"help_status": "Статус и ссылки",
|
||||
"help_search": "Поиск",
|
||||
"help_other": "Прочее",
|
||||
"help_server": "Сервер",
|
||||
"help_use_index": "Можно использовать номер из /list: /remove 1, /status 2",
|
||||
"help_reply": "Или ответьте на сообщение торрента командой",
|
||||
"help_id": "Ваш id",
|
||||
"no_torrents": "📭 Нет торрентов",
|
||||
"torrent_not_found": "❌ Торрент не найден",
|
||||
"invalid_hash": "❌ Некорректный хэш. Укажите 40 символов (a-f, 0-9)",
|
||||
"invalid_index": "❌ Некорректный номер. Используйте число из /list",
|
||||
"connecting": "⏳ Подключение к торренту...",
|
||||
"add_magnet": "ℹ️ Вставьте магнет/хэш/torrs:// чтоб добавить торрент",
|
||||
"range_error": "❌ Ошибка, нужно указывать числа, пример: 2-12",
|
||||
"lang_set": "🌐 Язык установлен: Русский",
|
||||
"lang_set_en": "🌐 Language set: English",
|
||||
"lang_current_ru": "🌐 Текущий язык: Русский",
|
||||
"lang_current_en": "🌐 Current language: English",
|
||||
"lang_switch_ru": "переключить на русский",
|
||||
"lang_switch_en": "switch to English",
|
||||
"lang_usage": "ℹ️ Использование: /lang RU | /lang EN",
|
||||
"admin_only": "🔒 Только для администратора",
|
||||
"server_stopped": "🛑 Сервер остановлен",
|
||||
"searching": "🔍 Поиск...",
|
||||
"search_not_found": "🔍 По запросу «%s» ничего не найдено (%s)",
|
||||
"search_disabled_rutor": "ℹ️ Поиск RuTor отключён в настройках",
|
||||
"search_disabled_torznab": "ℹ️ Поиск Torznab отключён в настройках",
|
||||
"search_usage": "ℹ️ Использование: /search <запрос>",
|
||||
"rutor_usage": "ℹ️ Использование: /rutor <запрос>",
|
||||
"torznab_usage": "ℹ️ Использование: /torznab <запрос> [индекс]",
|
||||
"clear_confirm": "🗑 Удалить все %d торрентов?",
|
||||
"clear_done": "🗑 Удалено торрентов: %d",
|
||||
"shutdown_confirm": "⚠️ Остановить сервер?",
|
||||
"canceled": "👌 Отменено",
|
||||
"deleted": "✅ Удалено",
|
||||
"callback_unknown": "❌ Ошибка: кнопка не распознана",
|
||||
"stats_title": "Сводная статистика",
|
||||
"page": "📄 Страница",
|
||||
"btn_add": "➕ Добавить",
|
||||
"btn_files": "Файлы",
|
||||
"btn_delete": "Удалить",
|
||||
"btn_status": "Статус",
|
||||
"btn_m3u": "M3U",
|
||||
"btn_link": "Ссылка",
|
||||
"btn_drop": "Отключить",
|
||||
"btn_yes": "Да",
|
||||
"btn_no": "Нет",
|
||||
"help_help": "Эта справка",
|
||||
"help_list": "/list [compact] - Список (compact — меньше кнопок)",
|
||||
"help_clear": "/clear - Удалить все торренты",
|
||||
"help_add": "/add <ссылка> - Добавить торрент",
|
||||
"help_hash": "/hash [N] - Показать hash торрентов",
|
||||
"help_manage_desc": "(hash или номер из /list)",
|
||||
"help_remove": "/remove, /drop, /set, /status, /cache, /queue",
|
||||
"help_links": "/link, /play, /m3u, /m3uall",
|
||||
"help_server_cmd": "/server - Информация о сервере",
|
||||
"help_echo": "/echo - Версия",
|
||||
"help_db": "/db - Торренты в БД",
|
||||
"help_search_desc": "(с кнопкой Добавить)",
|
||||
"help_search_cmd": "/search, /rutor, /torznab",
|
||||
"help_other_cmd": "/viewed, /ffp, /speedtest, /preload, /snake",
|
||||
"help_lang": "/lang RU|EN - Язык",
|
||||
"help_admin": "/shutdown, /settings, /preset - Админ",
|
||||
"help_stats": "/stats - Сводная статистика",
|
||||
"help_stat": "/stat - Детальный статус",
|
||||
"help_export": "/export - Экспорт магнет-ссылок",
|
||||
"help_import": "/import <текст> - Импорт из списка",
|
||||
"help_categories": "/categories - Категории торрентов",
|
||||
"help_rutor": "/rutor - Поиск RuTor",
|
||||
"help_m3uall": "/m3uall - M3U всех торрентов",
|
||||
"help_play": "/play - Алиас /link",
|
||||
"help_export_import": "Экспорт / Импорт",
|
||||
"help_categories_section": "Категории",
|
||||
"settings_title": "Настройки сервера",
|
||||
"settings_error": "❌ Ошибка: %s",
|
||||
"settings_not_loaded": "❌ Настройки не загружены",
|
||||
"settings_export": "Экспорт",
|
||||
"settings_nav_cache": "Кэш",
|
||||
"settings_nav_paths": "Пути",
|
||||
"settings_nav_storage": "Хранилище",
|
||||
"settings_export_caption": "Настройки TorrServer",
|
||||
"settings_exported": "✅ Настройки экспортированы",
|
||||
"settings_saved": "✅ Сохранено",
|
||||
"settings_readonly": "⚠️ Режим только чтение",
|
||||
"settings_more": "Ещё",
|
||||
"settings_back": "Назад",
|
||||
"settings_to_page2": "Кэш",
|
||||
"settings_page2": "Кэш и лимиты",
|
||||
"settings_page3": "Текстовые параметры",
|
||||
"settings_section_search": "Поиск",
|
||||
"settings_section_network": "Сеть",
|
||||
"settings_section_other": "Прочее",
|
||||
"settings_section_limits": "Лимиты",
|
||||
"settings_limits_cache": "Кэш",
|
||||
"settings_limits_connections": "Подключения",
|
||||
"settings_limits_speed": "Скорость",
|
||||
"settings_section_paths": "Пути и ключи",
|
||||
"settings_input_reply": "Ответьте на это сообщение новым значением",
|
||||
"settings_input_done": "✅ %s: %s",
|
||||
"settings_input_error": "❌ Ошибка: %s",
|
||||
"settings_input_torznab_usage": "Формат: URL или URL|Key или URL|Key|Name",
|
||||
"settings_input_torznab_added": "✅ Torznab добавлен: %s",
|
||||
"settings_set_friendlyname": "FriendlyName (DLNA)",
|
||||
"settings_set_path": "TorrentsSavePath",
|
||||
"settings_set_sslcert": "SslCert",
|
||||
"settings_set_sslkey": "SslKey",
|
||||
"settings_set_tmdbkey": "TMDB API Key",
|
||||
"settings_add_torznab": "Добавить Torznab",
|
||||
"settings_clear_torznab": "Очистить Torznab",
|
||||
"settings_set_proxyhosts": "ProxyHosts",
|
||||
"settings_hint_friendlyname": "Имя DLNA-сервера. clear — очистить",
|
||||
"settings_hint_path": "Путь к папке кэша на сервере. clear — отключить UseDisk",
|
||||
"settings_hint_sslcert": "Путь к SSL-сертификату. clear — очистить",
|
||||
"settings_hint_sslkey": "Путь к SSL-ключу. clear — очистить",
|
||||
"settings_hint_tmdbkey": "TMDB API Key. clear — очистить",
|
||||
"settings_hint_proxyhosts": "Хосты через запятую: host1, host2. clear — сброс",
|
||||
"settings_hint_torznab": "URL или URL|Key или URL|Key|Name",
|
||||
"settings_page4": "Хранилище и TMDB",
|
||||
"settings_section_storage": "Хранилище",
|
||||
"settings_section_tmdb": "TMDB (только просмотр)",
|
||||
"settings_storage_settings": "Настройки",
|
||||
"settings_storage_viewed": "Просмотренные",
|
||||
"settings_torznab_test": "Тест Torznab",
|
||||
"settings_hint_torznab_test": "URL|Key — проверка индексера до добавления",
|
||||
"settings_torznab_test_ok": "✅ Torznab: подключение успешно",
|
||||
"settings_torznab_test_fail": "❌ Torznab: %s",
|
||||
"settings_reset": "Сброс настроек",
|
||||
"settings_reset_confirm": "Сбросить на заводские настройки?",
|
||||
"settings_reset_done": "✅ Настройки сброшены",
|
||||
"preset_usage": "⚙️ /preset <имя> или /preset <ключ> <значение> ...\n\nИменованные: performance, storage, streaming, low, default\n\nПримеры:\n/preset performance\n/preset cache 256 preload 50\n/preset cache 512 conn 100 down 0",
|
||||
"preset_confirm": "⚠️ Применение пресета перезагрузит TorrServer (торренты будут отключены). Продолжить?",
|
||||
"preset_applied": "✅ Пресет применён: ",
|
||||
"add_error": "❌ Ошибка при подключении: %s",
|
||||
"add_not_created": "❌ Ошибка: торрент не создан",
|
||||
"add_timeout": "❌ Ошибка при добавлении торрента: timeout connection get torrent info",
|
||||
"add_getting_meta": "⏳ Получение метаданных...",
|
||||
"add_success": "✅ Торрент добавлен:\n<code>%s</code>",
|
||||
"stats_torrents": "Торрентов",
|
||||
"stats_total_size": "Общий размер",
|
||||
"stats_loaded": "Загружено",
|
||||
"stats_peers": "Пиры",
|
||||
"stats_active": "активных",
|
||||
"stats_seeds": "сидов",
|
||||
"stats_streams": "Потоков",
|
||||
"error": "❌ Ошибка",
|
||||
"search_expired": "ℹ️ Результат устарел, повторите поиск",
|
||||
"search_more": "Ещё",
|
||||
"search_more_hint": "ℹ️ Показано %d из %d. Нажмите для следующих результатов",
|
||||
"search_no_link": "ℹ️ Нет ссылки",
|
||||
"search_adding": "⏳ Добавление...",
|
||||
"add_usage": "ℹ️ Использование: /add <magnet|hash|torrs://|url>\nВставьте ссылку на торрент",
|
||||
"add_no_link": "ℹ️ Укажите ссылку на торрент",
|
||||
"remove_usage": "ℹ️ Использование: /remove <hash|номер>\nИли ответьте на сообщение торрента",
|
||||
"remove_done": "✅ Торрент удалён:\n<code>%s</code>",
|
||||
"status_waiting": "⏳ Ожидание информации о торренте...",
|
||||
"status_stopped": "🛑 Автообновление остановлено",
|
||||
"status_stop_btn": "🛑 Стоп",
|
||||
"status_refresh_btn": "🔄 Обновить",
|
||||
"status_auto_ended": "Автообновление завершено",
|
||||
"status_torrent_gone": "Торрент удалён или отключён",
|
||||
"status_no_active": "📭 Нет активных торрентов",
|
||||
"status_label": "Статус",
|
||||
"status_size": "Размер",
|
||||
"status_cache": "Кэш",
|
||||
"status_streams": "потоков",
|
||||
"status_download": "Скачивание",
|
||||
"status_upload": "Раздача",
|
||||
"status_peers": "Пиры",
|
||||
"speed_bps": "бит/c",
|
||||
"speed_kbps": "кбит/с",
|
||||
"speed_Mbps": "Мбит/c",
|
||||
"speed_Gbps": "Гбит/с",
|
||||
"speed_Tbps": "Тбит/с",
|
||||
"link_usage": "ℹ️ Использование: /link <hash|номер> [index]\nИли ответьте на сообщение торрента",
|
||||
"link_play": "🔗 Ссылка для воспроизведения:\n<code>%s</code>",
|
||||
"server_title": "Сервер TorrServer",
|
||||
"server_url": "URL",
|
||||
"server_port": "Порт",
|
||||
"server_streams": "Активных потоков",
|
||||
"m3u_usage": "ℹ️ Использование: /m3u <hash|номер> [fromlast]\nИли ответьте на сообщение торрента",
|
||||
"m3u_playlist": "🎵 M3U плейлист:\n<code>%s</code>",
|
||||
"m3u_all": "🎵 M3U всех торрентов:\n<code>%s</code>",
|
||||
"drop_done": "✅ Торрент отключён",
|
||||
"drop_done_hash": "✅ Торрент отключён:\n<code>%s</code>",
|
||||
"preload_usage": "ℹ️ Использование: /preload <hash|номер> <index>\nИли ответьте на сообщение торрента",
|
||||
"preload_invalid": "❌ Укажите корректный номер файла (целое число >= 1)",
|
||||
"preload_started": "⏳ Предзагрузка запущена для файла #%s",
|
||||
"preload_btn": "Предзагрузка #%s",
|
||||
"hash_title": "Hash торрентов",
|
||||
"files_link": "Ссылка",
|
||||
"files_download_all": "Скачать все файлы",
|
||||
"files_range_hint": "Чтобы скачать несколько файлов, ответьте на это сообщение, с какого файла скачать по какой, пример: 2-12\n\nСкачать все файлы? Всего: %d",
|
||||
"upload_queue_full": "⚠️ Очередь переполнена, попробуйте попозже\n\nЭлементов в очереди: %d",
|
||||
"upload_connecting": "⏳ <b>Подключение к торренту</b>\n<code>%s</code>",
|
||||
"upload_cancel": "Отмена",
|
||||
"upload_queue_pos": "📋 Номер в очереди %d",
|
||||
"upload_error": "❌ Ошибка загрузки в телеграм: %v",
|
||||
"parse_range_err": "❌ Неверный формат строки",
|
||||
"cache_usage": "ℹ️ Использование: /cache <hash|номер>\nИли ответьте на сообщение торрента",
|
||||
"cache_capacity": "Ёмкость",
|
||||
"cache_filled": "Заполнено",
|
||||
"cache_pieces": "Пайсов",
|
||||
"cache_readers": "Читателей",
|
||||
"cache_unavailable": "⚠️ Кэш недоступен для торрента:\n<code>%s</code>",
|
||||
"snake_usage": "ℹ️ Использование: /snake <hash|номер> [колонок] [строк]\n\nВизуализация кэша. Позиция движется по змейке.\nПо умолчанию: 20×3 (до 50×15)",
|
||||
"snake_cache": "Предзагрузка / Кеш",
|
||||
"snake_cached": "кэш",
|
||||
"snake_range": "буфер",
|
||||
"snake_empty": "пусто",
|
||||
"snake_reader": "позиция",
|
||||
"snake_legend": "🟩кэш 🟦буф 🔵поз ⬜пуст",
|
||||
"snake_pieces": "пайсы",
|
||||
"snake_no_data": "Нет данных кэша",
|
||||
"set_done": "✅ Название обновлено:\n<code>%s</code>",
|
||||
"set_usage": "ℹ️ Использование: /set <hash|номер> <название>\nИли ответьте на сообщение торрента",
|
||||
"set_title_required": "❌ Укажите новое название",
|
||||
"viewed_marked": "✅ Отмечено: <code>%s</code> файл #%d",
|
||||
"viewed_unmarked": "✅ Снята отметка: <code>%s</code> файл #%d",
|
||||
"viewed_cleared": "✅ Сняты все отметки: <code>%s</code>",
|
||||
"viewed_list": "📺 Просмотренные файлы",
|
||||
"viewed_usage": "ℹ️ Использование:\n/viewed <hash|номер> — список\n/viewed set <hash|номер> <index> — отметить\n/viewed rem <hash|номер> [index] — снять отметку",
|
||||
"viewed_usage_action": "ℹ️ Использование: /viewed %s <hash|номер> [index]",
|
||||
"viewed_usage_set": "ℹ️ Использование: /viewed set <hash|номер> <index>",
|
||||
"viewed_file_index": "❌ Укажите номер файла (целое число >= 1)",
|
||||
"viewed_empty": "📭 Нет просмотренных файлов для этого торрента",
|
||||
"speedtest_msg": "⚡ Тест загрузки %d MB:\n<code>%s</code>\n\nСкачайте файл и замерьте скорость",
|
||||
"ffp_usage": "ℹ️ Использование: /ffp <hash|номер> <id> [json]\nid — номер файла. json — сырой вывод",
|
||||
"ffp_file_index": "❌ Укажите корректный номер файла",
|
||||
"ffp_error": "❌ Ошибка ffprobe: %s",
|
||||
"ffp_format": "Формат",
|
||||
"ffp_container": "Контейнер",
|
||||
"ffp_duration": "Длительность",
|
||||
"ffp_size": "Размер",
|
||||
"ffp_bitrate": "Битрейт",
|
||||
"ffp_streams": "Дорожки",
|
||||
"ffp_video": "Видео",
|
||||
"ffp_audio": "Аудио",
|
||||
"ffp_subtitle": "Субтитры",
|
||||
"ffp_codec": "Кодек",
|
||||
"ffp_resolution": "Разрешение",
|
||||
"ffp_pixel": "Пиксели",
|
||||
"ffp_fps": "FPS",
|
||||
"ffp_color": "Цвет",
|
||||
"ffp_samplerate": "Частота",
|
||||
"ffp_channels": "Каналы",
|
||||
"ffp_title": "Название",
|
||||
"db_empty": "📭 База торрентов пуста",
|
||||
"db_title": "Торренты в БД",
|
||||
"export_title": "Экспорт торрентов",
|
||||
"export_file_caption": "Магнет-ссылки в файле",
|
||||
"import_usage": "ℹ️ Использование: /import <текст с magnet/hash/torrs>\nВставьте несколько ссылок через пробел или перенос строки",
|
||||
"import_no_links": "ℹ️ Ссылки не найдены. Вставьте magnet, hash или torrs://",
|
||||
"import_done": "✅ Добавлено: %d из %d",
|
||||
"categories_title": "Категории",
|
||||
"categories_uncategorized": "(без категории)",
|
||||
"queue_empty": "📭 Очередь пуста",
|
||||
"upload_working": "📥 Закачиваются",
|
||||
"upload_in_queue": "📋 В очереди",
|
||||
"upload_stopping": "⏹ Остановка...",
|
||||
"upload_title": "Загрузка торрента",
|
||||
"upload_hash": "Хэш",
|
||||
"upload_speed": "Скорость",
|
||||
"upload_remaining": "Осталось",
|
||||
"upload_peers": "Пиры",
|
||||
"upload_progress": "Загружено",
|
||||
"upload_files": "Файлов",
|
||||
"upload_finishing": "Завершение загрузки, это займёт некоторое время",
|
||||
"upload_file_too_large_2gb": "❌ Размер файла не должен превышать 2GB",
|
||||
"upload_file_too_large_50mb": "❌ Размер файла не должен превышать 50MB. Чтобы закачивать файлы до 2GB, укажите host в tg.cfg к <a href='https://github.com/tdlib/telegram-bot-api'>telegram bot-api</a>",
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tele "gopkg.in/telebot.v4"
|
||||
"server/torr"
|
||||
)
|
||||
|
||||
func callbackM3u(c tele.Context, hash string) error {
|
||||
uid := c.Sender().ID
|
||||
t := torr.GetTorrent(hash)
|
||||
if t == nil {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "torrent_not_found")})
|
||||
}
|
||||
host := getHost()
|
||||
url := fmt.Sprintf("%s/playlist?hash=%s", host, hash)
|
||||
_ = c.Respond(&tele.CallbackResponse{})
|
||||
return c.Send(fmt.Sprintf(tr(uid, "m3u_playlist"), url))
|
||||
}
|
||||
|
||||
func cmdM3u(c tele.Context) error {
|
||||
args := c.Args()
|
||||
arg := ""
|
||||
if len(args) > 0 {
|
||||
arg = args[0]
|
||||
}
|
||||
hash := resolveHash(c, arg)
|
||||
if hash == "" {
|
||||
return c.Send(tr(c.Sender().ID, "m3u_usage"))
|
||||
}
|
||||
|
||||
t := torr.GetTorrent(hash)
|
||||
if t == nil {
|
||||
return c.Send(tr(c.Sender().ID, "torrent_not_found") + ":\n<code>" + hash + "</code>")
|
||||
}
|
||||
|
||||
host := getHost()
|
||||
url := fmt.Sprintf("%s/playlist?hash=%s", host, hash)
|
||||
if len(args) > 1 && strings.ToLower(args[1]) == "fromlast" {
|
||||
url += "&fromlast=1"
|
||||
}
|
||||
return c.Send(fmt.Sprintf(tr(c.Sender().ID, "m3u_playlist"), url))
|
||||
}
|
||||
|
||||
func cmdM3uAll(c tele.Context) error {
|
||||
host := getHost()
|
||||
url := host + "/playlistall/all.m3u"
|
||||
return c.Send(fmt.Sprintf(tr(c.Sender().ID, "m3u_all"), url))
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package tgbot
|
||||
|
||||
import tele "gopkg.in/telebot.v4"
|
||||
|
||||
// adminOnly wraps a handler to allow only admin users (when whitelist is used)
|
||||
func adminOnly(h tele.HandlerFunc) tele.HandlerFunc {
|
||||
return func(c tele.Context) error {
|
||||
if c.Sender() == nil {
|
||||
return nil
|
||||
}
|
||||
if !isAdmin(c.Sender().ID) {
|
||||
return c.Send(tr(c.Sender().ID, "admin_only"))
|
||||
}
|
||||
return h(c)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
tele "gopkg.in/telebot.v4"
|
||||
"server/torr"
|
||||
)
|
||||
|
||||
func cmdPreload(c tele.Context) error {
|
||||
args := c.Args()
|
||||
if len(args) < 2 {
|
||||
return c.Send(tr(c.Sender().ID, "preload_usage"))
|
||||
}
|
||||
uid := c.Sender().ID
|
||||
hash := resolveHash(c, args[0])
|
||||
if hash == "" {
|
||||
return c.Send(tr(uid, "invalid_hash"))
|
||||
}
|
||||
index, err := strconv.Atoi(strings.TrimSpace(args[1]))
|
||||
if err != nil || index < 1 {
|
||||
return c.Send(tr(uid, "preload_invalid"))
|
||||
}
|
||||
|
||||
t := torr.GetTorrent(hash)
|
||||
if t == nil {
|
||||
return c.Send(tr(uid, "torrent_not_found") + ":\n<code>" + hash + "</code>")
|
||||
}
|
||||
|
||||
torr.Preload(t, index)
|
||||
return c.Send(fmt.Sprintf(tr(uid, "preload_started"), args[1]))
|
||||
}
|
||||
|
||||
func callbackPreload(c tele.Context, hash, indexStr string) error {
|
||||
uid := c.Sender().ID
|
||||
index, err := strconv.Atoi(indexStr)
|
||||
if err != nil || index < 1 {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "error")})
|
||||
}
|
||||
t := torr.GetTorrent(hash)
|
||||
if t == nil {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "torrent_not_found")})
|
||||
}
|
||||
torr.Preload(t, index)
|
||||
return c.Respond(&tele.CallbackResponse{Text: fmt.Sprintf(tr(uid, "preload_btn"), indexStr)})
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
tele "gopkg.in/telebot.v4"
|
||||
"server/torr"
|
||||
)
|
||||
|
||||
func cmdRemove(c tele.Context) error {
|
||||
arg := ""
|
||||
if args := c.Args(); len(args) > 0 {
|
||||
arg = args[0]
|
||||
}
|
||||
hash := resolveHash(c, arg)
|
||||
if hash == "" {
|
||||
return c.Send(tr(c.Sender().ID, "remove_usage"))
|
||||
}
|
||||
|
||||
torrents := torr.ListTorrent()
|
||||
var found bool
|
||||
for _, t := range torrents {
|
||||
if t.Hash().HexString() == hash {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return c.Send(tr(c.Sender().ID, "torrent_not_found") + ":\n<code>" + hash + "</code>")
|
||||
}
|
||||
|
||||
torr.RemTorrent(hash)
|
||||
return c.Send(fmt.Sprintf(tr(c.Sender().ID, "remove_done"), hash))
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
tele "gopkg.in/telebot.v4"
|
||||
"server/rutor"
|
||||
"server/rutor/models"
|
||||
sets "server/settings"
|
||||
"server/torznab"
|
||||
)
|
||||
|
||||
func cmdSearch(c tele.Context) error {
|
||||
if sets.BTsets == nil || (!sets.BTsets.EnableRutorSearch && !sets.BTsets.EnableTorznabSearch) {
|
||||
return c.Send(tr(c.Sender().ID, "search_disabled_rutor"))
|
||||
}
|
||||
|
||||
args := c.Args()
|
||||
if len(args) == 0 {
|
||||
return c.Send(tr(c.Sender().ID, "search_usage"))
|
||||
}
|
||||
query := strings.Join(args, " ")
|
||||
uid := c.Sender().ID
|
||||
statusMsg, err := c.Bot().Send(c.Sender(), tr(uid, "searching"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
go func() {
|
||||
var list []*models.TorrentDetails
|
||||
if sets.BTsets != nil && sets.BTsets.EnableRutorSearch {
|
||||
list = append(list, rutor.Search(query)...)
|
||||
}
|
||||
if sets.BTsets != nil && sets.BTsets.EnableTorznabSearch {
|
||||
list = append(list, torznab.Search(query, -1)...)
|
||||
}
|
||||
source := "RuTor+Torznab"
|
||||
sendSearchResultsAsync(c.Bot(), c.Sender(), statusMsg, uid, query, list, source)
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func cmdSearchRutor(c tele.Context) error {
|
||||
if sets.BTsets == nil || !sets.BTsets.EnableRutorSearch {
|
||||
return c.Send(tr(c.Sender().ID, "search_disabled_rutor"))
|
||||
}
|
||||
|
||||
args := c.Args()
|
||||
if len(args) == 0 {
|
||||
return c.Send(tr(c.Sender().ID, "rutor_usage"))
|
||||
}
|
||||
query := strings.Join(args, " ")
|
||||
uid := c.Sender().ID
|
||||
statusMsg, err := c.Bot().Send(c.Sender(), tr(uid, "searching"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
go func() {
|
||||
list := rutor.Search(query)
|
||||
sendSearchResultsAsync(c.Bot(), c.Sender(), statusMsg, uid, query, list, "RuTor")
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func cmdTorznab(c tele.Context) error {
|
||||
if sets.BTsets == nil || !sets.BTsets.EnableTorznabSearch {
|
||||
return c.Send(tr(c.Sender().ID, "search_disabled_torznab"))
|
||||
}
|
||||
|
||||
args := c.Args()
|
||||
if len(args) == 0 {
|
||||
return c.Send(tr(c.Sender().ID, "torznab_usage"))
|
||||
}
|
||||
query := strings.Join(args, " ")
|
||||
index := -1
|
||||
if len(args) > 1 {
|
||||
if i, err := strconv.Atoi(args[len(args)-1]); err == nil && i >= 0 && i < 100 {
|
||||
index = i
|
||||
query = strings.Join(args[:len(args)-1], " ")
|
||||
}
|
||||
}
|
||||
uid := c.Sender().ID
|
||||
statusMsg, err := c.Bot().Send(c.Sender(), tr(uid, "searching"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
go func() {
|
||||
list := torznab.Search(query, index)
|
||||
sendSearchResultsAsync(c.Bot(), c.Sender(), statusMsg, uid, query, list, "Torznab")
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func sendSearchResultsAsync(api tele.API, recipient tele.Recipient, statusMsg *tele.Message, userID int64, query string, list []*models.TorrentDetails, source string) {
|
||||
if len(list) == 0 {
|
||||
_, _ = api.Edit(statusMsg, fmt.Sprintf(tr(userID, "search_not_found"), query, source))
|
||||
return
|
||||
}
|
||||
_ = api.Delete(statusMsg)
|
||||
_ = sendSearchResultsToRecipient(api, recipient, userID, 0, list, source)
|
||||
}
|
||||
|
||||
func sendSearchResultsToRecipient(api tele.API, recipient tele.Recipient, userID int64, offset int, list []*models.TorrentDetails, source string) error {
|
||||
const pageSize = 10
|
||||
if offset == 0 {
|
||||
storeSearchResults(userID, list)
|
||||
}
|
||||
start := offset
|
||||
end := offset + pageSize
|
||||
if end > len(list) {
|
||||
end = len(list)
|
||||
}
|
||||
page := list[start:end]
|
||||
|
||||
for i, item := range page {
|
||||
idx := offset + i
|
||||
link := item.Magnet
|
||||
if link == "" {
|
||||
link = item.Link
|
||||
}
|
||||
if link == "" {
|
||||
continue
|
||||
}
|
||||
size := item.Size
|
||||
if size == "" {
|
||||
size = "?"
|
||||
}
|
||||
txt := fmt.Sprintf("%d. <b>%s</b> (%s) S:%d P:%d", idx+1, escapeHtml(item.Title), size, item.Seed, item.Peer)
|
||||
btnAdd := tele.InlineButton{Text: tr(userID, "btn_add"), Unique: "fadd", Data: strconv.Itoa(idx)}
|
||||
kbd := &tele.ReplyMarkup{InlineKeyboard: [][]tele.InlineButton{{btnAdd}}}
|
||||
_, _ = api.Send(recipient, txt, kbd)
|
||||
}
|
||||
|
||||
if end < len(list) {
|
||||
btnMore := tele.InlineButton{Text: "🔍 " + tr(userID, "search_more"), Unique: "fmore", Data: strconv.Itoa(end)}
|
||||
kbd := &tele.ReplyMarkup{InlineKeyboard: [][]tele.InlineButton{{btnMore}}}
|
||||
_, _ = api.Send(recipient, fmt.Sprintf(tr(userID, "search_more_hint"), end, len(list)), kbd)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func callbackSearchMore(c tele.Context, offsetStr string) error {
|
||||
uid := c.Sender().ID
|
||||
offset, err := strconv.Atoi(offsetStr)
|
||||
if err != nil || offset < 0 {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "error")})
|
||||
}
|
||||
slice, total := getSearchResultsSlice(uid, offset, 10)
|
||||
if len(slice) == 0 {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "search_expired")})
|
||||
}
|
||||
_ = c.Respond(&tele.CallbackResponse{})
|
||||
if c.Callback().Message != nil {
|
||||
_ = c.Bot().Delete(c.Callback().Message)
|
||||
}
|
||||
return sendSearchResultsPage(c.Bot(), c.Sender(), uid, offset, slice, total)
|
||||
}
|
||||
|
||||
func sendSearchResultsPage(api tele.API, recipient tele.Recipient, userID int64, offset int, page []*models.TorrentDetails, total int) error {
|
||||
for i, item := range page {
|
||||
idx := offset + i
|
||||
link := item.Magnet
|
||||
if link == "" {
|
||||
link = item.Link
|
||||
}
|
||||
if link == "" {
|
||||
continue
|
||||
}
|
||||
size := item.Size
|
||||
if size == "" {
|
||||
size = "?"
|
||||
}
|
||||
txt := fmt.Sprintf("%d. <b>%s</b> (%s) S:%d P:%d", idx+1, escapeHtml(item.Title), size, item.Seed, item.Peer)
|
||||
btnAdd := tele.InlineButton{Text: tr(userID, "btn_add"), Unique: "fadd", Data: strconv.Itoa(idx)}
|
||||
kbd := &tele.ReplyMarkup{InlineKeyboard: [][]tele.InlineButton{{btnAdd}}}
|
||||
_, _ = api.Send(recipient, txt, kbd)
|
||||
}
|
||||
nextOffset := offset + len(page)
|
||||
if nextOffset < total {
|
||||
btnMore := tele.InlineButton{Text: "🔍 " + tr(userID, "search_more"), Unique: "fmore", Data: strconv.Itoa(nextOffset)}
|
||||
kbd := &tele.ReplyMarkup{InlineKeyboard: [][]tele.InlineButton{{btnMore}}}
|
||||
_, _ = api.Send(recipient, fmt.Sprintf(tr(userID, "search_more_hint"), nextOffset, total), kbd)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
tele "gopkg.in/telebot.v4"
|
||||
"server/rutor/models"
|
||||
)
|
||||
|
||||
type searchCacheEntry struct {
|
||||
results []*models.TorrentDetails
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
searchCache = make(map[int64]*searchCacheEntry)
|
||||
searchCacheMu sync.RWMutex
|
||||
cacheTTL = 10 * time.Minute
|
||||
cacheMaxSize = 1000
|
||||
)
|
||||
|
||||
func init() {
|
||||
go searchCacheCleanup()
|
||||
}
|
||||
|
||||
func searchCacheCleanup() {
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
for range ticker.C {
|
||||
searchCacheMu.Lock()
|
||||
now := time.Now()
|
||||
for id, entry := range searchCache {
|
||||
if entry == nil || now.After(entry.expires) {
|
||||
delete(searchCache, id)
|
||||
}
|
||||
}
|
||||
if len(searchCache) > cacheMaxSize {
|
||||
evict := len(searchCache) - cacheMaxSize
|
||||
if evict < len(searchCache)/10 {
|
||||
evict = len(searchCache) / 10
|
||||
}
|
||||
if evict < 1 {
|
||||
evict = 1
|
||||
}
|
||||
type kv struct {
|
||||
id int64
|
||||
exp time.Time
|
||||
}
|
||||
var entries []kv
|
||||
for id, entry := range searchCache {
|
||||
if entry != nil {
|
||||
entries = append(entries, kv{id, entry.expires})
|
||||
}
|
||||
}
|
||||
for evict > 0 && len(entries) > 0 {
|
||||
oldest := 0
|
||||
for j := 1; j < len(entries); j++ {
|
||||
if entries[j].exp.Before(entries[oldest].exp) {
|
||||
oldest = j
|
||||
}
|
||||
}
|
||||
delete(searchCache, entries[oldest].id)
|
||||
entries[oldest] = entries[len(entries)-1]
|
||||
entries = entries[:len(entries)-1]
|
||||
evict--
|
||||
}
|
||||
}
|
||||
searchCacheMu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func storeSearchResults(userID int64, results []*models.TorrentDetails) {
|
||||
searchCacheMu.Lock()
|
||||
defer searchCacheMu.Unlock()
|
||||
searchCache[userID] = &searchCacheEntry{
|
||||
results: results,
|
||||
expires: time.Now().Add(cacheTTL),
|
||||
}
|
||||
}
|
||||
|
||||
func getSearchResult(userID int64, index int) *models.TorrentDetails {
|
||||
searchCacheMu.Lock()
|
||||
defer searchCacheMu.Unlock()
|
||||
entry, ok := searchCache[userID]
|
||||
if !ok || entry == nil || time.Now().After(entry.expires) {
|
||||
return nil
|
||||
}
|
||||
if index < 0 || index >= len(entry.results) {
|
||||
return nil
|
||||
}
|
||||
return entry.results[index]
|
||||
}
|
||||
|
||||
func getSearchResultsSlice(userID int64, offset, limit int) ([]*models.TorrentDetails, int) {
|
||||
searchCacheMu.Lock()
|
||||
defer searchCacheMu.Unlock()
|
||||
entry, ok := searchCache[userID]
|
||||
if !ok || entry == nil || time.Now().After(entry.expires) {
|
||||
return nil, 0
|
||||
}
|
||||
total := len(entry.results)
|
||||
if offset >= total {
|
||||
return nil, total
|
||||
}
|
||||
end := offset + limit
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
slice := make([]*models.TorrentDetails, end-offset)
|
||||
copy(slice, entry.results[offset:end])
|
||||
return slice, total
|
||||
}
|
||||
|
||||
func callbackSearchAdd(c tele.Context, indexStr string) error {
|
||||
uid := c.Sender().ID
|
||||
index, parseErr := strconv.Atoi(indexStr)
|
||||
if parseErr != nil {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "error")})
|
||||
}
|
||||
item := getSearchResult(uid, index)
|
||||
if item == nil {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "search_expired")})
|
||||
}
|
||||
link := item.Magnet
|
||||
if link == "" {
|
||||
link = item.Link
|
||||
}
|
||||
if link == "" {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "search_no_link")})
|
||||
}
|
||||
_ = c.Respond(&tele.CallbackResponse{Text: tr(uid, "search_adding")})
|
||||
if err := addTorrent(c, link); err != nil {
|
||||
return c.Send(fmt.Sprintf(tr(uid, "add_error"), err.Error()))
|
||||
}
|
||||
return list(c)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tele "gopkg.in/telebot.v4"
|
||||
"server/settings"
|
||||
"server/torr"
|
||||
)
|
||||
|
||||
func cmdServer(c tele.Context) error {
|
||||
uid := c.Sender().ID
|
||||
host := getHost()
|
||||
torrents := torr.ListTorrent()
|
||||
streams := torr.GetActiveStreams()
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("🖥 <b>" + tr(uid, "server_title") + "</b>\n\n")
|
||||
fmt.Fprintf(&sb, "%s: <code>%s</code>\n", tr(uid, "server_url"), host)
|
||||
fmt.Fprintf(&sb, "%s: %s\n", tr(uid, "server_port"), settings.Port)
|
||||
if settings.Ssl {
|
||||
fmt.Fprintf(&sb, "SSL %s: %s\n", tr(uid, "server_port"), settings.SslPort)
|
||||
}
|
||||
fmt.Fprintf(&sb, "%s: %d\n", tr(uid, "stats_torrents"), len(torrents))
|
||||
fmt.Fprintf(&sb, "%s: %d\n", tr(uid, "server_streams"), streams)
|
||||
return c.Send(sb.String())
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tele "gopkg.in/telebot.v4"
|
||||
"server/torr"
|
||||
)
|
||||
|
||||
func cmdSet(c tele.Context) error {
|
||||
args := c.Args()
|
||||
if len(args) < 2 {
|
||||
return c.Send(tr(c.Sender().ID, "set_usage"))
|
||||
}
|
||||
hash := resolveHash(c, args[0])
|
||||
if hash == "" {
|
||||
return c.Send(tr(c.Sender().ID, "invalid_hash"))
|
||||
}
|
||||
title := strings.TrimSpace(strings.Join(args[1:], " "))
|
||||
if title == "" {
|
||||
return c.Send(tr(c.Sender().ID, "set_title_required"))
|
||||
}
|
||||
|
||||
torr.SetTorrent(hash, title, "", "", "")
|
||||
return c.Send(fmt.Sprintf(tr(c.Sender().ID, "set_done"), escapeHtml(title)))
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
tele "gopkg.in/telebot.v4"
|
||||
"server/dlna"
|
||||
"server/rutor"
|
||||
"server/settings"
|
||||
"server/torr"
|
||||
"server/torznab"
|
||||
)
|
||||
|
||||
const pendingInputTTL = 30 * time.Minute
|
||||
|
||||
type pendingInput struct {
|
||||
Setting string
|
||||
UserID int64
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
pendingInputMu sync.Mutex
|
||||
pendingInputs = make(map[string]pendingInput)
|
||||
)
|
||||
|
||||
func init() {
|
||||
go pendingInputCleanup()
|
||||
}
|
||||
|
||||
func pendingInputCleanup() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
for range ticker.C {
|
||||
pendingInputMu.Lock()
|
||||
now := time.Now()
|
||||
for key, p := range pendingInputs {
|
||||
if now.Sub(p.CreatedAt) > pendingInputTTL {
|
||||
delete(pendingInputs, key)
|
||||
}
|
||||
}
|
||||
pendingInputMu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func sendSettingsInputPrompt(c tele.Context, uid int64, setting, hint string) error {
|
||||
msg := fmt.Sprintf("✏️ %s\n\n%s", tr(uid, "settings_input_reply"), hint)
|
||||
btnCancel := tele.InlineButton{Text: "❌ " + tr(uid, "canceled"), Unique: "fset", Data: "input_cancel"}
|
||||
kbd := &tele.ReplyMarkup{InlineKeyboard: [][]tele.InlineButton{{btnCancel}}}
|
||||
sent, err := c.Bot().Send(c.Chat(), msg, kbd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pendingInputMu.Lock()
|
||||
pendingInputs[chatMsgKey(sent.Chat.ID, sent.ID)] = pendingInput{Setting: setting, UserID: uid, CreatedAt: time.Now()}
|
||||
pendingInputMu.Unlock()
|
||||
return c.Respond(&tele.CallbackResponse{})
|
||||
}
|
||||
|
||||
func handleSettingsInputReply(c tele.Context) (handled bool) {
|
||||
msg := c.Message()
|
||||
if msg.ReplyTo == nil {
|
||||
return false
|
||||
}
|
||||
key := chatMsgKey(msg.ReplyTo.Chat.ID, msg.ReplyTo.ID)
|
||||
pendingInputMu.Lock()
|
||||
pending, ok := pendingInputs[key]
|
||||
delete(pendingInputs, key)
|
||||
pendingInputMu.Unlock()
|
||||
if !ok || pending.UserID != msg.Sender.ID {
|
||||
return false
|
||||
}
|
||||
if time.Since(pending.CreatedAt) > pendingInputTTL {
|
||||
_ = c.Send(tr(msg.Sender.ID, "canceled"))
|
||||
return true
|
||||
}
|
||||
applySettingsInput(c, pending.Setting, strings.TrimSpace(msg.Text))
|
||||
return true
|
||||
}
|
||||
|
||||
func cancelSettingsInput(c tele.Context) error {
|
||||
key := chatMsgKey(c.Callback().Message.Chat.ID, c.Callback().Message.ID)
|
||||
pendingInputMu.Lock()
|
||||
delete(pendingInputs, key)
|
||||
pendingInputMu.Unlock()
|
||||
_ = c.Bot().Delete(c.Callback().Message)
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "canceled")})
|
||||
}
|
||||
|
||||
func applySettingsInput(c tele.Context, setting, value string) {
|
||||
uid := c.Sender().ID
|
||||
if !isAdmin(uid) {
|
||||
_ = c.Send(tr(uid, "admin_only"))
|
||||
return
|
||||
}
|
||||
if settings.ReadOnly {
|
||||
_ = c.Send(tr(uid, "settings_readonly"))
|
||||
return
|
||||
}
|
||||
if settings.BTsets == nil {
|
||||
_ = c.Send(tr(uid, "settings_not_loaded"))
|
||||
return
|
||||
}
|
||||
|
||||
clear := strings.ToLower(value) == "clear" || strings.ToLower(value) == "очистить" || value == "-"
|
||||
if clear {
|
||||
value = ""
|
||||
}
|
||||
|
||||
sets := new(settings.BTSets)
|
||||
*sets = *settings.BTsets
|
||||
|
||||
switch setting {
|
||||
case "friendlyname":
|
||||
sets.FriendlyName = value
|
||||
_ = c.Send(fmt.Sprintf(tr(uid, "settings_input_done"), "FriendlyName", valueOrClear(value)))
|
||||
case "torrentssavepath":
|
||||
if value != "" {
|
||||
abs, err := filepath.Abs(value)
|
||||
if err != nil {
|
||||
abs = value
|
||||
}
|
||||
if _, err := os.Stat(abs); err != nil && !os.IsNotExist(err) {
|
||||
_ = c.Send(fmt.Sprintf(tr(uid, "settings_input_error"), err.Error()))
|
||||
return
|
||||
}
|
||||
sets.TorrentsSavePath = abs
|
||||
sets.UseDisk = true
|
||||
} else {
|
||||
sets.TorrentsSavePath = ""
|
||||
sets.UseDisk = false
|
||||
}
|
||||
_ = c.Send(fmt.Sprintf(tr(uid, "settings_input_done"), "TorrentsSavePath", valueOrClear(value)))
|
||||
case "sslcert":
|
||||
sets.SslCert = value
|
||||
_ = c.Send(fmt.Sprintf(tr(uid, "settings_input_done"), "SslCert", valueOrClear(value)))
|
||||
case "sslkey":
|
||||
sets.SslKey = value
|
||||
_ = c.Send(fmt.Sprintf(tr(uid, "settings_input_done"), "SslKey", valueOrClear(value)))
|
||||
case "tmdbkey":
|
||||
sets.TMDBSettings.APIKey = value
|
||||
_ = c.Send(fmt.Sprintf(tr(uid, "settings_input_done"), "TMDB API Key", valueOrClear(value)))
|
||||
case "proxyhosts":
|
||||
if value == "" {
|
||||
sets.ProxyHosts = nil
|
||||
} else {
|
||||
parts := strings.Split(value, ",")
|
||||
for i := range parts {
|
||||
parts[i] = strings.TrimSpace(parts[i])
|
||||
}
|
||||
sets.ProxyHosts = parts
|
||||
}
|
||||
_ = c.Send(fmt.Sprintf(tr(uid, "settings_input_done"), "ProxyHosts", valueOrClear(value)))
|
||||
case "torznab_add":
|
||||
if value == "" {
|
||||
_ = c.Send(tr(uid, "settings_input_torznab_usage"))
|
||||
return
|
||||
}
|
||||
parts := strings.SplitN(value, "|", 3)
|
||||
cfg := settings.TorznabConfig{Host: strings.TrimSpace(parts[0])}
|
||||
if len(parts) > 1 {
|
||||
cfg.Key = strings.TrimSpace(parts[1])
|
||||
}
|
||||
if len(parts) > 2 {
|
||||
cfg.Name = strings.TrimSpace(parts[2])
|
||||
}
|
||||
if !strings.HasPrefix(cfg.Host, "http") {
|
||||
cfg.Host = "https://" + cfg.Host
|
||||
}
|
||||
sets.TorznabUrls = append(sets.TorznabUrls, cfg)
|
||||
_ = c.Send(fmt.Sprintf(tr(uid, "settings_input_torznab_added"), cfg.Host))
|
||||
case "torznab_test":
|
||||
if value == "" {
|
||||
_ = c.Send(tr(uid, "settings_input_torznab_usage"))
|
||||
return
|
||||
}
|
||||
parts := strings.SplitN(value, "|", 3)
|
||||
host := strings.TrimSpace(parts[0])
|
||||
key := ""
|
||||
if len(parts) > 1 {
|
||||
key = strings.TrimSpace(parts[1])
|
||||
}
|
||||
if !strings.HasPrefix(host, "http") {
|
||||
host = "https://" + host
|
||||
}
|
||||
if err := torznab.Test(host, key); err != nil {
|
||||
_ = c.Send(fmt.Sprintf(tr(uid, "settings_torznab_test_fail"), err.Error()))
|
||||
return
|
||||
}
|
||||
_ = c.Send(tr(uid, "settings_torznab_test_ok"))
|
||||
return
|
||||
default:
|
||||
_ = c.Send(tr(uid, "callback_unknown"))
|
||||
return
|
||||
}
|
||||
|
||||
torr.SetSettings(sets)
|
||||
dlna.Stop()
|
||||
if sets.EnableDLNA {
|
||||
dlna.Start()
|
||||
}
|
||||
rutor.Stop()
|
||||
rutor.Start()
|
||||
}
|
||||
|
||||
func valueOrClear(v string) string {
|
||||
if v == "" {
|
||||
return "(cleared)"
|
||||
}
|
||||
if len(v) > 50 {
|
||||
return v[:47] + "..."
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
tele "gopkg.in/telebot.v4"
|
||||
"server/log"
|
||||
"server/torr"
|
||||
cacheSt "server/torr/storage/state"
|
||||
)
|
||||
|
||||
var (
|
||||
snakeStopChans = make(map[int]chan struct{})
|
||||
snakeStopChansMu sync.Mutex
|
||||
snakeWindowStart = make(map[string]int)
|
||||
snakeWindowStartMu sync.Mutex
|
||||
)
|
||||
|
||||
const (
|
||||
snakeBlockFilled = "🟩"
|
||||
snakeBlockEmpty = "⬜"
|
||||
snakeBlockReader = "🔵"
|
||||
snakeBlockInRange = "🟦"
|
||||
snakeTitleMaxLen = 55
|
||||
snakeHashDisplayLen = 8
|
||||
)
|
||||
|
||||
func cmdSnake(c tele.Context) error {
|
||||
args := c.Args()
|
||||
hash := ""
|
||||
cols, rows := 20, 3
|
||||
|
||||
if len(args) > 0 {
|
||||
hash = resolveHash(c, args[0])
|
||||
}
|
||||
if len(args) > 1 {
|
||||
if n, err := strconv.Atoi(args[1]); err == nil && n > 0 && n <= 50 {
|
||||
cols = n
|
||||
}
|
||||
}
|
||||
if len(args) > 2 {
|
||||
if n, err := strconv.Atoi(args[2]); err == nil && n > 0 && n <= 15 {
|
||||
rows = n
|
||||
}
|
||||
}
|
||||
|
||||
if hash == "" {
|
||||
return c.Send(tr(c.Sender().ID, "snake_usage"))
|
||||
}
|
||||
|
||||
t := torr.GetTorrent(hash)
|
||||
if t == nil {
|
||||
return c.Send(tr(c.Sender().ID, "torrent_not_found") + ":\n<code>" + hash + "</code>")
|
||||
}
|
||||
|
||||
st := t.CacheState()
|
||||
if st == nil {
|
||||
return c.Send(fmt.Sprintf(tr(c.Sender().ID, "cache_unavailable"), hash))
|
||||
}
|
||||
|
||||
uid := c.Sender().ID
|
||||
txt := formatSnake(uid, st, hash, cols, rows)
|
||||
kbd := snakeKeyboard(uid, hash, cols, rows, true)
|
||||
msg, err := c.Bot().Send(c.Sender(), txt, kbd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.TLogln("tg snake sent", logUserID(uid), logSafeStr(st.Torrent.Title, 40), hash)
|
||||
go snakeRefreshLoop(c.Bot(), msg, hash, uid, cols, rows)
|
||||
return nil
|
||||
}
|
||||
|
||||
func formatSnake(uid int64, st *cacheSt.CacheState, hash string, cols, rows int) string {
|
||||
totalBlocks := cols * rows
|
||||
if totalBlocks <= 0 {
|
||||
return tr(uid, "snake_no_data")
|
||||
}
|
||||
if st.PiecesCount <= 0 {
|
||||
title := ""
|
||||
if st.Torrent != nil {
|
||||
title = escapeHtml(st.Torrent.Title)
|
||||
}
|
||||
txt := "📊 <b>" + title + "</b>\n"
|
||||
txt += fmt.Sprintf("%s: %s / %s\n", tr(uid, "snake_cache"),
|
||||
humanize.IBytes(uint64(st.Filled)), humanize.IBytes(uint64(st.Capacity)))
|
||||
dispHash := st.Hash
|
||||
if len(dispHash) > snakeHashDisplayLen {
|
||||
dispHash = dispHash[:snakeHashDisplayLen]
|
||||
}
|
||||
txt += tr(uid, "snake_no_data") + " <code>" + dispHash + "</code>"
|
||||
return txt
|
||||
}
|
||||
|
||||
pieceFilled := make(map[int]bool)
|
||||
for id, p := range st.Pieces {
|
||||
if id >= 0 && id < st.PiecesCount && p.Size > 0 {
|
||||
pieceFilled[id] = true
|
||||
}
|
||||
}
|
||||
|
||||
readerPositions := make(map[int]bool)
|
||||
readerRanges := make(map[int]bool)
|
||||
for _, r := range st.Readers {
|
||||
readerPositions[r.Reader] = true
|
||||
for p := r.Start; p < r.End && p < st.PiecesCount; p++ {
|
||||
readerRanges[p] = true
|
||||
}
|
||||
}
|
||||
|
||||
cacheWindowPieces := int64(totalBlocks) * 2
|
||||
if st.PiecesLength > 0 {
|
||||
cacheWindowPieces = st.Capacity / st.PiecesLength
|
||||
}
|
||||
if cacheWindowPieces < int64(totalBlocks) {
|
||||
cacheWindowPieces = int64(totalBlocks)
|
||||
}
|
||||
|
||||
startPiece, endPiece := 0, st.PiecesCount
|
||||
if len(st.Readers) > 0 {
|
||||
minReader, maxReader := st.PiecesCount, 0
|
||||
for _, r := range st.Readers {
|
||||
if r.Reader < minReader {
|
||||
minReader = r.Reader
|
||||
}
|
||||
if r.Reader > maxReader {
|
||||
maxReader = r.Reader
|
||||
}
|
||||
}
|
||||
windowSize := int(cacheWindowPieces)
|
||||
snakeWindowStartMu.Lock()
|
||||
lastStart := snakeWindowStart[hash]
|
||||
scrollThreshold := windowSize * 3 / 4
|
||||
if lastStart == 0 || minReader < lastStart {
|
||||
lastStart = minReader
|
||||
} else if minReader >= lastStart+scrollThreshold {
|
||||
lastStart = minReader - windowSize/5
|
||||
}
|
||||
if lastStart < 0 {
|
||||
lastStart = 0
|
||||
}
|
||||
snakeWindowStart[hash] = lastStart
|
||||
snakeWindowStartMu.Unlock()
|
||||
startPiece = lastStart
|
||||
endPiece = startPiece + windowSize
|
||||
if endPiece > st.PiecesCount {
|
||||
endPiece = st.PiecesCount
|
||||
startPiece = endPiece - windowSize
|
||||
if startPiece < 0 {
|
||||
startPiece = 0
|
||||
}
|
||||
}
|
||||
} else if len(pieceFilled) > 0 {
|
||||
minP, maxP := st.PiecesCount, 0
|
||||
for id := range pieceFilled {
|
||||
if id < minP {
|
||||
minP = id
|
||||
}
|
||||
if id > maxP {
|
||||
maxP = id
|
||||
}
|
||||
}
|
||||
window := maxP - minP + 1
|
||||
if window > int(cacheWindowPieces) {
|
||||
window = int(cacheWindowPieces)
|
||||
}
|
||||
startPiece = minP
|
||||
endPiece = minP + window
|
||||
if endPiece > st.PiecesCount {
|
||||
endPiece = st.PiecesCount
|
||||
}
|
||||
}
|
||||
|
||||
windowSize := endPiece - startPiece
|
||||
if windowSize <= 0 {
|
||||
windowSize = 1
|
||||
}
|
||||
|
||||
blocks := make([]string, totalBlocks)
|
||||
piecesPerBlock := (windowSize + totalBlocks - 1) / totalBlocks
|
||||
if piecesPerBlock < 1 {
|
||||
piecesPerBlock = 1
|
||||
}
|
||||
|
||||
for i := 0; i < totalBlocks; i++ {
|
||||
start := startPiece + i*piecesPerBlock
|
||||
end := start + piecesPerBlock
|
||||
if end > endPiece {
|
||||
end = endPiece
|
||||
}
|
||||
if start >= end {
|
||||
blocks[i] = snakeBlockEmpty
|
||||
continue
|
||||
}
|
||||
|
||||
blockFilled := false
|
||||
blockHasReader := false
|
||||
blockInRange := false
|
||||
for p := start; p < end; p++ {
|
||||
if pieceFilled[p] {
|
||||
blockFilled = true
|
||||
}
|
||||
if readerPositions[p] {
|
||||
blockHasReader = true
|
||||
}
|
||||
if readerRanges[p] {
|
||||
blockInRange = true
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case blockHasReader:
|
||||
blocks[i] = snakeBlockReader
|
||||
case blockFilled:
|
||||
blocks[i] = snakeBlockFilled
|
||||
case blockInRange:
|
||||
blocks[i] = snakeBlockInRange
|
||||
default:
|
||||
blocks[i] = snakeBlockEmpty
|
||||
}
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
title := ""
|
||||
if st.Torrent != nil {
|
||||
title = st.Torrent.Title
|
||||
}
|
||||
if len([]rune(title)) > snakeTitleMaxLen {
|
||||
title = string([]rune(title)[:snakeTitleMaxLen]) + "…"
|
||||
}
|
||||
title = escapeHtml(title)
|
||||
sb.WriteString("📊 <b>")
|
||||
sb.WriteString(title)
|
||||
sb.WriteString("</b>\n")
|
||||
fmt.Fprintf(&sb, "%s: %s / %s",
|
||||
tr(uid, "snake_cache"),
|
||||
humanize.IBytes(uint64(st.Filled)),
|
||||
humanize.IBytes(uint64(st.Capacity)))
|
||||
if len(st.Readers) > 1 {
|
||||
fmt.Fprintf(&sb, " · %d %s", len(st.Readers), tr(uid, "status_streams"))
|
||||
}
|
||||
if endPiece-startPiece < st.PiecesCount {
|
||||
fmt.Fprintf(&sb, " · %s %d-%d", tr(uid, "snake_pieces"), startPiece+1, endPiece)
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
for r := 0; r < rows; r++ {
|
||||
for c := 0; c < cols; c++ {
|
||||
var idx int
|
||||
if r%2 == 0 {
|
||||
idx = r*cols + c
|
||||
} else {
|
||||
idx = r*cols + (cols - 1 - c)
|
||||
}
|
||||
if idx < len(blocks) {
|
||||
sb.WriteString(blocks[idx])
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
dispHash := st.Hash
|
||||
if len(dispHash) > snakeHashDisplayLen {
|
||||
dispHash = dispHash[:snakeHashDisplayLen]
|
||||
}
|
||||
sb.WriteString(tr(uid, "snake_legend"))
|
||||
sb.WriteString(" <code>")
|
||||
sb.WriteString(dispHash)
|
||||
sb.WriteString("</code>")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func snakeData(hash string, cols, rows int) string {
|
||||
return fmt.Sprintf("%s|%d|%d", hash, cols, rows)
|
||||
}
|
||||
|
||||
func parseSnakeData(data string) (hash string, cols, rows int) {
|
||||
cols, rows = 20, 3
|
||||
parts := strings.Split(data, "|")
|
||||
if len(parts) > 0 {
|
||||
hash = parts[0]
|
||||
}
|
||||
if len(parts) > 1 {
|
||||
if n, err := strconv.Atoi(parts[1]); err == nil && n > 0 {
|
||||
cols = n
|
||||
}
|
||||
}
|
||||
if len(parts) > 2 {
|
||||
if n, err := strconv.Atoi(parts[2]); err == nil && n > 0 {
|
||||
rows = n
|
||||
}
|
||||
}
|
||||
return hash, cols, rows
|
||||
}
|
||||
|
||||
func snakeKeyboard(uid int64, hash string, cols, rows int, active bool) *tele.ReplyMarkup {
|
||||
data := snakeData(hash, cols, rows)
|
||||
if active {
|
||||
return &tele.ReplyMarkup{InlineKeyboard: [][]tele.InlineButton{
|
||||
{
|
||||
{Text: "🔄", Unique: "fsnakerefresh", Data: data},
|
||||
{Text: tr(uid, "status_stop_btn"), Unique: "fsnakestop", Data: data},
|
||||
},
|
||||
}}
|
||||
}
|
||||
return &tele.ReplyMarkup{InlineKeyboard: [][]tele.InlineButton{
|
||||
{{Text: tr(uid, "status_refresh_btn"), Unique: "fsnakerefresh", Data: data}},
|
||||
}}
|
||||
}
|
||||
|
||||
func snakeRefreshLoop(api tele.API, msg *tele.Message, hash string, uid int64, cols, rows int) {
|
||||
const interval = 2 * time.Second
|
||||
const duration = 2 * time.Minute
|
||||
stopCh := make(chan struct{})
|
||||
snakeStopChansMu.Lock()
|
||||
snakeStopChans[msg.ID] = stopCh
|
||||
snakeStopChansMu.Unlock()
|
||||
defer func() {
|
||||
snakeStopChansMu.Lock()
|
||||
delete(snakeStopChans, msg.ID)
|
||||
snakeStopChansMu.Unlock()
|
||||
}()
|
||||
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
deadline := time.Now().Add(duration)
|
||||
for {
|
||||
select {
|
||||
case <-stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
if time.Now().After(deadline) {
|
||||
t := torr.GetTorrent(hash)
|
||||
if t != nil {
|
||||
if st := t.CacheState(); st != nil {
|
||||
txt := formatSnake(uid, st, hash, cols, rows) + "\n" + tr(uid, "status_auto_ended")
|
||||
_, _ = api.Edit(msg, txt, snakeKeyboard(uid, hash, cols, rows, false), tele.ModeHTML)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
t := torr.GetTorrent(hash)
|
||||
if t == nil {
|
||||
return
|
||||
}
|
||||
st := t.CacheState()
|
||||
if st == nil {
|
||||
return
|
||||
}
|
||||
txt := formatSnake(uid, st, hash, cols, rows)
|
||||
if _, err := api.Edit(msg, txt, snakeKeyboard(uid, hash, cols, rows, true), tele.ModeHTML); err != nil {
|
||||
errStr := err.Error()
|
||||
if strings.Contains(errStr, "message is not modified") {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(errStr, "message to edit not found") {
|
||||
return
|
||||
}
|
||||
log.TLogln("tg snake refresh err", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stopSnakeRefresh(msgID int) {
|
||||
snakeStopChansMu.Lock()
|
||||
ch := snakeStopChans[msgID]
|
||||
delete(snakeStopChans, msgID)
|
||||
snakeStopChansMu.Unlock()
|
||||
if ch != nil {
|
||||
close(ch)
|
||||
}
|
||||
}
|
||||
|
||||
func callbackSnakeRefresh(c tele.Context, data string) error {
|
||||
hash, cols, rows := parseSnakeData(data)
|
||||
if hash == "" {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "callback_unknown")})
|
||||
}
|
||||
t := torr.GetTorrent(hash)
|
||||
if t == nil {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "torrent_not_found")})
|
||||
}
|
||||
st := t.CacheState()
|
||||
if st == nil {
|
||||
return c.Respond(&tele.CallbackResponse{Text: fmt.Sprintf(tr(c.Sender().ID, "cache_unavailable"), hash)})
|
||||
}
|
||||
if c.Callback().Message != nil {
|
||||
stopSnakeRefresh(c.Callback().Message.ID)
|
||||
_ = c.Bot().Delete(c.Callback().Message)
|
||||
}
|
||||
_ = c.Respond(&tele.CallbackResponse{})
|
||||
uid := c.Sender().ID
|
||||
txt := formatSnake(uid, st, hash, cols, rows)
|
||||
kbd := snakeKeyboard(uid, hash, cols, rows, true)
|
||||
msg, err := c.Bot().Send(c.Sender(), txt, kbd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
go snakeRefreshLoop(c.Bot(), msg, hash, uid, cols, rows)
|
||||
return nil
|
||||
}
|
||||
|
||||
func callbackSnakeStop(c tele.Context, data string) error {
|
||||
uid := c.Sender().ID
|
||||
hash, cols, rows := parseSnakeData(data)
|
||||
if hash != "" {
|
||||
if t := torr.GetTorrent(hash); t != nil {
|
||||
log.TLogln("tg snake stop", logUserID(uid), logSafeStr(t.Title, 40), hash)
|
||||
}
|
||||
}
|
||||
if c.Callback().Message != nil {
|
||||
stopSnakeRefresh(c.Callback().Message.ID)
|
||||
if hash != "" {
|
||||
msg := c.Callback().Message
|
||||
t := torr.GetTorrent(hash)
|
||||
txt := ""
|
||||
if t != nil {
|
||||
if st := t.CacheState(); st != nil {
|
||||
txt = formatSnake(uid, st, hash, cols, rows)
|
||||
}
|
||||
}
|
||||
if txt == "" {
|
||||
txt = "<code>" + hash + "</code>"
|
||||
}
|
||||
txt += "\n" + tr(uid, "status_stopped")
|
||||
_, _ = c.Bot().Edit(msg, txt, snakeKeyboard(uid, hash, cols, rows, false), tele.ModeHTML)
|
||||
}
|
||||
}
|
||||
return c.Respond(&tele.CallbackResponse{Text: "🛑"})
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
tele "gopkg.in/telebot.v4"
|
||||
)
|
||||
|
||||
func cmdSpeedtest(c tele.Context) error {
|
||||
args := c.Args()
|
||||
size := 10
|
||||
if len(args) > 0 {
|
||||
if s, err := strconv.Atoi(strings.TrimSpace(args[0])); err == nil && s > 0 && s <= 100 {
|
||||
size = s
|
||||
}
|
||||
}
|
||||
|
||||
host := getHost()
|
||||
url := fmt.Sprintf("%s/download/%d", host, size)
|
||||
return c.Send(fmt.Sprintf(tr(c.Sender().ID, "speedtest_msg"), size, url))
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
|
||||
tele "gopkg.in/telebot.v4"
|
||||
"server/torr"
|
||||
)
|
||||
|
||||
func cmdStat(c tele.Context) error {
|
||||
var buf bytes.Buffer
|
||||
torr.WriteStatus(&buf)
|
||||
msg := buf.String()
|
||||
msg = strings.ReplaceAll(msg, "<", "<")
|
||||
msg = strings.ReplaceAll(msg, ">", ">")
|
||||
if len(msg) > 4000 {
|
||||
msg = msg[:4000] + "\n..."
|
||||
}
|
||||
return c.Send("📋 <b>" + tr(c.Sender().ID, "help_stat") + "</b>\n\n<pre>" + msg + "</pre>")
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
tele "gopkg.in/telebot.v4"
|
||||
"server/torr"
|
||||
)
|
||||
|
||||
func cmdStats(c tele.Context) error {
|
||||
torrents := torr.ListTorrent()
|
||||
if len(torrents) == 0 {
|
||||
return c.Send(tr(c.Sender().ID, "no_torrents"))
|
||||
}
|
||||
|
||||
var totalSize, loadedSize int64
|
||||
var totalPeers, activePeers, seeders int
|
||||
for _, t := range torrents {
|
||||
st := t.Status()
|
||||
if st != nil {
|
||||
totalSize += st.TorrentSize
|
||||
loadedSize += st.LoadedSize
|
||||
totalPeers += st.TotalPeers
|
||||
activePeers += st.ActivePeers
|
||||
seeders += st.ConnectedSeeders
|
||||
} else {
|
||||
totalSize += t.Size
|
||||
}
|
||||
}
|
||||
|
||||
streams := torr.GetActiveStreams()
|
||||
|
||||
uid := c.Sender().ID
|
||||
var sb strings.Builder
|
||||
sb.WriteString("📊 <b>" + tr(uid, "stats_title") + "</b>\n\n")
|
||||
fmt.Fprintf(&sb, "%s: %d\n", tr(uid, "stats_torrents"), len(torrents))
|
||||
fmt.Fprintf(&sb, "%s: %s\n", tr(uid, "stats_total_size"), humanize.IBytes(uint64(totalSize)))
|
||||
progress := 0.0
|
||||
if totalSize > 0 {
|
||||
progress = float64(loadedSize) / float64(totalSize) * 100
|
||||
}
|
||||
fmt.Fprintf(&sb, "%s: %s (%.1f%%)\n",
|
||||
tr(uid, "stats_loaded"), humanize.IBytes(uint64(loadedSize)), progress)
|
||||
fmt.Fprintf(&sb, "%s: %d %s, %d %s\n",
|
||||
tr(uid, "stats_peers"), activePeers, tr(uid, "stats_active"), seeders, tr(uid, "stats_seeds"))
|
||||
fmt.Fprintf(&sb, "%s: %d\n", tr(uid, "stats_streams"), streams)
|
||||
return c.Send(sb.String())
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
tele "gopkg.in/telebot.v4"
|
||||
"server/log"
|
||||
"server/torr"
|
||||
)
|
||||
|
||||
// humanizeSpeedBits formats bytes/s as bits/s (bps, kbps, Mbps, Gbps, Tbps) — same as web mode.
|
||||
func humanizeSpeedBits(uid int64, bytesPerSec float64) string {
|
||||
if bytesPerSec <= 0 {
|
||||
return "0 " + tr(uid, "speed_bps")
|
||||
}
|
||||
bits := bytesPerSec * 8
|
||||
i := int(math.Floor(math.Log(bits) / math.Log(1000)))
|
||||
if i < 0 {
|
||||
i = 0
|
||||
}
|
||||
units := []string{"speed_bps", "speed_kbps", "speed_Mbps", "speed_Gbps", "speed_Tbps"}
|
||||
if i >= len(units) {
|
||||
i = len(units) - 1
|
||||
}
|
||||
val := bits / math.Pow(1000, float64(i))
|
||||
return fmt.Sprintf("%.0f %s", val, tr(uid, units[i]))
|
||||
}
|
||||
|
||||
var (
|
||||
statusStopChans = make(map[int]chan struct{})
|
||||
statusStopChansMu sync.Mutex
|
||||
)
|
||||
|
||||
func cmdStatus(c tele.Context) error {
|
||||
arg := ""
|
||||
if args := c.Args(); len(args) > 0 {
|
||||
arg = args[0]
|
||||
}
|
||||
hash := resolveHash(c, arg)
|
||||
|
||||
torrents := torr.ListTorrent()
|
||||
if len(torrents) == 0 {
|
||||
return c.Send(tr(c.Sender().ID, "no_torrents"))
|
||||
}
|
||||
|
||||
if hash != "" {
|
||||
t := torr.GetTorrent(hash)
|
||||
if t == nil {
|
||||
return c.Send(tr(c.Sender().ID, "torrent_not_found") + ":\n<code>" + hash + "</code>")
|
||||
}
|
||||
log.TLogln("tg status cmd", logUser(c.Sender()), logSafeStr(t.Title, 40), hash)
|
||||
if !t.WaitInfo() {
|
||||
msg, err := c.Bot().Send(c.Sender(), tr(c.Sender().ID, "status_waiting"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
go waitForInfoAndUpdateStatus(c.Bot(), msg, hash, c.Sender().ID)
|
||||
return nil
|
||||
}
|
||||
return sendStatus(c, t)
|
||||
}
|
||||
|
||||
return sendStatusAllPage(c, 0)
|
||||
}
|
||||
|
||||
const statusAllPageSize = 5
|
||||
|
||||
func sendStatusAllPage(c tele.Context, page int) error {
|
||||
torrents := torr.ListTorrent()
|
||||
if len(torrents) == 0 {
|
||||
return c.Send(tr(c.Sender().ID, "no_torrents"))
|
||||
}
|
||||
|
||||
totalPages := (len(torrents) + statusAllPageSize - 1) / statusAllPageSize
|
||||
if page < 0 {
|
||||
page = 0
|
||||
}
|
||||
if page >= totalPages {
|
||||
page = totalPages - 1
|
||||
}
|
||||
start := page * statusAllPageSize
|
||||
end := start + statusAllPageSize
|
||||
if end > len(torrents) {
|
||||
end = len(torrents)
|
||||
}
|
||||
pageTorrents := torrents[start:end]
|
||||
|
||||
uid := c.Sender().ID
|
||||
var sb strings.Builder
|
||||
for _, t := range pageTorrents {
|
||||
txt := formatTorrentStatus(uid, t)
|
||||
if txt != "" {
|
||||
sb.WriteString(txt)
|
||||
sb.WriteString("\n\n")
|
||||
}
|
||||
}
|
||||
if sb.Len() == 0 {
|
||||
return c.Send(tr(uid, "status_no_active"))
|
||||
}
|
||||
msg := strings.TrimSuffix(sb.String(), "\n\n")
|
||||
|
||||
navRow := []tele.InlineButton{}
|
||||
if totalPages > 1 {
|
||||
if page > 0 {
|
||||
navRow = append(navRow, tele.InlineButton{Text: "◀️", Unique: "fstatusall", Data: strconv.Itoa(page - 1)})
|
||||
}
|
||||
navRow = append(navRow, tele.InlineButton{Text: strconv.Itoa(page+1) + "/" + strconv.Itoa(totalPages), Unique: "fnop", Data: ""})
|
||||
if page < totalPages-1 {
|
||||
navRow = append(navRow, tele.InlineButton{Text: "▶️", Unique: "fstatusall", Data: strconv.Itoa(page + 1)})
|
||||
}
|
||||
}
|
||||
navRow = append(navRow, tele.InlineButton{Text: "🔄", Unique: "fstatusallrefresh", Data: strconv.Itoa(page)})
|
||||
|
||||
kbd := &tele.ReplyMarkup{InlineKeyboard: [][]tele.InlineButton{navRow}}
|
||||
if err := c.Send(msg, kbd); err != nil {
|
||||
log.TLogln("tg status all send err", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func callbackStatusAllPage(c tele.Context, data string) error {
|
||||
page := 0
|
||||
if data != "" {
|
||||
if p, err := strconv.Atoi(data); err == nil {
|
||||
page = p
|
||||
}
|
||||
}
|
||||
_ = c.Respond(&tele.CallbackResponse{})
|
||||
if c.Callback().Message != nil {
|
||||
_ = c.Bot().Delete(c.Callback().Message)
|
||||
}
|
||||
return sendStatusAllPage(c, page)
|
||||
}
|
||||
|
||||
func callbackStatusAllRefresh(c tele.Context, data string) error {
|
||||
page := 0
|
||||
if data != "" {
|
||||
if p, err := strconv.Atoi(data); err == nil {
|
||||
page = p
|
||||
}
|
||||
}
|
||||
_ = c.Respond(&tele.CallbackResponse{Text: "🔄"})
|
||||
if c.Callback().Message != nil {
|
||||
_ = c.Bot().Delete(c.Callback().Message)
|
||||
}
|
||||
return sendStatusAllPage(c, page)
|
||||
}
|
||||
|
||||
func sendStatus(c tele.Context, t *torr.Torrent) error {
|
||||
uid := c.Sender().ID
|
||||
txt := formatTorrentStatus(uid, t)
|
||||
if txt == "" && t != nil {
|
||||
txt = "<b>" + escapeHtml(t.Title) + "</b>\n" + tr(uid, "status_label") + ": " + t.Stat.String()
|
||||
}
|
||||
hash := ""
|
||||
if t != nil {
|
||||
hash = t.Hash().HexString()
|
||||
}
|
||||
kbd := statusKeyboard(uid, hash, true)
|
||||
msg, err := c.Bot().Send(c.Sender(), txt, kbd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if t != nil {
|
||||
log.TLogln("tg status sent", logUserID(uid), logSafeStr(t.Title, 40), hash)
|
||||
go refreshStatusLoop(c.Bot(), msg, hash, uid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func statusKeyboard(uid int64, hash string, active bool) *tele.ReplyMarkup {
|
||||
if active {
|
||||
return &tele.ReplyMarkup{InlineKeyboard: [][]tele.InlineButton{
|
||||
{
|
||||
{Text: "🔄", Unique: "fstatusrefresh", Data: hash},
|
||||
{Text: tr(uid, "status_stop_btn"), Unique: "fstatusstop", Data: hash},
|
||||
},
|
||||
}}
|
||||
}
|
||||
return &tele.ReplyMarkup{InlineKeyboard: [][]tele.InlineButton{
|
||||
{{Text: tr(uid, "status_refresh_btn"), Unique: "fstatusrefresh", Data: hash}},
|
||||
}}
|
||||
}
|
||||
|
||||
func refreshStatusLoop(api tele.API, msg *tele.Message, hash string, uid int64) {
|
||||
const interval = 5 * time.Second
|
||||
const duration = 2 * time.Minute
|
||||
stopCh := make(chan struct{})
|
||||
statusStopChansMu.Lock()
|
||||
statusStopChans[msg.ID] = stopCh
|
||||
statusStopChansMu.Unlock()
|
||||
defer func() {
|
||||
statusStopChansMu.Lock()
|
||||
delete(statusStopChans, msg.ID)
|
||||
statusStopChansMu.Unlock()
|
||||
}()
|
||||
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
deadline := time.Now().Add(duration)
|
||||
for {
|
||||
select {
|
||||
case <-stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
if time.Now().After(deadline) {
|
||||
t := torr.GetTorrent(hash)
|
||||
txt := ""
|
||||
if t != nil {
|
||||
txt = formatTorrentStatus(uid, t)
|
||||
if txt == "" {
|
||||
txt = "<b>" + escapeHtml(t.Title) + "</b>\n" + tr(uid, "status_label") + ": " + t.Stat.String()
|
||||
}
|
||||
txt += "\n\n" + tr(uid, "status_auto_ended")
|
||||
} else {
|
||||
txt = "<code>" + hash + "</code>\n\n" + tr(uid, "status_torrent_gone")
|
||||
}
|
||||
_, _ = api.Edit(msg, txt, statusKeyboard(uid, hash, false), tele.ModeHTML)
|
||||
return
|
||||
}
|
||||
t := torr.GetTorrent(hash)
|
||||
if t == nil {
|
||||
txt := "<code>" + hash + "</code>\n\n" + tr(uid, "status_torrent_gone")
|
||||
_, _ = api.Edit(msg, txt, statusKeyboard(uid, hash, false), tele.ModeHTML)
|
||||
return
|
||||
}
|
||||
txt := formatTorrentStatus(uid, t)
|
||||
if txt == "" {
|
||||
txt = "<b>" + escapeHtml(t.Title) + "</b>\n" + tr(uid, "status_label") + ": " + t.Stat.String()
|
||||
}
|
||||
if _, err := api.Edit(msg, txt, statusKeyboard(uid, hash, true), tele.ModeHTML); err != nil {
|
||||
errStr := err.Error()
|
||||
if strings.Contains(errStr, "message is not modified") {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(errStr, "message to edit not found") {
|
||||
return
|
||||
}
|
||||
log.TLogln("tg status refresh err", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stopStatusRefresh(msgID int) {
|
||||
statusStopChansMu.Lock()
|
||||
ch := statusStopChans[msgID]
|
||||
delete(statusStopChans, msgID)
|
||||
statusStopChansMu.Unlock()
|
||||
if ch != nil {
|
||||
close(ch)
|
||||
}
|
||||
}
|
||||
|
||||
const waitForInfoTimeout = 2 * time.Minute
|
||||
|
||||
func waitForInfoAndUpdateStatus(api tele.API, msg *tele.Message, hash string, uid int64) {
|
||||
deadline := time.Now().Add(waitForInfoTimeout)
|
||||
for {
|
||||
t := torr.GetTorrent(hash)
|
||||
if t == nil {
|
||||
_, _ = api.Edit(msg, tr(uid, "torrent_not_found")+":\n<code>"+hash+"</code>", tele.ModeHTML)
|
||||
return
|
||||
}
|
||||
if t.WaitInfo() {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
_, _ = api.Edit(msg, tr(uid, "status_waiting")+"\n\n"+tr(uid, "status_auto_ended"), tele.ModeHTML)
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
t := torr.GetTorrent(hash)
|
||||
if t == nil {
|
||||
_, _ = api.Edit(msg, tr(uid, "torrent_not_found")+":\n<code>"+hash+"</code>", tele.ModeHTML)
|
||||
return
|
||||
}
|
||||
txt := formatTorrentStatus(uid, t)
|
||||
if txt == "" {
|
||||
txt = "<b>" + escapeHtml(t.Title) + "</b>\n" + tr(uid, "status_label") + ": " + t.Stat.String()
|
||||
}
|
||||
if _, err := api.Edit(msg, txt, statusKeyboard(uid, hash, true), tele.ModeHTML); err != nil {
|
||||
log.TLogln("tg status wait edit err", err)
|
||||
return
|
||||
}
|
||||
go refreshStatusLoop(api, msg, hash, uid)
|
||||
}
|
||||
|
||||
func callbackStatusRefresh(c tele.Context, hash string) error {
|
||||
uid := c.Sender().ID
|
||||
if hash == "" {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "callback_unknown")})
|
||||
}
|
||||
t := torr.GetTorrent(hash)
|
||||
if t != nil {
|
||||
log.TLogln("tg status refresh", logUserID(uid), logSafeStr(t.Title, 40), hash)
|
||||
}
|
||||
if t == nil {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "torrent_not_found")})
|
||||
}
|
||||
if c.Callback().Message != nil {
|
||||
stopStatusRefresh(c.Callback().Message.ID)
|
||||
_ = c.Bot().Delete(c.Callback().Message)
|
||||
}
|
||||
_ = c.Respond(&tele.CallbackResponse{})
|
||||
return sendStatus(c, t)
|
||||
}
|
||||
|
||||
func callbackStatusStop(c tele.Context, hash string) error {
|
||||
uid := c.Sender().ID
|
||||
if hash != "" {
|
||||
if t := torr.GetTorrent(hash); t != nil {
|
||||
log.TLogln("tg status stop", logUserID(uid), logSafeStr(t.Title, 40), hash)
|
||||
}
|
||||
}
|
||||
if c.Callback().Message != nil {
|
||||
stopStatusRefresh(c.Callback().Message.ID)
|
||||
if hash != "" {
|
||||
msg := c.Callback().Message
|
||||
t := torr.GetTorrent(hash)
|
||||
txt := ""
|
||||
if t != nil {
|
||||
txt = formatTorrentStatus(uid, t)
|
||||
if txt == "" {
|
||||
txt = "<b>" + escapeHtml(t.Title) + "</b>\n" + tr(uid, "status_label") + ": " + t.Stat.String()
|
||||
}
|
||||
} else {
|
||||
txt = "<code>" + hash + "</code>"
|
||||
}
|
||||
txt += "\n\n" + tr(uid, "status_stopped")
|
||||
_, _ = c.Bot().Edit(msg, txt, statusKeyboard(uid, hash, false), tele.ModeHTML)
|
||||
}
|
||||
}
|
||||
return c.Respond(&tele.CallbackResponse{Text: "🛑"})
|
||||
}
|
||||
|
||||
func callbackStatus(c tele.Context, hash string) error {
|
||||
uid := c.Sender().ID
|
||||
t := torr.GetTorrent(hash)
|
||||
if t == nil {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(uid, "torrent_not_found")})
|
||||
}
|
||||
_ = c.Respond(&tele.CallbackResponse{})
|
||||
if !t.WaitInfo() {
|
||||
msg, err := c.Bot().Send(c.Sender(), tr(uid, "status_waiting"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
go waitForInfoAndUpdateStatus(c.Bot(), msg, hash, uid)
|
||||
return nil
|
||||
}
|
||||
return sendStatus(c, t)
|
||||
}
|
||||
|
||||
func formatTorrentStatus(uid int64, t *torr.Torrent) string {
|
||||
if t == nil {
|
||||
return ""
|
||||
}
|
||||
st := t.Status()
|
||||
if st == nil {
|
||||
return "<b>" + escapeHtml(t.Title) + "</b>\n" + tr(uid, "status_label") + ": " + t.Stat.String()
|
||||
}
|
||||
|
||||
// For streaming: size + cache info (progress is misleading — we stream, not download sequentially)
|
||||
sizeLine := fmt.Sprintf("%s: %s", tr(uid, "status_size"), humanize.IBytes(uint64(st.TorrentSize)))
|
||||
if cache := t.CacheState(); cache != nil {
|
||||
sizeLine += fmt.Sprintf(" | %s: %s / %s · %d %s",
|
||||
tr(uid, "status_cache"),
|
||||
humanize.IBytes(uint64(cache.Filled)),
|
||||
humanize.IBytes(uint64(cache.Capacity)),
|
||||
len(cache.Readers),
|
||||
tr(uid, "status_streams"))
|
||||
}
|
||||
|
||||
txt := fmt.Sprintf("<b>%s</b>\n", escapeHtml(st.Title))
|
||||
txt += fmt.Sprintf("%s: %s\n", tr(uid, "status_label"), st.StatString)
|
||||
txt += sizeLine + "\n"
|
||||
txt += fmt.Sprintf("%s: %s | %s: %s\n",
|
||||
tr(uid, "status_download"), humanizeSpeedBits(uid, st.DownloadSpeed),
|
||||
tr(uid, "status_upload"), humanizeSpeedBits(uid, st.UploadSpeed))
|
||||
txt += fmt.Sprintf("%s: %d %s, %d %s\n",
|
||||
tr(uid, "stats_peers"), st.ActivePeers, tr(uid, "stats_active"),
|
||||
st.ConnectedSeeders, tr(uid, "stats_seeds"))
|
||||
txt += fmt.Sprintf("<code>%s</code>", st.Hash)
|
||||
return txt
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
tele "gopkg.in/telebot.v4"
|
||||
up "server/tgbot/upload"
|
||||
)
|
||||
|
||||
func upload(c tele.Context) error {
|
||||
args := c.Args()
|
||||
if len(args) < 3 {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "callback_unknown")})
|
||||
}
|
||||
hash := args[1]
|
||||
if !isHash(hash) {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "callback_unknown")})
|
||||
}
|
||||
id, err := strconv.Atoi(args[2])
|
||||
if err != nil {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "callback_unknown")})
|
||||
}
|
||||
up.AddRange(c, hash, id, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func uploadall(c tele.Context) error {
|
||||
args := c.Args()
|
||||
if len(args) < 2 {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "callback_unknown")})
|
||||
}
|
||||
hash := ""
|
||||
if len(args) >= 3 && isHash(args[2]) {
|
||||
hash = args[2]
|
||||
} else {
|
||||
hash = strings.TrimPrefix(args[1], "all|")
|
||||
}
|
||||
if !isHash(hash) {
|
||||
return c.Respond(&tele.CallbackResponse{Text: tr(c.Sender().ID, "callback_unknown")})
|
||||
}
|
||||
up.AddRange(c, hash, 1, -1)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
package upload
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
tele "gopkg.in/telebot.v4"
|
||||
|
||||
"server/log"
|
||||
"server/torr"
|
||||
"server/torr/state"
|
||||
)
|
||||
|
||||
// TrFunc is set by tgbot for localization (avoids circular import)
|
||||
var TrFunc func(int64, string) string
|
||||
|
||||
// EscapeFunc is set by tgbot for HTML escaping (avoids circular import)
|
||||
var EscapeFunc func(string) string
|
||||
|
||||
func tr(uid int64, key string) string {
|
||||
if TrFunc != nil {
|
||||
return TrFunc(uid, key)
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
func escapeHtml(s string) string {
|
||||
if EscapeFunc != nil {
|
||||
return EscapeFunc(s)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
type Worker struct {
|
||||
id int
|
||||
c tele.Context
|
||||
msg *tele.Message
|
||||
torrentHash string
|
||||
isCancelled bool
|
||||
from int
|
||||
to int
|
||||
ti *state.TorrentStatus
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
queue []*Worker
|
||||
working map[int]*Worker
|
||||
ids int
|
||||
wrkSync sync.Mutex
|
||||
queueLock sync.Mutex
|
||||
}
|
||||
|
||||
func (m *Manager) Start() {
|
||||
m.working = make(map[int]*Worker)
|
||||
go m.work()
|
||||
}
|
||||
|
||||
func (m *Manager) AddRange(c tele.Context, hash string, from, to int) {
|
||||
m.queueLock.Lock()
|
||||
defer m.queueLock.Unlock()
|
||||
|
||||
if len(m.queue) > 50 {
|
||||
c.Bot().Send(c.Recipient(), fmt.Sprintf(tr(c.Sender().ID, "upload_queue_full"), len(m.queue)))
|
||||
return
|
||||
}
|
||||
|
||||
m.ids++
|
||||
if m.ids > math.MaxInt {
|
||||
m.ids = 0
|
||||
}
|
||||
|
||||
var msg *tele.Message
|
||||
var err error
|
||||
|
||||
for i := 0; i < 20; i++ {
|
||||
msg, err = c.Bot().Send(c.Recipient(), fmt.Sprintf(tr(c.Sender().ID, "upload_connecting"), hash))
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
log.TLogln("tg upload retry", i+1, "/", 20)
|
||||
if i < 19 {
|
||||
backoff := time.Duration(1<<uint(i)) * 100 * time.Millisecond
|
||||
if backoff > 5*time.Second {
|
||||
backoff = 5 * time.Second
|
||||
}
|
||||
time.Sleep(backoff)
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.TLogln("tg upload send err", err)
|
||||
return
|
||||
}
|
||||
|
||||
t := torr.GetTorrent(hash)
|
||||
if t == nil {
|
||||
c.Bot().Edit(msg, tr(c.Sender().ID, "torrent_not_found")+":\n<code>"+hash+"</code>")
|
||||
return
|
||||
}
|
||||
t.WaitInfo()
|
||||
for t.Status().Stat != state.TorrentWorking {
|
||||
time.Sleep(time.Second)
|
||||
t = torr.GetTorrent(hash)
|
||||
if t == nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
ti := t.Status()
|
||||
|
||||
if from == 1 && to == -1 {
|
||||
to = len(ti.FileStats)
|
||||
}
|
||||
if from < 1 {
|
||||
from = 1
|
||||
}
|
||||
if to > len(ti.FileStats) {
|
||||
to = len(ti.FileStats)
|
||||
}
|
||||
if from > to {
|
||||
from, to = to, from
|
||||
}
|
||||
if to > len(ti.FileStats) {
|
||||
to = len(ti.FileStats)
|
||||
}
|
||||
|
||||
w := &Worker{
|
||||
id: m.ids,
|
||||
c: c,
|
||||
torrentHash: hash,
|
||||
msg: msg,
|
||||
ti: ti,
|
||||
from: from,
|
||||
to: to,
|
||||
}
|
||||
|
||||
m.queue = append(m.queue, w)
|
||||
}
|
||||
|
||||
func (m *Manager) Cancel(id int) {
|
||||
m.queueLock.Lock()
|
||||
defer m.queueLock.Unlock()
|
||||
for i, w := range m.queue {
|
||||
if w.id == id {
|
||||
w.isCancelled = true
|
||||
w.c.Bot().Delete(w.msg)
|
||||
m.queue = append(m.queue[:i], m.queue[i+1:]...)
|
||||
return
|
||||
}
|
||||
}
|
||||
if wrk, ok := m.working[id]; ok {
|
||||
wrk.isCancelled = true
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) work() {
|
||||
for {
|
||||
m.queueLock.Lock()
|
||||
if len(m.working) > 0 {
|
||||
m.queueLock.Unlock()
|
||||
m.sendQueueStatus()
|
||||
time.Sleep(time.Second)
|
||||
continue
|
||||
}
|
||||
if len(m.queue) == 0 {
|
||||
m.queueLock.Unlock()
|
||||
time.Sleep(time.Second)
|
||||
continue
|
||||
}
|
||||
wrk := m.queue[0]
|
||||
m.queue = m.queue[1:]
|
||||
m.working[wrk.id] = wrk
|
||||
m.queueLock.Unlock()
|
||||
|
||||
m.sendQueueStatus()
|
||||
|
||||
loading(wrk)
|
||||
|
||||
m.queueLock.Lock()
|
||||
delete(m.working, wrk.id)
|
||||
m.queueLock.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) sendQueueStatus() {
|
||||
m.queueLock.Lock()
|
||||
defer m.queueLock.Unlock()
|
||||
for i, wrk := range m.queue {
|
||||
if wrk.msg == nil || wrk.c.Sender() == nil {
|
||||
continue
|
||||
}
|
||||
torrKbd := &tele.ReplyMarkup{}
|
||||
torrKbd.Inline([]tele.Row{torrKbd.Row(torrKbd.Data(tr(wrk.c.Sender().ID, "upload_cancel"), "cancel", strconv.Itoa(wrk.id)))}...)
|
||||
|
||||
msg := fmt.Sprintf(tr(wrk.c.Sender().ID, "upload_queue_pos"), i+1)
|
||||
|
||||
wrk.c.Bot().Edit(wrk.msg, msg, torrKbd)
|
||||
}
|
||||
}
|
||||
|
||||
func loading(wrk *Worker) {
|
||||
iserr := false
|
||||
|
||||
t := torr.GetTorrent(wrk.torrentHash)
|
||||
if t == nil {
|
||||
wrk.c.Bot().Edit(wrk.msg, tr(wrk.c.Sender().ID, "torrent_not_found")+":\n<code>"+wrk.torrentHash+"</code>")
|
||||
return
|
||||
}
|
||||
t.WaitInfo()
|
||||
for t.Status().Stat != state.TorrentWorking {
|
||||
time.Sleep(time.Second)
|
||||
t = torr.GetTorrent(wrk.torrentHash)
|
||||
if t == nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
wrk.ti = t.Status()
|
||||
|
||||
for i := wrk.from - 1; i <= wrk.to-1; i++ {
|
||||
file := wrk.ti.FileStats[i]
|
||||
if wrk.isCancelled {
|
||||
return
|
||||
}
|
||||
|
||||
err := uploadFile(wrk, file, i+1, len(wrk.ti.FileStats))
|
||||
if err != nil {
|
||||
errstr := fmt.Sprintf(tr(wrk.c.Sender().ID, "upload_error"), err)
|
||||
wrk.c.Bot().Edit(wrk.msg, errstr)
|
||||
iserr = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !iserr {
|
||||
wrk.c.Bot().Delete(wrk.msg)
|
||||
}
|
||||
}
|
||||
|
||||
func uploadFile(wrk *Worker, file *state.TorrentFileStat, fi, fc int) error {
|
||||
caption := filepath.Base(file.Path)
|
||||
torrFile, err := NewTorrFile(wrk, file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var wa sync.WaitGroup
|
||||
wa.Add(1)
|
||||
complete := false
|
||||
go func() {
|
||||
for !complete {
|
||||
updateLoadStatus(wrk, torrFile, fi, fc)
|
||||
time.Sleep(1 * time.Second)
|
||||
}
|
||||
wa.Done()
|
||||
}()
|
||||
|
||||
d := &tele.Document{}
|
||||
d.FileName = file.Path
|
||||
d.Caption = caption
|
||||
d.File.FileReader = torrFile
|
||||
|
||||
for i := 0; i < 20; i++ {
|
||||
err = wrk.c.Send(d)
|
||||
if err == nil || errors.Is(err, ERR_STOPPED) {
|
||||
break
|
||||
}
|
||||
log.TLogln("tg upload retry", i+1, "/", 20)
|
||||
if i < 19 {
|
||||
backoff := time.Duration(1<<uint(i)) * 100 * time.Millisecond
|
||||
if backoff > 5*time.Second {
|
||||
backoff = 5 * time.Second
|
||||
}
|
||||
time.Sleep(backoff)
|
||||
}
|
||||
}
|
||||
|
||||
complete = true
|
||||
wa.Wait()
|
||||
torrFile.Close()
|
||||
if errors.Is(err, ERR_STOPPED) {
|
||||
err = nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package upload
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
tele "gopkg.in/telebot.v4"
|
||||
"server/torr"
|
||||
)
|
||||
|
||||
type DLQueue struct {
|
||||
id int
|
||||
c tele.Context
|
||||
hash string
|
||||
fileID string
|
||||
fileName string
|
||||
updateMsg *tele.Message
|
||||
}
|
||||
|
||||
var manager = &Manager{}
|
||||
|
||||
func Start() {
|
||||
manager.Start()
|
||||
}
|
||||
|
||||
func ShowQueue(c tele.Context) error {
|
||||
msg := ""
|
||||
manager.queueLock.Lock()
|
||||
defer manager.queueLock.Unlock()
|
||||
if len(manager.queue) == 0 && len(manager.working) == 0 {
|
||||
return c.Send(tr(c.Sender().ID, "queue_empty"))
|
||||
}
|
||||
if len(manager.working) > 0 {
|
||||
msg += tr(c.Sender().ID, "upload_working") + ":\n"
|
||||
i := 0
|
||||
for _, dlQueue := range manager.working {
|
||||
s := "#" + strconv.Itoa(i+1) + ": <code>" + dlQueue.torrentHash + "</code>\n"
|
||||
if len(msg+s) > 1024 {
|
||||
c.Send(msg)
|
||||
msg = ""
|
||||
}
|
||||
msg += s
|
||||
i++
|
||||
}
|
||||
if len(msg) > 0 {
|
||||
c.Send(msg)
|
||||
msg = ""
|
||||
}
|
||||
}
|
||||
if len(manager.queue) > 0 {
|
||||
msg = tr(c.Sender().ID, "upload_in_queue") + ":\n"
|
||||
for i, dlQueue := range manager.queue {
|
||||
s := "#" + strconv.Itoa(i+1) + ": <code>" + dlQueue.torrentHash + "</code>\n"
|
||||
if len(msg+s) > 1024 {
|
||||
c.Send(msg)
|
||||
msg = ""
|
||||
}
|
||||
msg += s
|
||||
}
|
||||
if len(msg) > 0 {
|
||||
c.Send(msg)
|
||||
msg = ""
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func AddRange(c tele.Context, hash string, from, to int) {
|
||||
manager.AddRange(c, hash, from, to)
|
||||
}
|
||||
|
||||
func Cancel(id int) {
|
||||
manager.Cancel(id)
|
||||
}
|
||||
|
||||
func updateLoadStatus(wrk *Worker, file *TorrFile, fi, fc int) {
|
||||
if wrk.msg == nil {
|
||||
return
|
||||
}
|
||||
t := torr.GetTorrent(wrk.torrentHash)
|
||||
if t == nil {
|
||||
return
|
||||
}
|
||||
ti := t.Status()
|
||||
if wrk.isCancelled {
|
||||
wrk.c.Bot().Edit(wrk.msg, tr(wrk.c.Sender().ID, "upload_stopping"))
|
||||
} else {
|
||||
wrk.c.Send(tele.UploadingVideo)
|
||||
if ti.DownloadSpeed == 0 {
|
||||
ti.DownloadSpeed = 1.0
|
||||
}
|
||||
wait := time.Duration(float64(file.Remaining())/ti.DownloadSpeed) * time.Second
|
||||
speed := humanize.IBytes(uint64(ti.DownloadSpeed)) + "/sec"
|
||||
peers := fmt.Sprintf("%v · %v/%v", ti.ConnectedSeeders, ti.ActivePeers, ti.TotalPeers)
|
||||
prc := fmt.Sprintf("%.2f%% %v / %v", float64(file.offset)*100.0/float64(file.size), humanize.IBytes(uint64(file.offset)), humanize.IBytes(uint64(file.size)))
|
||||
|
||||
name := file.name
|
||||
if name == ti.Title {
|
||||
name = ""
|
||||
}
|
||||
|
||||
uid := wrk.c.Sender().ID
|
||||
msg := tr(uid, "upload_title") + ":\n" +
|
||||
"<b>" + escapeHtml(ti.Title) + "</b>\n"
|
||||
if name != "" {
|
||||
msg += "<i>" + escapeHtml(name) + "</i>\n"
|
||||
}
|
||||
msg += "<b>" + tr(uid, "upload_hash") + ":</b> <code>" + file.hash + "</code>\n"
|
||||
if file.offset < file.size {
|
||||
msg += "<b>" + tr(uid, "upload_speed") + ": </b>" + speed + "\n" +
|
||||
"<b>" + tr(uid, "upload_remaining") + ": </b>" + wait.String() + "\n" +
|
||||
"<b>" + tr(uid, "upload_peers") + ": </b>" + peers + "\n" +
|
||||
"<b>" + tr(uid, "upload_progress") + ": </b>" + prc
|
||||
}
|
||||
if fc > 1 {
|
||||
msg += "\n<b>" + tr(uid, "upload_files") + ": </b>" + strconv.Itoa(fi) + "/" + strconv.Itoa(fc)
|
||||
}
|
||||
if file.offset >= file.size {
|
||||
msg += "\n<b>" + tr(uid, "upload_finishing") + "</b>"
|
||||
wrk.c.Bot().Edit(wrk.msg, msg)
|
||||
return
|
||||
}
|
||||
|
||||
torrKbd := &tele.ReplyMarkup{}
|
||||
torrKbd.Inline([]tele.Row{torrKbd.Row(torrKbd.Data(tr(wrk.c.Sender().ID, "upload_cancel"), "cancel", strconv.Itoa(wrk.id)))}...)
|
||||
wrk.c.Bot().Edit(wrk.msg, msg, torrKbd)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package upload
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/anacrolix/torrent"
|
||||
|
||||
sets "server/settings"
|
||||
"server/log"
|
||||
"server/tgbot/config"
|
||||
"server/torr"
|
||||
"server/torr/state"
|
||||
"server/torr/storage/torrstor"
|
||||
)
|
||||
|
||||
var ERR_STOPPED = errors.New("stopped")
|
||||
|
||||
type TorrFile struct {
|
||||
hash string
|
||||
name string
|
||||
wrk *Worker
|
||||
offset int64
|
||||
size int64
|
||||
id int
|
||||
|
||||
reader *torrstor.Reader
|
||||
}
|
||||
|
||||
func NewTorrFile(wrk *Worker, stFile *state.TorrentFileStat) (*TorrFile, error) {
|
||||
uid := int64(0)
|
||||
if wrk.c != nil && wrk.c.Sender() != nil {
|
||||
uid = wrk.c.Sender().ID
|
||||
}
|
||||
if config.Cfg != nil && config.Cfg.HostTG != "" && stFile.Length > 2*1024*1024*1024 {
|
||||
return nil, errors.New(tr(uid, "upload_file_too_large_2gb"))
|
||||
}
|
||||
if (config.Cfg == nil || config.Cfg.HostTG == "") && stFile.Length > 50*1024*1024 {
|
||||
return nil, errors.New(tr(uid, "upload_file_too_large_50mb"))
|
||||
}
|
||||
|
||||
tf := new(TorrFile)
|
||||
tf.hash = wrk.torrentHash
|
||||
tf.name = filepath.Base(stFile.Path)
|
||||
tf.wrk = wrk
|
||||
tf.size = stFile.Length
|
||||
|
||||
t := torr.GetTorrent(wrk.torrentHash)
|
||||
t.WaitInfo()
|
||||
|
||||
files := t.Files()
|
||||
var file *torrent.File
|
||||
for _, tfile := range files {
|
||||
if tfile.Path() == stFile.Path {
|
||||
file = tfile
|
||||
break
|
||||
}
|
||||
}
|
||||
if file == nil {
|
||||
return nil, fmt.Errorf("file with id %v not found", stFile.Id)
|
||||
}
|
||||
if int64(sets.MaxSize) > 0 && file.Length() > int64(sets.MaxSize) {
|
||||
log.TLogln("tg upload err size", file.DisplayPath(), "max", sets.MaxSize)
|
||||
return nil, fmt.Errorf("file size exceeded max allowed %d bytes", sets.MaxSize)
|
||||
}
|
||||
|
||||
reader := t.NewReader(file)
|
||||
if reader == nil {
|
||||
return nil, errors.New("cannot create torrent reader")
|
||||
}
|
||||
if sets.BTsets != nil && sets.BTsets.ResponsiveMode {
|
||||
reader.SetResponsive()
|
||||
}
|
||||
tf.reader = reader
|
||||
|
||||
return tf, nil
|
||||
}
|
||||
|
||||
func (t *TorrFile) Read(p []byte) (n int, err error) {
|
||||
if t.wrk.isCancelled {
|
||||
return 0, ERR_STOPPED
|
||||
}
|
||||
n, err = t.reader.Read(p)
|
||||
t.offset += int64(n)
|
||||
return
|
||||
}
|
||||
|
||||
func (t *TorrFile) Remaining() int64 {
|
||||
return t.size - t.offset
|
||||
}
|
||||
|
||||
func (t *TorrFile) Close() {
|
||||
if t.reader != nil {
|
||||
t.reader.Close()
|
||||
t.reader = nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
tele "gopkg.in/telebot.v4"
|
||||
|
||||
"server/settings"
|
||||
"server/tgbot/config"
|
||||
"server/web"
|
||||
)
|
||||
|
||||
func chatMsgKey(chatID int64, msgID int) string {
|
||||
return fmt.Sprintf("%d_%d", chatID, msgID)
|
||||
}
|
||||
|
||||
// escapeHtml escapes <, >, &, " for Telegram HTML parse mode to prevent "Unsupported start tag" errors
|
||||
func escapeHtml(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
s = strings.ReplaceAll(s, "\"", """)
|
||||
return s
|
||||
}
|
||||
|
||||
// logSafeStr truncates by runes, strips emojis/symbols for clean laconic logs
|
||||
func logSafeStr(s string, maxRunes int) string {
|
||||
var b strings.Builder
|
||||
n := 0
|
||||
lastSpace := true
|
||||
for _, r := range s {
|
||||
if n >= maxRunes {
|
||||
break
|
||||
}
|
||||
switch {
|
||||
case r == '\n' || r == '\r' || r == '\t':
|
||||
if !lastSpace {
|
||||
b.WriteRune(' ')
|
||||
n++
|
||||
lastSpace = true
|
||||
}
|
||||
case r < 32 || r == 127:
|
||||
case logIsEmojiOrSymbol(r):
|
||||
case unicode.IsLetter(r) || unicode.IsNumber(r) || r == '/' || r == '-' || r == '_' || r == '|' || r == ':' || r == '.' || r == ',' || r == ' ' || r == '?' || r == '!' || r == '@':
|
||||
b.WriteRune(r)
|
||||
n++
|
||||
lastSpace = (r == ' ')
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
n++
|
||||
lastSpace = false
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(b.String())
|
||||
}
|
||||
|
||||
func logIsEmojiOrSymbol(r rune) bool {
|
||||
if unicode.IsSymbol(r) {
|
||||
return true
|
||||
}
|
||||
u := uint32(r)
|
||||
return (u >= 0x1F300 && u <= 0x1F9FF) || (u >= 0x2600 && u <= 0x26FF) ||
|
||||
(u >= 0x2700 && u <= 0x27BF) || (u >= 0x1F600 && u <= 0x1F64F) ||
|
||||
(u >= 0x1F680 && u <= 0x1F6FF) || (u >= 0x1F1E0 && u <= 0x1F1FF) ||
|
||||
(u >= 0xFE00 && u <= 0xFE0F) || (u >= 0x1F000 && u <= 0x1F02F)
|
||||
}
|
||||
|
||||
// logUser formats uid and optional username for logs
|
||||
func logUser(u *tele.User) string {
|
||||
if u == nil {
|
||||
return "uid=?"
|
||||
}
|
||||
return logUserID(u.ID) + logUsername(u.Username)
|
||||
}
|
||||
|
||||
// logUserID formats uid for logs when User is not available
|
||||
func logUserID(uid int64) string {
|
||||
return "uid=" + strconv.FormatInt(uid, 10)
|
||||
}
|
||||
|
||||
func logUsername(username string) string {
|
||||
if username == "" {
|
||||
return ""
|
||||
}
|
||||
return " @" + username
|
||||
}
|
||||
|
||||
// logHashOrTruncate returns hash for logging if link is hash or magnet with btih, else truncated link
|
||||
func logHashOrTruncate(link string) string {
|
||||
if isHash(link) {
|
||||
return link
|
||||
}
|
||||
if idx := strings.Index(link, "btih:"); idx >= 0 && idx+45 <= len(link) {
|
||||
if h := link[idx+5 : idx+45]; isHash(h) {
|
||||
return h
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(link), "torrs://") && len(link) >= 48 {
|
||||
if h := link[8:48]; isHash(h) {
|
||||
return h
|
||||
}
|
||||
}
|
||||
if len(link) > 64 {
|
||||
return link[:64] + "..."
|
||||
}
|
||||
return link
|
||||
}
|
||||
|
||||
// getHost returns the base URL for stream/play links (e.g. http://192.168.1.1:8090)
|
||||
func getHost() string {
|
||||
host := config.Cfg.HostWeb
|
||||
if host == "" {
|
||||
host = settings.PubIPv4
|
||||
if host == "" {
|
||||
ips := web.GetLocalIps()
|
||||
if len(ips) == 0 {
|
||||
host = "127.0.0.1"
|
||||
} else {
|
||||
host = ips[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
if !strings.Contains(host, ":") {
|
||||
if settings.Ssl {
|
||||
host += ":" + settings.SslPort
|
||||
} else {
|
||||
host += ":" + settings.Port
|
||||
}
|
||||
}
|
||||
if !strings.HasPrefix(host, "http") {
|
||||
if settings.Ssl {
|
||||
host = "https://" + host
|
||||
} else {
|
||||
host = "http://" + host
|
||||
}
|
||||
}
|
||||
return host
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
tele "gopkg.in/telebot.v4"
|
||||
sets "server/settings"
|
||||
)
|
||||
|
||||
func cmdViewed(c tele.Context) error {
|
||||
args := c.Args()
|
||||
if len(args) == 0 {
|
||||
return c.Send(tr(c.Sender().ID, "viewed_usage"))
|
||||
}
|
||||
|
||||
action := strings.ToLower(args[0])
|
||||
if action == "set" || action == "rem" {
|
||||
if len(args) < 2 {
|
||||
return c.Send(fmt.Sprintf(tr(c.Sender().ID, "viewed_usage_action"), action))
|
||||
}
|
||||
hash := resolveHash(c, args[1])
|
||||
if hash == "" {
|
||||
return c.Send(tr(c.Sender().ID, "invalid_hash"))
|
||||
}
|
||||
if action == "set" {
|
||||
if len(args) < 3 {
|
||||
return c.Send(tr(c.Sender().ID, "viewed_usage_set"))
|
||||
}
|
||||
index, err := strconv.Atoi(args[2])
|
||||
if err != nil || index < 1 {
|
||||
return c.Send(tr(c.Sender().ID, "viewed_file_index"))
|
||||
}
|
||||
sets.SetViewed(&sets.Viewed{Hash: hash, FileIndex: index})
|
||||
return c.Send(fmt.Sprintf(tr(c.Sender().ID, "viewed_marked"), hash, index))
|
||||
}
|
||||
index := -1
|
||||
if len(args) >= 3 {
|
||||
if i, err := strconv.Atoi(args[2]); err == nil && i >= 1 {
|
||||
index = i
|
||||
}
|
||||
}
|
||||
sets.RemViewed(&sets.Viewed{Hash: hash, FileIndex: index})
|
||||
if index >= 1 {
|
||||
return c.Send(fmt.Sprintf(tr(c.Sender().ID, "viewed_unmarked"), hash, index))
|
||||
}
|
||||
return c.Send(fmt.Sprintf(tr(c.Sender().ID, "viewed_cleared"), hash))
|
||||
}
|
||||
|
||||
hash := resolveHash(c, args[0])
|
||||
if hash == "" {
|
||||
return c.Send(tr(c.Sender().ID, "viewed_usage"))
|
||||
}
|
||||
|
||||
list := sets.ListViewed(hash)
|
||||
if len(list) == 0 {
|
||||
return c.Send(tr(c.Sender().ID, "viewed_empty"))
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("<b>" + tr(c.Sender().ID, "viewed_list") + "</b>\n\n")
|
||||
fmt.Fprintf(&sb, "<code>%s</code>\n\n", hash)
|
||||
for _, v := range list {
|
||||
fmt.Fprintf(&sb, " #%d\n", v.FileIndex)
|
||||
}
|
||||
return c.Send(sb.String())
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
package torr
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/anacrolix/torrent"
|
||||
"github.com/anacrolix/torrent/metainfo"
|
||||
|
||||
"server/log"
|
||||
sets "server/settings"
|
||||
)
|
||||
|
||||
var bts *BTServer
|
||||
|
||||
func InitApiHelper(bt *BTServer) {
|
||||
bts = bt
|
||||
}
|
||||
|
||||
func LoadTorrent(tor *Torrent) *Torrent {
|
||||
if tor.TorrentSpec == nil {
|
||||
return nil
|
||||
}
|
||||
tr, err := NewTorrent(tor.TorrentSpec, bts)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if !tr.WaitInfo() {
|
||||
return nil
|
||||
}
|
||||
tr.Title = tor.Title
|
||||
tr.Poster = tor.Poster
|
||||
tr.Data = tor.Data
|
||||
return tr
|
||||
}
|
||||
|
||||
func AddTorrent(spec *torrent.TorrentSpec, title, poster string, data string, category string) (*Torrent, error) {
|
||||
torr, err := NewTorrent(spec, bts)
|
||||
if err != nil {
|
||||
log.TLogln("error add torrent:", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
torDB := GetTorrentDB(spec.InfoHash)
|
||||
|
||||
if torr.Title == "" {
|
||||
torr.Title = title
|
||||
if title == "" && torDB != nil {
|
||||
torr.Title = torDB.Title
|
||||
}
|
||||
if torr.Title == "" && torr.Torrent != nil && torr.Torrent.Info() != nil {
|
||||
torr.Title = torr.Info().Name
|
||||
}
|
||||
}
|
||||
|
||||
if torr.Category == "" {
|
||||
torr.Category = category
|
||||
if torr.Category == "" && torDB != nil {
|
||||
torr.Category = torDB.Category
|
||||
}
|
||||
}
|
||||
|
||||
if torr.Poster == "" {
|
||||
torr.Poster = poster
|
||||
if torr.Poster == "" && torDB != nil {
|
||||
torr.Poster = torDB.Poster
|
||||
}
|
||||
}
|
||||
|
||||
if torr.Data == "" {
|
||||
torr.Data = data
|
||||
if torr.Data == "" && torDB != nil {
|
||||
torr.Data = torDB.Data
|
||||
}
|
||||
}
|
||||
|
||||
return torr, nil
|
||||
}
|
||||
|
||||
func SaveTorrentToDB(torr *Torrent) {
|
||||
log.TLogln("save to db:", torr.Hash())
|
||||
AddTorrentDB(torr)
|
||||
}
|
||||
|
||||
func GetTorrent(hashHex string) *Torrent {
|
||||
hash := metainfo.NewHashFromHex(hashHex)
|
||||
timeout := time.Second * time.Duration(sets.BTsets.TorrentDisconnectTimeout)
|
||||
if timeout > time.Minute {
|
||||
timeout = time.Minute
|
||||
}
|
||||
tor := bts.GetTorrent(hash)
|
||||
if tor != nil {
|
||||
tor.AddExpiredTime(timeout)
|
||||
return tor
|
||||
}
|
||||
|
||||
tr := GetTorrentDB(hash)
|
||||
if tr != nil {
|
||||
tor = tr
|
||||
go func() {
|
||||
log.TLogln("New torrent", tor.Hash())
|
||||
tr, _ := NewTorrent(tor.TorrentSpec, bts)
|
||||
if tr != nil {
|
||||
tr.Title = tor.Title
|
||||
tr.Poster = tor.Poster
|
||||
tr.Data = tor.Data
|
||||
tr.Size = tor.Size
|
||||
tr.Timestamp = tor.Timestamp
|
||||
tr.Category = tor.Category
|
||||
tr.GotInfo()
|
||||
}
|
||||
}()
|
||||
}
|
||||
return tor
|
||||
}
|
||||
|
||||
func SetTorrent(hashHex, title, poster, category string, data string) *Torrent {
|
||||
hash := metainfo.NewHashFromHex(hashHex)
|
||||
torr := bts.GetTorrent(hash)
|
||||
torrDb := GetTorrentDB(hash)
|
||||
|
||||
if title == "" && torr == nil && torrDb != nil {
|
||||
torr = GetTorrent(hashHex)
|
||||
torr.GotInfo()
|
||||
if torr.Torrent != nil && torr.Torrent.Info() != nil {
|
||||
title = torr.Info().Name
|
||||
}
|
||||
}
|
||||
|
||||
if torr != nil {
|
||||
if title == "" && torr.Torrent != nil && torr.Torrent.Info() != nil {
|
||||
title = torr.Info().Name
|
||||
}
|
||||
torr.Title = title
|
||||
torr.Poster = poster
|
||||
torr.Category = category
|
||||
if data != "" {
|
||||
torr.Data = data
|
||||
}
|
||||
}
|
||||
// update torrent data in DB
|
||||
if torrDb != nil {
|
||||
torrDb.Title = title
|
||||
torrDb.Poster = poster
|
||||
torrDb.Category = category
|
||||
if data != "" {
|
||||
torrDb.Data = data
|
||||
}
|
||||
AddTorrentDB(torrDb)
|
||||
}
|
||||
if torr != nil {
|
||||
return torr
|
||||
} else {
|
||||
return torrDb
|
||||
}
|
||||
}
|
||||
|
||||
func RemTorrent(hashHex string) {
|
||||
if sets.ReadOnly {
|
||||
log.TLogln("API RemTorrent: Read-only DB mode!", hashHex)
|
||||
return
|
||||
}
|
||||
hash := metainfo.NewHashFromHex(hashHex)
|
||||
if bts.RemoveTorrent(hash) {
|
||||
if sets.BTsets.UseDisk && hashHex != "" && hashHex != "/" {
|
||||
name := filepath.Join(sets.BTsets.TorrentsSavePath, hashHex)
|
||||
ff, _ := os.ReadDir(name)
|
||||
for _, f := range ff {
|
||||
os.Remove(filepath.Join(name, f.Name()))
|
||||
}
|
||||
err := os.Remove(name)
|
||||
if err != nil {
|
||||
log.TLogln("Error remove cache:", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
RemTorrentDB(hash)
|
||||
}
|
||||
|
||||
func ListTorrent() []*Torrent {
|
||||
btlist := bts.ListTorrents()
|
||||
dblist := ListTorrentsDB()
|
||||
|
||||
for hash, t := range dblist {
|
||||
if _, ok := btlist[hash]; !ok {
|
||||
btlist[hash] = t
|
||||
}
|
||||
}
|
||||
var ret []*Torrent
|
||||
|
||||
for _, t := range btlist {
|
||||
ret = append(ret, t)
|
||||
}
|
||||
|
||||
sort.Slice(ret, func(i, j int) bool {
|
||||
if ret[i].Timestamp != ret[j].Timestamp {
|
||||
return ret[i].Timestamp > ret[j].Timestamp
|
||||
} else {
|
||||
return ret[i].Title > ret[j].Title
|
||||
}
|
||||
})
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
func DropTorrent(hashHex string) {
|
||||
hash := metainfo.NewHashFromHex(hashHex)
|
||||
bts.RemoveTorrent(hash)
|
||||
}
|
||||
|
||||
func SetSettings(set *sets.BTSets) {
|
||||
if sets.ReadOnly {
|
||||
log.TLogln("API SetSettings: Read-only DB mode!")
|
||||
return
|
||||
}
|
||||
sets.SetBTSets(set)
|
||||
log.TLogln("drop all torrents")
|
||||
dropAllTorrent()
|
||||
time.Sleep(time.Second * 1)
|
||||
log.TLogln("disconect")
|
||||
bts.Disconnect()
|
||||
log.TLogln("connect")
|
||||
bts.Connect()
|
||||
time.Sleep(time.Second * 1)
|
||||
log.TLogln("end set settings")
|
||||
}
|
||||
|
||||
func SetDefSettings() {
|
||||
if sets.ReadOnly {
|
||||
log.TLogln("API SetDefSettings: Read-only DB mode!")
|
||||
return
|
||||
}
|
||||
sets.SetDefaultConfig()
|
||||
log.TLogln("drop all torrents")
|
||||
dropAllTorrent()
|
||||
time.Sleep(time.Second * 1)
|
||||
log.TLogln("disconect")
|
||||
bts.Disconnect()
|
||||
log.TLogln("connect")
|
||||
bts.Connect()
|
||||
time.Sleep(time.Second * 1)
|
||||
log.TLogln("end set default settings")
|
||||
}
|
||||
|
||||
func dropAllTorrent() {
|
||||
for _, torr := range bts.torrents {
|
||||
torr.drop()
|
||||
<-torr.closed
|
||||
}
|
||||
}
|
||||
|
||||
func Shutdown() {
|
||||
bts.Disconnect()
|
||||
sets.CloseDB()
|
||||
log.TLogln("Received shutdown. Quit")
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
func WriteStatus(w io.Writer) {
|
||||
bts.client.WriteStatus(w)
|
||||
}
|
||||
|
||||
func Preload(torr *Torrent, index int) {
|
||||
cache := float32(sets.BTsets.CacheSize)
|
||||
preload := float32(sets.BTsets.PreloadCache)
|
||||
size := int64((cache / 100.0) * preload)
|
||||
if size <= 0 {
|
||||
return
|
||||
}
|
||||
if size > sets.BTsets.CacheSize {
|
||||
size = sets.BTsets.CacheSize
|
||||
}
|
||||
torr.Preload(index, size)
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
package torr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"maps"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"server/proxy"
|
||||
"sync"
|
||||
|
||||
"github.com/anacrolix/publicip"
|
||||
"github.com/anacrolix/torrent"
|
||||
"github.com/anacrolix/torrent/metainfo"
|
||||
"github.com/wlynxg/anet"
|
||||
|
||||
"server/settings"
|
||||
"server/torr/storage/torrstor"
|
||||
"server/torr/utils"
|
||||
"server/version"
|
||||
)
|
||||
|
||||
type BTServer struct {
|
||||
config *torrent.ClientConfig
|
||||
client *torrent.Client
|
||||
|
||||
storage *torrstor.Storage
|
||||
|
||||
torrents map[metainfo.Hash]*Torrent
|
||||
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
var privateIPBlocks []*net.IPNet
|
||||
|
||||
func init() {
|
||||
for _, cidr := range []string{
|
||||
"127.0.0.0/8", // IPv4 loopback
|
||||
"10.0.0.0/8", // RFC1918
|
||||
"172.16.0.0/12", // RFC1918
|
||||
"192.168.0.0/16", // RFC1918
|
||||
"169.254.0.0/16", // RFC3927 link-local
|
||||
"::1/128", // IPv6 loopback
|
||||
"fe80::/10", // IPv6 link-local
|
||||
"fc00::/7", // IPv6 unique local addr
|
||||
} {
|
||||
_, block, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("parse error on %q: %v", cidr, err))
|
||||
}
|
||||
privateIPBlocks = append(privateIPBlocks, block)
|
||||
}
|
||||
}
|
||||
|
||||
func NewBTS() *BTServer {
|
||||
bts := new(BTServer)
|
||||
bts.torrents = make(map[metainfo.Hash]*Torrent)
|
||||
return bts
|
||||
}
|
||||
|
||||
func (bt *BTServer) Connect() error {
|
||||
bt.mu.Lock()
|
||||
defer bt.mu.Unlock()
|
||||
var err error
|
||||
bt.configure(context.TODO())
|
||||
bt.client, err = torrent.NewClient(bt.config)
|
||||
bt.torrents = make(map[metainfo.Hash]*Torrent)
|
||||
InitApiHelper(bt)
|
||||
|
||||
proxy.Start()
|
||||
return err
|
||||
}
|
||||
|
||||
func (bt *BTServer) Disconnect() {
|
||||
bt.mu.Lock()
|
||||
defer bt.mu.Unlock()
|
||||
if bt.client != nil {
|
||||
bt.client.Close()
|
||||
bt.client = nil
|
||||
utils.FreeOSMemGC()
|
||||
}
|
||||
proxy.Stop()
|
||||
}
|
||||
|
||||
func (bt *BTServer) configure(ctx context.Context) {
|
||||
blocklist, _ := utils.ReadBlockedIP()
|
||||
bt.config = torrent.NewDefaultClientConfig()
|
||||
|
||||
bt.storage = torrstor.NewStorage(settings.BTsets.CacheSize)
|
||||
bt.config.DefaultStorage = bt.storage
|
||||
|
||||
userAgent := "qBittorrent/4.3.9"
|
||||
peerID := "-qB4390-"
|
||||
upnpID := "TorrServer/" + version.Version
|
||||
cliVers := userAgent
|
||||
|
||||
bt.config.Debug = settings.BTsets.EnableDebug
|
||||
bt.config.DisableIPv6 = !settings.BTsets.EnableIPv6
|
||||
bt.config.DisableTCP = settings.BTsets.DisableTCP
|
||||
bt.config.DisableUTP = settings.BTsets.DisableUTP
|
||||
// https://github.com/anacrolix/torrent/issues/703
|
||||
// bt.config.DisableWebtorrent = true // NE
|
||||
// bt.config.DisableWebseeds = false // NE
|
||||
bt.config.NoDefaultPortForwarding = settings.BTsets.DisableUPNP
|
||||
bt.config.NoDHT = settings.BTsets.DisableDHT
|
||||
bt.config.DisablePEX = settings.BTsets.DisablePEX
|
||||
bt.config.NoUpload = settings.BTsets.DisableUpload
|
||||
bt.config.IPBlocklist = blocklist
|
||||
bt.config.Bep20 = peerID
|
||||
bt.config.PeerID = utils.PeerIDRandom(peerID)
|
||||
bt.config.UpnpID = upnpID
|
||||
bt.config.HTTPUserAgent = userAgent
|
||||
bt.config.ExtendedHandshakeClientVersion = cliVers
|
||||
bt.config.EstablishedConnsPerTorrent = settings.BTsets.ConnectionsLimit
|
||||
bt.config.TotalHalfOpenConns = 500
|
||||
// Encryption/Obfuscation
|
||||
bt.config.EncryptionPolicy = torrent.EncryptionPolicy{ // OE
|
||||
ForceEncryption: settings.BTsets.ForceEncrypt, // OE
|
||||
} // OE
|
||||
// bt.config.HeaderObfuscationPolicy = torrent.HeaderObfuscationPolicy{ // NE
|
||||
// RequirePreferred: settings.BTsets.ForceEncrypt, // NE
|
||||
// Preferred: true, // NE
|
||||
// } // NE
|
||||
if settings.BTsets.DownloadRateLimit > 0 {
|
||||
bt.config.DownloadRateLimiter = utils.Limit(settings.BTsets.DownloadRateLimit * 1024)
|
||||
}
|
||||
if settings.BTsets.UploadRateLimit > 0 {
|
||||
bt.config.Seed = true
|
||||
bt.config.UploadRateLimiter = utils.Limit(settings.BTsets.UploadRateLimit * 1024)
|
||||
}
|
||||
if settings.TorAddr != "" {
|
||||
log.Println("Set listen addr", settings.TorAddr)
|
||||
bt.config.SetListenAddr(settings.TorAddr)
|
||||
} else {
|
||||
if settings.BTsets.PeersListenPort > 0 {
|
||||
log.Println("Set listen port", settings.BTsets.PeersListenPort)
|
||||
bt.config.ListenPort = settings.BTsets.PeersListenPort
|
||||
} else {
|
||||
log.Println("Set listen port to random autoselect (0)")
|
||||
bt.config.ListenPort = 0
|
||||
}
|
||||
}
|
||||
|
||||
// Configure proxy if enabled
|
||||
if err := bt.configureProxy(); err != nil {
|
||||
log.Println("Proxy configuration error:", err)
|
||||
}
|
||||
|
||||
log.Println("Client config:", settings.BTsets)
|
||||
|
||||
var err error
|
||||
|
||||
// set public IPv4
|
||||
if settings.PubIPv4 != "" {
|
||||
if ip4 := net.ParseIP(settings.PubIPv4); ip4.To4() != nil && !isPrivateIP(ip4) {
|
||||
bt.config.PublicIp4 = ip4
|
||||
}
|
||||
}
|
||||
if bt.config.PublicIp4 == nil {
|
||||
bt.config.PublicIp4, err = publicip.Get4(ctx)
|
||||
if err != nil {
|
||||
log.Printf("error getting public ipv4 address: %v", err)
|
||||
}
|
||||
}
|
||||
if bt.config.PublicIp4.To4() == nil { // possible IPv6 from publicip.Get4(ctx)
|
||||
bt.config.PublicIp4 = nil
|
||||
}
|
||||
if bt.config.PublicIp4 != nil {
|
||||
log.Println("PublicIp4:", bt.config.PublicIp4)
|
||||
}
|
||||
|
||||
// set public IPv6
|
||||
if settings.PubIPv6 != "" {
|
||||
if ip6 := net.ParseIP(settings.PubIPv6); ip6.To16() != nil && ip6.To4() == nil && !isPrivateIP(ip6) {
|
||||
bt.config.PublicIp6 = ip6
|
||||
}
|
||||
}
|
||||
if bt.config.PublicIp6 == nil && settings.BTsets.EnableIPv6 {
|
||||
bt.config.PublicIp6, err = publicip.Get6(ctx)
|
||||
if err != nil {
|
||||
log.Printf("error getting public ipv6 address: %v", err)
|
||||
}
|
||||
}
|
||||
if bt.config.PublicIp6.To16() == nil { // just 4 sure it's valid IPv6
|
||||
bt.config.PublicIp6 = nil
|
||||
}
|
||||
if bt.config.PublicIp6 != nil {
|
||||
log.Println("PublicIp6:", bt.config.PublicIp6)
|
||||
}
|
||||
}
|
||||
|
||||
func (bt *BTServer) configureProxy() error {
|
||||
proxyURL := settings.Args.ProxyURL
|
||||
|
||||
if proxyURL == "" {
|
||||
return nil // No proxy configured
|
||||
}
|
||||
|
||||
proxyMode := settings.Args.ProxyMode
|
||||
if proxyMode == "" {
|
||||
proxyMode = "tracker" // default
|
||||
}
|
||||
|
||||
// Parse and validate proxy URL
|
||||
parsedURL, err := url.Parse(proxyURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid proxy URL: %w", err)
|
||||
}
|
||||
|
||||
scheme := parsedURL.Scheme
|
||||
// Validate proxy protocol
|
||||
switch scheme {
|
||||
case "socks5", "socks5h", "socks4", "socks4a", "http", "https":
|
||||
// Supported protocols
|
||||
default:
|
||||
return fmt.Errorf("unsupported proxy protocol: %s (supported: http, https, socks4, socks4a, socks5, socks5h)", scheme)
|
||||
}
|
||||
|
||||
if proxyMode == "full" {
|
||||
log.Printf("Configuring proxy for all BitTorrent traffic: %s://%s", scheme, parsedURL.Host)
|
||||
|
||||
// Set ProxyURL - this will be used by anacrolix/torrent for all BitTorrent traffic
|
||||
bt.config.ProxyURL = proxyURL
|
||||
|
||||
// Also set HTTPProxy explicitly for HTTP tracker requests
|
||||
bt.config.HTTPProxy = func(req *http.Request) (*url.URL, error) {
|
||||
return parsedURL, nil
|
||||
}
|
||||
|
||||
log.Println("Proxy configured successfully for all BitTorrent connections (tracker, DHT, peers)")
|
||||
} else if proxyMode == "peers" {
|
||||
log.Printf("Configuring proxy for peer connections only: %s://%s", scheme, parsedURL.Host)
|
||||
|
||||
// Set ProxyURL for peer connections, but don't set HTTPProxy
|
||||
// This routes DHT and peer connections through proxy, but not HTTP tracker requests
|
||||
bt.config.ProxyURL = proxyURL
|
||||
|
||||
log.Println("Proxy configured successfully for peer and DHT connections only")
|
||||
} else {
|
||||
log.Printf("Configuring proxy for HTTP tracker requests only: %s://%s", scheme, parsedURL.Host)
|
||||
|
||||
// Only set HTTPProxy for tracker requests, don't set ProxyURL
|
||||
bt.config.HTTPProxy = func(req *http.Request) (*url.URL, error) {
|
||||
return parsedURL, nil
|
||||
}
|
||||
|
||||
log.Println("Proxy configured successfully for HTTP tracker connections only")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (bt *BTServer) GetTorrent(hash torrent.InfoHash) *Torrent {
|
||||
if torr, ok := bt.torrents[hash]; ok {
|
||||
return torr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (bt *BTServer) ListTorrents() map[metainfo.Hash]*Torrent {
|
||||
list := make(map[metainfo.Hash]*Torrent)
|
||||
maps.Copy(list, bt.torrents)
|
||||
return list
|
||||
}
|
||||
|
||||
func (bt *BTServer) RemoveTorrent(hash torrent.InfoHash) bool {
|
||||
if torr, ok := bt.torrents[hash]; ok {
|
||||
return torr.Close()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isPrivateIP(ip net.IP) bool {
|
||||
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, block := range privateIPBlocks {
|
||||
if block.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func getPublicIp4() net.IP {
|
||||
ifaces, err := anet.Interfaces()
|
||||
if err != nil {
|
||||
log.Println("Error get public IPv4")
|
||||
return nil
|
||||
}
|
||||
for _, i := range ifaces {
|
||||
addrs, _ := anet.InterfaceAddrsByInterface(&i)
|
||||
if i.Flags&net.FlagUp == net.FlagUp {
|
||||
for _, addr := range addrs {
|
||||
var ip net.IP
|
||||
switch v := addr.(type) {
|
||||
case *net.IPNet:
|
||||
ip = v.IP
|
||||
case *net.IPAddr:
|
||||
ip = v.IP
|
||||
}
|
||||
if !isPrivateIP(ip) && ip.To4() != nil {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getPublicIp6() net.IP {
|
||||
ifaces, err := anet.Interfaces()
|
||||
if err != nil {
|
||||
log.Println("Error get public IPv6")
|
||||
return nil
|
||||
}
|
||||
for _, i := range ifaces {
|
||||
addrs, _ := anet.InterfaceAddrsByInterface(&i)
|
||||
if i.Flags&net.FlagUp == net.FlagUp {
|
||||
for _, addr := range addrs {
|
||||
var ip net.IP
|
||||
switch v := addr.(type) {
|
||||
case *net.IPNet:
|
||||
ip = v.IP
|
||||
case *net.IPAddr:
|
||||
ip = v.IP
|
||||
}
|
||||
if !isPrivateIP(ip) && ip.To16() != nil && ip.To4() == nil {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package torr
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"server/settings"
|
||||
"server/torr/state"
|
||||
"server/torr/utils"
|
||||
|
||||
"github.com/anacrolix/torrent/metainfo"
|
||||
)
|
||||
|
||||
type tsFiles struct {
|
||||
TorrServer struct {
|
||||
Files []*state.TorrentFileStat `json:"Files"`
|
||||
} `json:"TorrServer"`
|
||||
}
|
||||
|
||||
func AddTorrentDB(torr *Torrent) {
|
||||
t := new(settings.TorrentDB)
|
||||
t.TorrentSpec = torr.TorrentSpec
|
||||
t.Title = torr.Title
|
||||
t.Category = torr.Category
|
||||
if torr.Data == "" {
|
||||
files := new(tsFiles)
|
||||
files.TorrServer.Files = torr.Status().FileStats
|
||||
buf, err := json.Marshal(files)
|
||||
if err == nil {
|
||||
t.Data = string(buf)
|
||||
torr.Data = t.Data
|
||||
}
|
||||
} else {
|
||||
t.Data = torr.Data
|
||||
}
|
||||
|
||||
if torr.Poster != "" && utils.CheckImgUrl(torr.Poster) {
|
||||
t.Poster = torr.Poster
|
||||
}
|
||||
t.Size = torr.Size
|
||||
if t.Size == 0 && torr.Torrent != nil {
|
||||
t.Size = torr.Torrent.Length()
|
||||
}
|
||||
// don't override timestamp from DB on edit
|
||||
t.Timestamp = torr.Timestamp // time.Now().Unix()
|
||||
|
||||
settings.AddTorrent(t)
|
||||
}
|
||||
|
||||
func GetTorrentDB(hash metainfo.Hash) *Torrent {
|
||||
list := settings.ListTorrent()
|
||||
for _, db := range list {
|
||||
if hash == db.InfoHash {
|
||||
torr := new(Torrent)
|
||||
torr.TorrentSpec = db.TorrentSpec
|
||||
torr.Title = db.Title
|
||||
torr.Poster = db.Poster
|
||||
torr.Category = db.Category
|
||||
torr.Timestamp = db.Timestamp
|
||||
torr.Size = db.Size
|
||||
torr.Data = db.Data
|
||||
torr.Stat = state.TorrentInDB
|
||||
return torr
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func RemTorrentDB(hash metainfo.Hash) {
|
||||
settings.RemTorrent(hash)
|
||||
}
|
||||
|
||||
func ListTorrentsDB() map[metainfo.Hash]*Torrent {
|
||||
ret := make(map[metainfo.Hash]*Torrent)
|
||||
list := settings.ListTorrent()
|
||||
for _, db := range list {
|
||||
torr := new(Torrent)
|
||||
torr.TorrentSpec = db.TorrentSpec
|
||||
torr.Title = db.Title
|
||||
torr.Poster = db.Poster
|
||||
torr.Category = db.Category
|
||||
torr.Timestamp = db.Timestamp
|
||||
torr.Size = db.Size
|
||||
torr.Data = db.Data
|
||||
torr.Stat = state.TorrentInDB
|
||||
ret[torr.TorrentSpec.InfoHash] = torr
|
||||
}
|
||||
return ret
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
package torr
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"server/ffprobe"
|
||||
|
||||
"server/log"
|
||||
"server/settings"
|
||||
"server/torr/state"
|
||||
utils2 "server/utils"
|
||||
|
||||
"github.com/anacrolix/torrent"
|
||||
)
|
||||
|
||||
func (t *Torrent) Preload(index int, size int64) {
|
||||
if size <= 0 {
|
||||
return
|
||||
}
|
||||
t.PreloadSize = size
|
||||
|
||||
if t.Stat == state.TorrentGettingInfo {
|
||||
if !t.WaitInfo() {
|
||||
return
|
||||
}
|
||||
// wait change status
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
|
||||
t.muTorrent.Lock()
|
||||
if t.Stat != state.TorrentWorking {
|
||||
t.muTorrent.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
t.Stat = state.TorrentPreload
|
||||
t.muTorrent.Unlock()
|
||||
|
||||
defer func() {
|
||||
t.muTorrent.Lock()
|
||||
if t.Stat == state.TorrentPreload {
|
||||
t.Stat = state.TorrentWorking
|
||||
}
|
||||
t.muTorrent.Unlock()
|
||||
// Очистка по окончании прелоада
|
||||
t.BitRate = ""
|
||||
t.DurationSeconds = 0
|
||||
}()
|
||||
|
||||
file := t.findFileIndex(index)
|
||||
if file == nil {
|
||||
file = t.Files()[0]
|
||||
}
|
||||
|
||||
if size > file.Length() {
|
||||
size = file.Length()
|
||||
}
|
||||
|
||||
if t.Info() == nil {
|
||||
return
|
||||
}
|
||||
|
||||
timeout := time.Second * time.Duration(settings.BTsets.TorrentDisconnectTimeout)
|
||||
if timeout > time.Minute {
|
||||
timeout = time.Minute
|
||||
}
|
||||
|
||||
// Create a stop channel for the logging goroutine
|
||||
logStopChan := make(chan struct{})
|
||||
defer close(logStopChan) // Ensure logging stops when function returns
|
||||
|
||||
// Запуск лога в отдельном потоке
|
||||
go func(stopChan <-chan struct{}) {
|
||||
ticker := time.NewTicker(time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
t.muTorrent.Lock()
|
||||
stat := t.Stat
|
||||
t.muTorrent.Unlock()
|
||||
|
||||
if stat != state.TorrentPreload {
|
||||
return
|
||||
}
|
||||
|
||||
statStr := fmt.Sprint(file.Torrent().InfoHash().HexString(), " ",
|
||||
utils2.Format(float64(t.PreloadedBytes)), "/",
|
||||
utils2.Format(float64(t.PreloadSize)), " Speed:",
|
||||
utils2.Format(t.DownloadSpeed), " Peers:",
|
||||
t.Torrent.Stats().ActivePeers, "/",
|
||||
t.Torrent.Stats().TotalPeers, " [Seeds:",
|
||||
t.Torrent.Stats().ConnectedSeeders, "]")
|
||||
log.TLogln("Preload:", statStr)
|
||||
t.AddExpiredTime(timeout)
|
||||
case <-stopChan:
|
||||
return
|
||||
}
|
||||
}
|
||||
}(logStopChan)
|
||||
|
||||
if ffprobe.Exists() {
|
||||
link := "http://127.0.0.1:" + settings.Port + "/play/" + t.Hash().HexString() + "/" + strconv.Itoa(index)
|
||||
if settings.Ssl {
|
||||
link = "https://127.0.0.1:" + settings.SslPort + "/play/" + t.Hash().HexString() + "/" + strconv.Itoa(index)
|
||||
}
|
||||
if data, err := ffprobe.ProbeUrl(link); err == nil {
|
||||
t.BitRate = data.Format.BitRate
|
||||
t.DurationSeconds = data.Format.DurationSeconds
|
||||
}
|
||||
}
|
||||
|
||||
// Check if torrent was closed
|
||||
t.muTorrent.Lock()
|
||||
isClosed := t.Stat == state.TorrentClosed
|
||||
t.muTorrent.Unlock()
|
||||
|
||||
if isClosed {
|
||||
log.TLogln("End preload: torrent closed")
|
||||
return
|
||||
}
|
||||
|
||||
// startend -> 8/16 MB
|
||||
startend := t.Info().PieceLength
|
||||
if startend < 8<<20 {
|
||||
startend = 8 << 20
|
||||
}
|
||||
|
||||
readerStart := file.NewReader()
|
||||
if readerStart == nil {
|
||||
log.TLogln("End preload: null reader")
|
||||
return
|
||||
}
|
||||
defer readerStart.Close()
|
||||
|
||||
readerStart.SetResponsive()
|
||||
readerStart.SetReadahead(0)
|
||||
readerStartEnd := size - startend
|
||||
|
||||
if readerStartEnd < 0 {
|
||||
// Если конец начального ридера оказался за началом
|
||||
readerStartEnd = size
|
||||
}
|
||||
if readerStartEnd > file.Length() {
|
||||
// Если конец начального ридера оказался после конца файла
|
||||
readerStartEnd = file.Length()
|
||||
}
|
||||
|
||||
readerEndStart := file.Length() - startend
|
||||
readerEndEnd := file.Length()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var preloadErr error
|
||||
|
||||
// Start end range preload if needed
|
||||
if readerEndStart > readerStartEnd {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
// Check if we should still preload
|
||||
t.muTorrent.Lock()
|
||||
shouldPreload := t.Stat == state.TorrentPreload
|
||||
t.muTorrent.Unlock()
|
||||
|
||||
if !shouldPreload {
|
||||
return
|
||||
}
|
||||
|
||||
readerEnd := file.NewReader()
|
||||
if readerEnd == nil {
|
||||
log.TLogln("Err preload: null reader")
|
||||
preloadErr = fmt.Errorf("null reader for end range")
|
||||
return
|
||||
}
|
||||
defer readerEnd.Close() // Ensure reader is always closed
|
||||
|
||||
readerEnd.SetResponsive()
|
||||
readerEnd.SetReadahead(0)
|
||||
|
||||
_, err := readerEnd.Seek(readerEndStart, io.SeekStart)
|
||||
if err != nil {
|
||||
log.TLogln("Err preload seek:", err)
|
||||
preloadErr = err
|
||||
return
|
||||
}
|
||||
|
||||
offset := readerEndStart
|
||||
tmp := make([]byte, 32768)
|
||||
for offset+int64(len(tmp)) < readerEndEnd {
|
||||
n, err := readerEnd.Read(tmp)
|
||||
if err != nil {
|
||||
if err != io.EOF {
|
||||
log.TLogln("Err preload read:", err)
|
||||
preloadErr = err
|
||||
}
|
||||
break
|
||||
}
|
||||
offset += int64(n)
|
||||
|
||||
// Check if we should continue
|
||||
t.muTorrent.Lock()
|
||||
shouldContinue := t.Stat == state.TorrentPreload
|
||||
t.muTorrent.Unlock()
|
||||
|
||||
if !shouldContinue {
|
||||
break
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Main preload section
|
||||
pieceLength := t.Info().PieceLength
|
||||
readahead := pieceLength * 4
|
||||
if readerStartEnd < readahead {
|
||||
readahead = 0
|
||||
}
|
||||
readerStart.SetReadahead(readahead)
|
||||
|
||||
offset := int64(0)
|
||||
tmp := make([]byte, 32768)
|
||||
for offset+int64(len(tmp)) < readerStartEnd {
|
||||
// Check if we should continue
|
||||
t.muTorrent.Lock()
|
||||
shouldContinue := t.Stat == state.TorrentPreload
|
||||
t.muTorrent.Unlock()
|
||||
|
||||
if !shouldContinue {
|
||||
log.TLogln("Preload cancelled")
|
||||
break
|
||||
}
|
||||
|
||||
n, err := readerStart.Read(tmp)
|
||||
if err != nil {
|
||||
if err != io.EOF {
|
||||
log.TLogln("Error preload:", err)
|
||||
}
|
||||
break
|
||||
}
|
||||
offset += int64(n)
|
||||
|
||||
if readahead > 0 && readerStartEnd-(offset+int64(len(tmp))) < readahead {
|
||||
readahead = 0
|
||||
readerStart.SetReadahead(0)
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for end range preload to complete
|
||||
wg.Wait()
|
||||
|
||||
// Check if end range preload failed
|
||||
if preloadErr != nil {
|
||||
log.TLogln("End range preload failed:", preloadErr)
|
||||
}
|
||||
|
||||
// Final log
|
||||
t.muTorrent.Lock()
|
||||
finalStat := t.Stat
|
||||
t.muTorrent.Unlock()
|
||||
|
||||
if finalStat == state.TorrentPreload {
|
||||
log.TLogln("End preload:", file.Torrent().InfoHash().HexString(),
|
||||
"Peers:", t.Torrent.Stats().ActivePeers, "/",
|
||||
t.Torrent.Stats().TotalPeers, "[ Seeds:",
|
||||
t.Torrent.Stats().ConnectedSeeders, "]")
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Torrent) findFileIndex(index int) *torrent.File {
|
||||
st := t.Status()
|
||||
var stFile *state.TorrentFileStat
|
||||
for _, f := range st.FileStats {
|
||||
if index == f.Id {
|
||||
stFile = f
|
||||
break
|
||||
}
|
||||
}
|
||||
if stFile == nil {
|
||||
return nil
|
||||
}
|
||||
for _, file := range t.Files() {
|
||||
if file.Path() == stFile.Path {
|
||||
return file
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package state
|
||||
|
||||
type TorrentStat int
|
||||
|
||||
func (t TorrentStat) String() string {
|
||||
switch t {
|
||||
case TorrentAdded:
|
||||
return "Torrent added"
|
||||
case TorrentGettingInfo:
|
||||
return "Torrent getting info"
|
||||
case TorrentPreload:
|
||||
return "Torrent preload"
|
||||
case TorrentWorking:
|
||||
return "Torrent working"
|
||||
case TorrentClosed:
|
||||
return "Torrent closed"
|
||||
case TorrentInDB:
|
||||
return "Torrent in db"
|
||||
default:
|
||||
return "Torrent unknown status"
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
TorrentAdded = TorrentStat(iota)
|
||||
TorrentGettingInfo
|
||||
TorrentPreload
|
||||
TorrentWorking
|
||||
TorrentClosed
|
||||
TorrentInDB
|
||||
)
|
||||
|
||||
type TorrentStatus struct {
|
||||
Title string `json:"title"`
|
||||
Category string `json:"category"`
|
||||
Poster string `json:"poster"`
|
||||
Data string `json:"data,omitempty"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Hash string `json:"hash,omitempty"`
|
||||
TorrsHash string `json:"torrs_hash,omitempty"`
|
||||
Stat TorrentStat `json:"stat"`
|
||||
StatString string `json:"stat_string"`
|
||||
LoadedSize int64 `json:"loaded_size,omitempty"`
|
||||
TorrentSize int64 `json:"torrent_size,omitempty"`
|
||||
PreloadedBytes int64 `json:"preloaded_bytes,omitempty"`
|
||||
PreloadSize int64 `json:"preload_size,omitempty"`
|
||||
DownloadSpeed float64 `json:"download_speed,omitempty"`
|
||||
UploadSpeed float64 `json:"upload_speed,omitempty"`
|
||||
TotalPeers int `json:"total_peers,omitempty"`
|
||||
PendingPeers int `json:"pending_peers,omitempty"`
|
||||
ActivePeers int `json:"active_peers,omitempty"`
|
||||
ConnectedSeeders int `json:"connected_seeders,omitempty"`
|
||||
HalfOpenPeers int `json:"half_open_peers,omitempty"`
|
||||
BytesWritten int64 `json:"bytes_written,omitempty"`
|
||||
BytesWrittenData int64 `json:"bytes_written_data,omitempty"`
|
||||
BytesRead int64 `json:"bytes_read,omitempty"`
|
||||
BytesReadData int64 `json:"bytes_read_data,omitempty"`
|
||||
BytesReadUsefulData int64 `json:"bytes_read_useful_data,omitempty"`
|
||||
ChunksWritten int64 `json:"chunks_written,omitempty"`
|
||||
ChunksRead int64 `json:"chunks_read,omitempty"`
|
||||
ChunksReadUseful int64 `json:"chunks_read_useful,omitempty"`
|
||||
ChunksReadWasted int64 `json:"chunks_read_wasted,omitempty"`
|
||||
PiecesDirtiedGood int64 `json:"pieces_dirtied_good,omitempty"`
|
||||
PiecesDirtiedBad int64 `json:"pieces_dirtied_bad,omitempty"`
|
||||
DurationSeconds float64 `json:"duration_seconds,omitempty"`
|
||||
BitRate string `json:"bit_rate,omitempty"`
|
||||
|
||||
FileStats []*TorrentFileStat `json:"file_stats,omitempty"`
|
||||
}
|
||||
|
||||
type TorrentFileStat struct {
|
||||
Id int `json:"id,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Length int64 `json:"length,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"server/torr/state"
|
||||
)
|
||||
|
||||
type CacheState struct {
|
||||
Hash string
|
||||
Capacity int64
|
||||
Filled int64
|
||||
PiecesLength int64
|
||||
PiecesCount int
|
||||
Torrent *state.TorrentStatus
|
||||
Pieces map[int]ItemState
|
||||
Readers []*ReaderState
|
||||
}
|
||||
|
||||
type ItemState struct {
|
||||
Id int
|
||||
Length int64
|
||||
Size int64
|
||||
Completed bool
|
||||
Priority int
|
||||
}
|
||||
|
||||
type ReaderState struct {
|
||||
Start int
|
||||
End int
|
||||
Reader int
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"github.com/anacrolix/torrent/metainfo"
|
||||
"github.com/anacrolix/torrent/storage"
|
||||
)
|
||||
|
||||
type Storage interface {
|
||||
storage.ClientImpl
|
||||
|
||||
CloseHash(hash metainfo.Hash)
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
package torrstor
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/anacrolix/torrent"
|
||||
|
||||
"server/log"
|
||||
"server/settings"
|
||||
"server/torr/storage/state"
|
||||
"server/torr/utils"
|
||||
|
||||
"github.com/anacrolix/torrent/metainfo"
|
||||
"github.com/anacrolix/torrent/storage"
|
||||
)
|
||||
|
||||
type Cache struct {
|
||||
storage.TorrentImpl
|
||||
storage *Storage
|
||||
|
||||
capacity int64
|
||||
filled int64
|
||||
hash metainfo.Hash
|
||||
|
||||
pieceLength int64
|
||||
pieceCount int
|
||||
|
||||
pieces map[int]*Piece
|
||||
|
||||
readers map[*Reader]struct{}
|
||||
muReaders sync.Mutex
|
||||
|
||||
isRemove bool
|
||||
isClosed bool
|
||||
muRemove sync.Mutex
|
||||
torrent *torrent.Torrent
|
||||
}
|
||||
|
||||
func NewCache(capacity int64, storage *Storage) *Cache {
|
||||
ret := &Cache{
|
||||
capacity: capacity,
|
||||
filled: 0,
|
||||
pieces: make(map[int]*Piece),
|
||||
storage: storage,
|
||||
readers: make(map[*Reader]struct{}),
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
func (c *Cache) Init(info *metainfo.Info, hash metainfo.Hash) {
|
||||
log.TLogln("Create cache for:", info.Name, hash.HexString())
|
||||
if c.capacity == 0 {
|
||||
c.capacity = info.PieceLength * 4
|
||||
}
|
||||
|
||||
c.pieceLength = info.PieceLength
|
||||
c.pieceCount = info.NumPieces()
|
||||
c.hash = hash
|
||||
|
||||
if settings.BTsets.UseDisk {
|
||||
name := filepath.Join(settings.BTsets.TorrentsSavePath, hash.HexString())
|
||||
err := os.MkdirAll(name, 0o777)
|
||||
if err != nil {
|
||||
log.TLogln("Error create dir:", err)
|
||||
}
|
||||
}
|
||||
|
||||
for i := 0; i < c.pieceCount; i++ {
|
||||
c.pieces[i] = NewPiece(i, c)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Cache) SetTorrent(torr *torrent.Torrent) {
|
||||
c.torrent = torr
|
||||
}
|
||||
|
||||
func (c *Cache) Piece(m metainfo.Piece) storage.PieceImpl {
|
||||
if val, ok := c.pieces[m.Index()]; ok {
|
||||
return val
|
||||
}
|
||||
return &PieceFake{}
|
||||
}
|
||||
|
||||
func (c *Cache) Close() error {
|
||||
if c.torrent != nil {
|
||||
log.TLogln("Close cache for:", c.torrent.Name(), c.hash)
|
||||
} else {
|
||||
log.TLogln("Close cache for:", c.hash)
|
||||
}
|
||||
c.isClosed = true
|
||||
|
||||
delete(c.storage.caches, c.hash)
|
||||
|
||||
if settings.BTsets.RemoveCacheOnDrop {
|
||||
name := filepath.Join(settings.BTsets.TorrentsSavePath, c.hash.HexString())
|
||||
if name != "" && name != "/" {
|
||||
for _, v := range c.pieces {
|
||||
if v.dPiece != nil {
|
||||
os.Remove(v.dPiece.name)
|
||||
}
|
||||
}
|
||||
os.Remove(name)
|
||||
}
|
||||
}
|
||||
|
||||
c.muReaders.Lock()
|
||||
c.readers = nil
|
||||
c.pieces = nil
|
||||
c.muReaders.Unlock()
|
||||
|
||||
utils.FreeOSMemGC()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Cache) removePiece(piece *Piece) {
|
||||
if !c.isClosed {
|
||||
piece.Release()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Cache) AdjustRA(readahead int64) {
|
||||
if settings.BTsets.CacheSize == 0 {
|
||||
c.capacity = readahead * 3
|
||||
}
|
||||
if c.Readers() > 0 {
|
||||
c.muReaders.Lock()
|
||||
for r := range c.readers {
|
||||
r.SetReadahead(readahead)
|
||||
}
|
||||
c.muReaders.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Cache) GetState() *state.CacheState {
|
||||
cState := new(state.CacheState)
|
||||
|
||||
piecesState := make(map[int]state.ItemState, 0)
|
||||
var fill int64 = 0
|
||||
|
||||
if len(c.pieces) > 0 {
|
||||
for _, p := range c.pieces {
|
||||
if p.Size > 0 {
|
||||
fill += p.Size
|
||||
piecesState[p.Id] = state.ItemState{
|
||||
Id: p.Id,
|
||||
Size: p.Size,
|
||||
Length: c.pieceLength,
|
||||
Completed: p.Complete,
|
||||
Priority: int(c.torrent.PieceState(p.Id).Priority),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
readersState := make([]*state.ReaderState, 0)
|
||||
|
||||
if c.Readers() > 0 {
|
||||
c.muReaders.Lock()
|
||||
for r := range c.readers {
|
||||
rng := r.getPiecesRange()
|
||||
pc := r.getReaderPiece()
|
||||
readersState = append(readersState, &state.ReaderState{
|
||||
Start: rng.Start,
|
||||
End: rng.End,
|
||||
Reader: pc,
|
||||
})
|
||||
}
|
||||
c.muReaders.Unlock()
|
||||
}
|
||||
|
||||
c.filled = fill
|
||||
cState.Capacity = c.capacity
|
||||
cState.PiecesLength = c.pieceLength
|
||||
cState.PiecesCount = c.pieceCount
|
||||
cState.Hash = c.hash.HexString()
|
||||
cState.Filled = fill
|
||||
cState.Pieces = piecesState
|
||||
cState.Readers = readersState
|
||||
return cState
|
||||
}
|
||||
|
||||
func (c *Cache) cleanPieces() {
|
||||
if c.isRemove || c.isClosed {
|
||||
return
|
||||
}
|
||||
c.muRemove.Lock()
|
||||
if c.isRemove {
|
||||
c.muRemove.Unlock()
|
||||
return
|
||||
}
|
||||
c.isRemove = true
|
||||
defer func() { c.isRemove = false }()
|
||||
c.muRemove.Unlock()
|
||||
|
||||
remPieces := c.getRemPieces()
|
||||
if c.filled > c.capacity {
|
||||
rems := (c.filled-c.capacity)/c.pieceLength + 1
|
||||
for _, p := range remPieces {
|
||||
c.removePiece(p)
|
||||
rems--
|
||||
if rems <= 0 {
|
||||
utils.FreeOSMemGC()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Cache) getRemPieces() []*Piece {
|
||||
piecesRemove := make([]*Piece, 0)
|
||||
fill := int64(0)
|
||||
|
||||
ranges := make([]Range, 0)
|
||||
c.muReaders.Lock()
|
||||
for r := range c.readers {
|
||||
r.checkReader()
|
||||
if r.isUse {
|
||||
ranges = append(ranges, r.getPiecesRange())
|
||||
}
|
||||
}
|
||||
c.muReaders.Unlock()
|
||||
ranges = mergeRange(ranges)
|
||||
|
||||
for id, p := range c.pieces {
|
||||
if p.Size > 0 {
|
||||
fill += p.Size
|
||||
}
|
||||
if len(ranges) > 0 {
|
||||
if !inRanges(ranges, id) {
|
||||
if p.Size > 0 && !c.isIdInFileBE(ranges, id) {
|
||||
piecesRemove = append(piecesRemove, p)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// on preload clean
|
||||
if p.Size > 0 && !c.isIdInFileBE(ranges, id) {
|
||||
piecesRemove = append(piecesRemove, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.clearPriority()
|
||||
c.setLoadPriority(ranges)
|
||||
|
||||
sort.Slice(piecesRemove, func(i, j int) bool {
|
||||
return piecesRemove[i].Accessed < piecesRemove[j].Accessed
|
||||
})
|
||||
|
||||
c.filled = fill
|
||||
return piecesRemove
|
||||
}
|
||||
|
||||
func (c *Cache) setLoadPriority(ranges []Range) {
|
||||
c.muReaders.Lock()
|
||||
for r := range c.readers {
|
||||
if !r.isUse {
|
||||
continue
|
||||
}
|
||||
if c.isIdInFileBE(ranges, r.getReaderPiece()) {
|
||||
continue
|
||||
}
|
||||
readerPos := r.getReaderPiece()
|
||||
readerRAHPos := r.getReaderRAHPiece()
|
||||
end := r.getPiecesRange().End
|
||||
count := settings.BTsets.ConnectionsLimit / len(c.readers) // max concurrent loading blocks
|
||||
limit := 0
|
||||
for i := readerPos; i < end && limit < count; i++ {
|
||||
if !c.pieces[i].Complete {
|
||||
if i == readerPos {
|
||||
c.torrent.Piece(i).SetPriority(torrent.PiecePriorityNow)
|
||||
} else if i == readerPos+1 {
|
||||
c.torrent.Piece(i).SetPriority(torrent.PiecePriorityNext)
|
||||
} else if i > readerPos && i <= readerRAHPos {
|
||||
c.torrent.Piece(i).SetPriority(torrent.PiecePriorityReadahead)
|
||||
} else if i > readerRAHPos && i <= readerRAHPos+5 && c.torrent.PieceState(i).Priority != torrent.PiecePriorityHigh {
|
||||
c.torrent.Piece(i).SetPriority(torrent.PiecePriorityHigh)
|
||||
} else if i > readerRAHPos+5 && c.torrent.PieceState(i).Priority != torrent.PiecePriorityNormal {
|
||||
c.torrent.Piece(i).SetPriority(torrent.PiecePriorityNormal)
|
||||
}
|
||||
limit++
|
||||
}
|
||||
}
|
||||
}
|
||||
c.muReaders.Unlock()
|
||||
}
|
||||
|
||||
func (c *Cache) isIdInFileBE(ranges []Range, id int) bool {
|
||||
// keep 8/16 MB
|
||||
FileRangeNotDelete := int64(c.pieceLength)
|
||||
if FileRangeNotDelete < 8<<20 {
|
||||
FileRangeNotDelete = 8 << 20
|
||||
}
|
||||
|
||||
for _, rng := range ranges {
|
||||
ss := int(rng.File.Offset() / c.pieceLength)
|
||||
se := int((rng.File.Offset() + FileRangeNotDelete) / c.pieceLength)
|
||||
|
||||
es := int((rng.File.Offset() + rng.File.Length() - FileRangeNotDelete) / c.pieceLength)
|
||||
ee := int((rng.File.Offset() + rng.File.Length()) / c.pieceLength)
|
||||
|
||||
if id >= ss && id < se || id > es && id <= ee {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
//////////////////
|
||||
// Reader section
|
||||
////////
|
||||
|
||||
func (c *Cache) NewReader(file *torrent.File) *Reader {
|
||||
return newReader(file, c)
|
||||
}
|
||||
|
||||
func (c *Cache) GetUseReaders() int {
|
||||
if c == nil {
|
||||
return 0
|
||||
}
|
||||
c.muReaders.Lock()
|
||||
defer c.muReaders.Unlock()
|
||||
readers := 0
|
||||
for reader := range c.readers {
|
||||
if reader.isUse {
|
||||
readers++
|
||||
}
|
||||
}
|
||||
return readers
|
||||
}
|
||||
|
||||
func (c *Cache) Readers() int {
|
||||
if c == nil {
|
||||
return 0
|
||||
}
|
||||
c.muReaders.Lock()
|
||||
defer c.muReaders.Unlock()
|
||||
if c.readers == nil {
|
||||
return 0
|
||||
}
|
||||
return len(c.readers)
|
||||
}
|
||||
|
||||
func (c *Cache) CloseReader(r *Reader) {
|
||||
r.cache.muReaders.Lock()
|
||||
r.Close()
|
||||
delete(r.cache.readers, r)
|
||||
r.cache.muReaders.Unlock()
|
||||
go c.clearPriority()
|
||||
}
|
||||
|
||||
func (c *Cache) clearPriority() {
|
||||
time.Sleep(time.Second)
|
||||
ranges := make([]Range, 0)
|
||||
c.muReaders.Lock()
|
||||
for r := range c.readers {
|
||||
r.checkReader()
|
||||
if r.isUse {
|
||||
ranges = append(ranges, r.getPiecesRange())
|
||||
}
|
||||
}
|
||||
c.muReaders.Unlock()
|
||||
ranges = mergeRange(ranges)
|
||||
|
||||
for id := range c.pieces {
|
||||
if len(ranges) > 0 {
|
||||
if !inRanges(ranges, id) {
|
||||
if c.torrent.PieceState(id).Priority != torrent.PiecePriorityNone {
|
||||
c.torrent.Piece(id).SetPriority(torrent.PiecePriorityNone)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if c.torrent.PieceState(id).Priority != torrent.PiecePriorityNone {
|
||||
c.torrent.Piece(id).SetPriority(torrent.PiecePriorityNone)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Cache) GetCapacity() int64 {
|
||||
if c == nil {
|
||||
return 0
|
||||
}
|
||||
return c.capacity
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package torrstor
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"server/log"
|
||||
"server/settings"
|
||||
)
|
||||
|
||||
type DiskPiece struct {
|
||||
piece *Piece
|
||||
|
||||
name string
|
||||
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func NewDiskPiece(p *Piece) *DiskPiece {
|
||||
name := filepath.Join(settings.BTsets.TorrentsSavePath, p.cache.hash.HexString(), strconv.Itoa(p.Id))
|
||||
ff, err := os.Stat(name)
|
||||
if err == nil {
|
||||
p.Size = ff.Size()
|
||||
p.Complete = ff.Size() == p.cache.pieceLength
|
||||
p.Accessed = ff.ModTime().Unix()
|
||||
}
|
||||
return &DiskPiece{piece: p, name: name}
|
||||
}
|
||||
|
||||
func (p *DiskPiece) WriteAt(b []byte, off int64) (n int, err error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
ff, err := os.OpenFile(p.name, os.O_RDWR|os.O_CREATE, 0o666)
|
||||
if err != nil {
|
||||
log.TLogln("Error open file:", err)
|
||||
return 0, err
|
||||
}
|
||||
defer ff.Close()
|
||||
n, err = ff.WriteAt(b, off)
|
||||
|
||||
p.piece.Size += int64(n)
|
||||
if p.piece.Size > p.piece.cache.pieceLength {
|
||||
p.piece.Size = p.piece.cache.pieceLength
|
||||
}
|
||||
p.piece.Accessed = time.Now().Unix()
|
||||
return
|
||||
}
|
||||
|
||||
func (p *DiskPiece) ReadAt(b []byte, off int64) (n int, err error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
ff, err := os.OpenFile(p.name, os.O_RDONLY, 0o666)
|
||||
if os.IsNotExist(err) {
|
||||
return 0, io.EOF
|
||||
}
|
||||
if err != nil {
|
||||
log.TLogln("Error open file:", err)
|
||||
return 0, err
|
||||
}
|
||||
defer ff.Close()
|
||||
|
||||
n, err = ff.ReadAt(b, off)
|
||||
|
||||
p.piece.Accessed = time.Now().Unix()
|
||||
if int64(len(b))+off >= p.piece.Size {
|
||||
go p.piece.cache.cleanPieces()
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (p *DiskPiece) Release() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
p.piece.Size = 0
|
||||
p.piece.Complete = false
|
||||
|
||||
os.Remove(p.name)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package torrstor
|
||||
|
||||
import (
|
||||
"io"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type MemPiece struct {
|
||||
piece *Piece
|
||||
|
||||
buffer []byte
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func NewMemPiece(p *Piece) *MemPiece {
|
||||
return &MemPiece{piece: p}
|
||||
}
|
||||
|
||||
func (p *MemPiece) WriteAt(b []byte, off int64) (n int, err error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if p.buffer == nil {
|
||||
go p.piece.cache.cleanPieces()
|
||||
p.buffer = make([]byte, p.piece.cache.pieceLength, p.piece.cache.pieceLength)
|
||||
}
|
||||
n = copy(p.buffer[off:], b[:])
|
||||
p.piece.Size += int64(n)
|
||||
if p.piece.Size > p.piece.cache.pieceLength {
|
||||
p.piece.Size = p.piece.cache.pieceLength
|
||||
}
|
||||
p.piece.Accessed = time.Now().Unix()
|
||||
return
|
||||
}
|
||||
|
||||
func (p *MemPiece) ReadAt(b []byte, off int64) (n int, err error) {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
|
||||
size := len(b)
|
||||
if size+int(off) > len(p.buffer) {
|
||||
size = len(p.buffer) - int(off)
|
||||
if size < 0 {
|
||||
size = 0
|
||||
}
|
||||
}
|
||||
if len(p.buffer) < int(off) || len(p.buffer) < int(off)+size {
|
||||
return 0, io.EOF
|
||||
}
|
||||
n = copy(b, p.buffer[int(off) : int(off)+size][:])
|
||||
p.piece.Accessed = time.Now().Unix()
|
||||
if int64(len(b))+off >= p.piece.Size {
|
||||
go p.piece.cache.cleanPieces()
|
||||
}
|
||||
if n == 0 {
|
||||
return 0, io.EOF
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (p *MemPiece) Release() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.buffer != nil {
|
||||
p.buffer = nil
|
||||
}
|
||||
p.piece.Size = 0
|
||||
p.piece.Complete = false
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package torrstor
|
||||
|
||||
import (
|
||||
"github.com/anacrolix/torrent"
|
||||
"github.com/anacrolix/torrent/storage"
|
||||
"server/settings"
|
||||
)
|
||||
|
||||
type Piece struct {
|
||||
storage.PieceImpl `json:"-"`
|
||||
|
||||
Id int `json:"-"`
|
||||
Size int64 `json:"size"`
|
||||
|
||||
Complete bool `json:"complete"`
|
||||
Accessed int64 `json:"accessed"`
|
||||
|
||||
mPiece *MemPiece `json:"-"`
|
||||
dPiece *DiskPiece `json:"-"`
|
||||
|
||||
cache *Cache `json:"-"`
|
||||
}
|
||||
|
||||
func NewPiece(id int, cache *Cache) *Piece {
|
||||
p := &Piece{
|
||||
Id: id,
|
||||
cache: cache,
|
||||
}
|
||||
|
||||
if !settings.BTsets.UseDisk {
|
||||
p.mPiece = NewMemPiece(p)
|
||||
} else {
|
||||
p.dPiece = NewDiskPiece(p)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *Piece) WriteAt(b []byte, off int64) (n int, err error) {
|
||||
if !settings.BTsets.UseDisk {
|
||||
return p.mPiece.WriteAt(b, off)
|
||||
} else {
|
||||
return p.dPiece.WriteAt(b, off)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Piece) ReadAt(b []byte, off int64) (n int, err error) {
|
||||
if !settings.BTsets.UseDisk {
|
||||
return p.mPiece.ReadAt(b, off)
|
||||
} else {
|
||||
return p.dPiece.ReadAt(b, off)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Piece) MarkComplete() error {
|
||||
p.Complete = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Piece) MarkNotComplete() error {
|
||||
p.Complete = false
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Piece) Completion() storage.Completion {
|
||||
return storage.Completion{
|
||||
Complete: p.Complete,
|
||||
Ok: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Piece) Release() {
|
||||
if !settings.BTsets.UseDisk {
|
||||
p.mPiece.Release()
|
||||
} else {
|
||||
p.dPiece.Release()
|
||||
}
|
||||
if !p.cache.isClosed {
|
||||
p.cache.torrent.Piece(p.Id).SetPriority(torrent.PiecePriorityNone)
|
||||
p.cache.torrent.Piece(p.Id).UpdateCompletion()
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user