mirror of
https://github.com/kodjodevf/mangayomi.git
synced 2026-01-11 22:40:36 +00:00
1010 lines
24 KiB
Go
1010 lines
24 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/anacrolix/torrent"
|
|
"github.com/anacrolix/torrent/metainfo"
|
|
"github.com/rs/cors"
|
|
)
|
|
|
|
var (
|
|
torrentCli *torrent.Client
|
|
torrentcliCfg *torrent.ClientConfig
|
|
// Cache for torrent metadata
|
|
torrentCache = sync.Map{}
|
|
// Worker pool for I/O operations
|
|
workerPool *WorkerPool
|
|
)
|
|
|
|
// WorkerPool manages a pool of goroutines for concurrent tasks
|
|
type WorkerPool struct {
|
|
workers int
|
|
jobQueue chan func()
|
|
quit chan bool
|
|
wg sync.WaitGroup
|
|
}
|
|
|
|
// NewWorkerPool creates a new worker pool
|
|
func NewWorkerPool(workers int) *WorkerPool {
|
|
if workers <= 0 {
|
|
workers = runtime.NumCPU()
|
|
}
|
|
|
|
pool := &WorkerPool{
|
|
workers: workers,
|
|
jobQueue: make(chan func(), workers*2),
|
|
quit: make(chan bool),
|
|
}
|
|
|
|
pool.start()
|
|
return pool
|
|
}
|
|
|
|
// start starts the workers
|
|
func (p *WorkerPool) start() {
|
|
for i := 0; i < p.workers; i++ {
|
|
p.wg.Add(1)
|
|
go p.worker()
|
|
}
|
|
}
|
|
|
|
// worker executes the jobs
|
|
func (p *WorkerPool) worker() {
|
|
defer p.wg.Done()
|
|
for {
|
|
select {
|
|
case job := <-p.jobQueue:
|
|
job()
|
|
case <-p.quit:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// Submit submits a job to the pool
|
|
func (p *WorkerPool) Submit(job func()) {
|
|
select {
|
|
case p.jobQueue <- job:
|
|
default:
|
|
// If pool is full, execute directly
|
|
go job()
|
|
}
|
|
}
|
|
|
|
// Stop stops the pool
|
|
func (p *WorkerPool) Stop() {
|
|
close(p.quit)
|
|
p.wg.Wait()
|
|
}
|
|
|
|
// TorrentMetadata caches torrent metadata
|
|
type TorrentMetadata struct {
|
|
InfoHash string
|
|
Name string
|
|
Files []FileMetadata
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
// FileMetadata contains file information
|
|
type FileMetadata struct {
|
|
Path string
|
|
Size int64
|
|
StreamURL string
|
|
}
|
|
|
|
func Start(config *Config) (int, error) {
|
|
// Initialize worker pool
|
|
workerPool = NewWorkerPool(runtime.NumCPU() * 2)
|
|
|
|
torrentcliCfg = torrent.NewDefaultClientConfig()
|
|
torrentcliCfg.DataDir = filepath.Clean(config.Path)
|
|
|
|
// Performance optimizations
|
|
torrentcliCfg.DisableUTP = false
|
|
torrentcliCfg.NoDHT = false
|
|
torrentcliCfg.NoDefaultPortForwarding = false
|
|
torrentcliCfg.DisablePEX = false
|
|
torrentcliCfg.AcceptPeerConnections = true
|
|
torrentcliCfg.EstablishedConnsPerTorrent = 80
|
|
torrentcliCfg.HalfOpenConnsPerTorrent = 25
|
|
torrentcliCfg.TorrentPeersHighWater = 200
|
|
torrentcliCfg.TorrentPeersLowWater = 50
|
|
|
|
log.Printf("[INFO] Download directory: %s", torrentcliCfg.DataDir)
|
|
log.Printf("[INFO] Worker pool size: %d", workerPool.workers)
|
|
|
|
var err error
|
|
torrentCli, err = torrent.NewClient(torrentcliCfg)
|
|
if err != nil {
|
|
log.Fatalf("[ERROR] BitTorrent client creation failed: %s", err)
|
|
}
|
|
|
|
// Optimized DNS configuration
|
|
configureDNS()
|
|
|
|
// HTTP server configuration with optimized timeouts
|
|
mux := setupRoutes()
|
|
c := configureCORS()
|
|
|
|
listener, err := net.Listen("tcp", config.Address)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
addr := listener.Addr().(*net.TCPAddr)
|
|
|
|
// Optimized HTTP server
|
|
server := &http.Server{
|
|
Handler: c.Handler(mux),
|
|
ReadTimeout: 30 * time.Second,
|
|
WriteTimeout: 30 * time.Second,
|
|
IdleTimeout: 120 * time.Second,
|
|
ReadHeaderTimeout: 10 * time.Second,
|
|
MaxHeaderBytes: 1 << 20, // 1MB
|
|
}
|
|
|
|
// Graceful shutdown handling
|
|
setupGracefulShutdown(server)
|
|
|
|
log.Printf("[INFO] Server listening on %s", addr.AddrPort())
|
|
|
|
go func() {
|
|
if err := server.Serve(listener); err != nil && err != http.ErrServerClosed {
|
|
log.Printf("[ERROR] Server error: %s", err)
|
|
}
|
|
}()
|
|
|
|
return addr.Port, nil
|
|
}
|
|
|
|
func setupRoutes() *http.ServeMux {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/torrent/addmagnet", addMagnet)
|
|
mux.HandleFunc("/torrent/stream", streamTorrent)
|
|
mux.HandleFunc("/torrent/remove", removeTorrent)
|
|
mux.HandleFunc("/torrent/torrents", listTorrents)
|
|
mux.HandleFunc("/torrent/play", playTorrent)
|
|
mux.HandleFunc("/torrent/add", AddTorrent)
|
|
mux.HandleFunc("/", Init)
|
|
return mux
|
|
}
|
|
|
|
func configureCORS() *cors.Cors {
|
|
return cors.New(cors.Options{
|
|
AllowedOrigins: []string{"*"},
|
|
AllowedMethods: []string{"GET", "POST", "DELETE"},
|
|
AllowCredentials: true,
|
|
MaxAge: 86400, // 24h cache
|
|
})
|
|
}
|
|
|
|
func setupGracefulShutdown(server *http.Server) {
|
|
sigs := make(chan os.Signal, 1)
|
|
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
|
|
|
|
go func() {
|
|
<-sigs
|
|
log.Println("[INFO] Shutdown signal received")
|
|
|
|
// Graceful server shutdown
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
if err := server.Shutdown(ctx); err != nil {
|
|
log.Printf("[ERROR] Server shutdown error: %s", err)
|
|
}
|
|
|
|
// Torrent cleanup
|
|
cleanupTorrents()
|
|
|
|
// Stop worker pool
|
|
if workerPool != nil {
|
|
workerPool.Stop()
|
|
}
|
|
|
|
os.Exit(0)
|
|
}()
|
|
}
|
|
|
|
func cleanupTorrents() {
|
|
log.Println("[INFO] Cleaning up torrents...")
|
|
for _, t := range torrentCli.Torrents() {
|
|
log.Printf("[INFO] Removing torrent: [%s]", t.Name())
|
|
t.Drop()
|
|
if err := os.RemoveAll(filepath.Join(torrentcliCfg.DataDir, t.Name())); err != nil {
|
|
log.Printf("[ERROR] Failed to remove torrent files [%s]: %s", t.Name(), err)
|
|
}
|
|
}
|
|
if torrentCli != nil {
|
|
torrentCli.Close()
|
|
}
|
|
}
|
|
|
|
func safenDisplayPath(displayPath string) string {
|
|
return strings.ReplaceAll(displayPath, "/", " ")
|
|
}
|
|
|
|
func appendFilePlaylist(scheme, host, infohash, name string) string {
|
|
var sb strings.Builder
|
|
sb.WriteString("#EXTINF:-1,")
|
|
sb.WriteString(safenDisplayPath(name))
|
|
sb.WriteString("\n")
|
|
sb.WriteString(scheme)
|
|
sb.WriteString("://")
|
|
sb.WriteString(host)
|
|
sb.WriteString("/torrent/stream?infohash=")
|
|
sb.WriteString(infohash)
|
|
sb.WriteString("&file=")
|
|
sb.WriteString(url.QueryEscape(name))
|
|
sb.WriteString("\n")
|
|
return sb.String()
|
|
}
|
|
|
|
func nameCheck(str, substr string) bool {
|
|
strLower := strings.ToLower(str)
|
|
words := strings.Fields(strings.ToLower(substr))
|
|
for _, word := range words {
|
|
if !strings.Contains(strLower, word) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func getTorrentFile(files []*torrent.File, filename string, exactName bool) *torrent.File {
|
|
if filename == "" && !exactName {
|
|
return nil
|
|
}
|
|
|
|
for _, file := range files {
|
|
if exactName && file.DisplayPath() == filename {
|
|
return file
|
|
}
|
|
if !exactName && nameCheck(file.DisplayPath(), filename) {
|
|
return file
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// configureDNS optimizes DNS resolution
|
|
func configureDNS() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
addrs, err := net.DefaultResolver.LookupHost(ctx, "www.google.com")
|
|
if len(addrs) == 0 || err != nil {
|
|
log.Printf("[WARN] DNS check failed, using Cloudflare DNS: %v", err)
|
|
|
|
net.DefaultResolver = &net.Resolver{
|
|
PreferGo: true,
|
|
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
|
|
d := net.Dialer{Timeout: 5 * time.Second}
|
|
return d.DialContext(ctx, "udp", "1.1.1.1:53")
|
|
},
|
|
}
|
|
|
|
// Test with Cloudflare DNS
|
|
ctx2, cancel2 := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel2()
|
|
addrs, err = net.DefaultResolver.LookupHost(ctx2, "www.google.com")
|
|
log.Printf("[INFO] Cloudflare DNS test: %v, error: %v", len(addrs) > 0, err)
|
|
} else {
|
|
log.Printf("[INFO] DNS check OK: %d addresses", len(addrs))
|
|
}
|
|
}
|
|
|
|
// getCachedTorrentMetadata retrieves metadata from cache
|
|
func getCachedTorrentMetadata(infoHash string) (*TorrentMetadata, bool) {
|
|
if cached, ok := torrentCache.Load(infoHash); ok {
|
|
meta := cached.(*TorrentMetadata)
|
|
// Cache valid for 5 minutes
|
|
if time.Since(meta.UpdatedAt) < 5*time.Minute {
|
|
return meta, true
|
|
}
|
|
// Remove expired cache
|
|
torrentCache.Delete(infoHash)
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
// cacheTorrentMetadata caches metadata
|
|
func cacheTorrentMetadata(t *torrent.Torrent) *TorrentMetadata {
|
|
meta := &TorrentMetadata{
|
|
InfoHash: t.InfoHash().String(),
|
|
Name: t.Name(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
|
|
if t.Info() != nil {
|
|
meta.Files = make([]FileMetadata, 0, len(t.Files()))
|
|
for _, file := range t.Files() {
|
|
meta.Files = append(meta.Files, FileMetadata{
|
|
Path: file.DisplayPath(),
|
|
Size: file.Length(),
|
|
StreamURL: makePlayStreamURL(meta.InfoHash, file.DisplayPath(), true),
|
|
})
|
|
}
|
|
}
|
|
|
|
torrentCache.Store(meta.InfoHash, meta)
|
|
return meta
|
|
}
|
|
|
|
func makePlayStreamURL(infohash, filename string, isStream bool) string {
|
|
var sb strings.Builder
|
|
sb.WriteString("/torrent/")
|
|
if isStream {
|
|
sb.WriteString("stream")
|
|
} else {
|
|
sb.WriteString("play")
|
|
}
|
|
sb.WriteString("?infohash=")
|
|
sb.WriteString(infohash)
|
|
if filename != "" {
|
|
sb.WriteString("&file=")
|
|
sb.WriteString(url.QueryEscape(filename))
|
|
}
|
|
return sb.String()
|
|
}
|
|
|
|
func httpJSONError(w http.ResponseWriter, error string, code int) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(code)
|
|
if err := json.NewEncoder(w).Encode(errorRes{Error: error}); err != nil {
|
|
http.Error(w, error, code)
|
|
}
|
|
}
|
|
|
|
func parseRequestBody(w http.ResponseWriter, r *http.Request, v interface{}) error {
|
|
defer r.Body.Close()
|
|
decoder := json.NewDecoder(r.Body)
|
|
decoder.DisallowUnknownFields() // Additional security
|
|
err := decoder.Decode(v)
|
|
if err != nil {
|
|
httpJSONError(w, "Request JSON body decode error", http.StatusBadRequest)
|
|
}
|
|
return err
|
|
}
|
|
|
|
func makeJSONResponse(w http.ResponseWriter, v interface{}) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
encoder := json.NewEncoder(w)
|
|
if err := encoder.Encode(v); err != nil {
|
|
httpJSONError(w, "Response JSON body encode error", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
// getInfoWithTimeout waits for torrent information with timeout
|
|
func getInfoWithTimeout(t *torrent.Torrent, timeout time.Duration) error {
|
|
if t == nil {
|
|
return nil
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
defer cancel()
|
|
|
|
select {
|
|
case <-t.GotInfo():
|
|
return nil
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
}
|
|
}
|
|
|
|
func initMagnet(w http.ResponseWriter, magnet string, alldn, alltr []string) *torrent.Torrent {
|
|
var sb strings.Builder
|
|
sb.WriteString(magnet)
|
|
|
|
for _, dn := range alldn {
|
|
sb.WriteString("&dn=")
|
|
sb.WriteString(url.QueryEscape(dn))
|
|
}
|
|
for _, tr := range alltr {
|
|
sb.WriteString("&tr=")
|
|
sb.WriteString(url.QueryEscape(tr))
|
|
}
|
|
|
|
t, err := torrentCli.AddMagnet(sb.String())
|
|
if err != nil {
|
|
httpJSONError(w, "Torrent add error: "+err.Error(), http.StatusInternalServerError)
|
|
return nil
|
|
}
|
|
|
|
// Wait for information with timeout
|
|
if err := getInfoWithTimeout(t, 30*time.Second); err != nil {
|
|
log.Printf("[WARN] Timeout waiting for torrent info: %s", err)
|
|
}
|
|
|
|
// Cache metadata
|
|
cacheTorrentMetadata(t)
|
|
|
|
return t
|
|
}
|
|
|
|
func getTorrent(w http.ResponseWriter, infoHash string) *torrent.Torrent {
|
|
if len(infoHash) != 40 {
|
|
httpJSONError(w, "InfoHash not valid", http.StatusBadRequest)
|
|
return nil
|
|
}
|
|
|
|
// Check cache first
|
|
if _, found := getCachedTorrentMetadata(infoHash); found {
|
|
if t, ok := torrentCli.Torrent(metainfo.NewHashFromHex(infoHash)); ok {
|
|
return t
|
|
}
|
|
// Remove from cache if torrent no longer exists
|
|
torrentCache.Delete(infoHash)
|
|
}
|
|
|
|
t, ok := torrentCli.Torrent(metainfo.NewHashFromHex(infoHash))
|
|
if !ok {
|
|
httpJSONError(w, "Torrent not found", http.StatusNotFound)
|
|
return nil
|
|
}
|
|
|
|
if t == nil {
|
|
httpJSONError(w, "Torrent is nil", http.StatusInternalServerError)
|
|
return nil
|
|
}
|
|
|
|
// Wait for information with timeout
|
|
if err := getInfoWithTimeout(t, 10*time.Second); err != nil {
|
|
log.Printf("[WARN] Timeout waiting for torrent info: %s", err)
|
|
// Don't return nil here, torrent may still be usable
|
|
}
|
|
|
|
// Cache metadata only if we have the info
|
|
if t.Info() != nil {
|
|
cacheTorrentMetadata(t)
|
|
}
|
|
|
|
return t
|
|
}
|
|
|
|
func parseTorrentStats(t *torrent.Torrent) torrentStatsRes {
|
|
// Protection against nil pointers
|
|
if t == nil {
|
|
return torrentStatsRes{}
|
|
}
|
|
|
|
// Use cache if available
|
|
if meta, found := getCachedTorrentMetadata(t.InfoHash().String()); found {
|
|
stats := t.Stats()
|
|
return torrentStatsRes{
|
|
InfoHash: meta.InfoHash,
|
|
Name: meta.Name,
|
|
TotalPeers: stats.TotalPeers,
|
|
ActivePeers: stats.ActivePeers,
|
|
HalfOpenPeers: stats.HalfOpenPeers,
|
|
PendingPeers: stats.PendingPeers,
|
|
Files: buildFilesFromCache(meta, t),
|
|
}
|
|
}
|
|
|
|
// Fallback to original method
|
|
var tsRes torrentStatsRes
|
|
|
|
tsRes.InfoHash = t.InfoHash().String()
|
|
tsRes.Name = t.Name()
|
|
|
|
stats := t.Stats()
|
|
tsRes.TotalPeers = stats.TotalPeers
|
|
tsRes.ActivePeers = stats.ActivePeers
|
|
tsRes.HalfOpenPeers = stats.HalfOpenPeers
|
|
tsRes.PendingPeers = stats.PendingPeers
|
|
|
|
if t.Info() == nil {
|
|
return tsRes
|
|
}
|
|
|
|
files := t.Files()
|
|
if files == nil {
|
|
return tsRes
|
|
}
|
|
|
|
tsRes.Files.OnTorrent = make([]torrentStatsFilesOnTorrent, 0, len(files))
|
|
|
|
for _, tFile := range files {
|
|
if tFile == nil {
|
|
continue
|
|
}
|
|
tsRes.Files.OnTorrent = append(tsRes.Files.OnTorrent, torrentStatsFilesOnTorrent{
|
|
FileName: tFile.DisplayPath(),
|
|
FileSizeBytes: int(tFile.Length()),
|
|
})
|
|
|
|
if tFile.BytesCompleted() > 0 {
|
|
tsRes.Files.OnDisk = append(tsRes.Files.OnDisk, torrentStatsFilesOnDisk{
|
|
FileName: tFile.DisplayPath(),
|
|
StreamURL: makePlayStreamURL(t.InfoHash().String(), tFile.DisplayPath(), true),
|
|
BytesDownloaded: int(tFile.BytesCompleted()),
|
|
FileSizeBytes: int(tFile.Length()),
|
|
})
|
|
}
|
|
}
|
|
|
|
// Update cache only if we have complete info
|
|
if t.Info() != nil {
|
|
cacheTorrentMetadata(t)
|
|
}
|
|
|
|
return tsRes
|
|
}
|
|
|
|
func buildFilesFromCache(meta *TorrentMetadata, t *torrent.Torrent) torrentStatsFiles {
|
|
var files torrentStatsFiles
|
|
files.OnTorrent = make([]torrentStatsFilesOnTorrent, 0, len(meta.Files))
|
|
|
|
for _, fileMeta := range meta.Files {
|
|
files.OnTorrent = append(files.OnTorrent, torrentStatsFilesOnTorrent{
|
|
FileName: fileMeta.Path,
|
|
FileSizeBytes: int(fileMeta.Size),
|
|
})
|
|
|
|
// Check if file is downloaded (requires access to real torrent)
|
|
if tFile := getTorrentFile(t.Files(), fileMeta.Path, true); tFile != nil && tFile.BytesCompleted() > 0 {
|
|
files.OnDisk = append(files.OnDisk, torrentStatsFilesOnDisk{
|
|
FileName: fileMeta.Path,
|
|
StreamURL: fileMeta.StreamURL,
|
|
BytesDownloaded: int(tFile.BytesCompleted()),
|
|
FileSizeBytes: int(fileMeta.Size),
|
|
})
|
|
}
|
|
}
|
|
|
|
return files
|
|
}
|
|
|
|
func addMagnet(w http.ResponseWriter, r *http.Request) {
|
|
var amBody addMagnetBody
|
|
var amRes addMagnetRes
|
|
|
|
if parseRequestBody(w, r, &amBody) != nil {
|
|
return
|
|
}
|
|
|
|
if amBody.Magnet == "" {
|
|
httpJSONError(w, "Magnet link is not provided", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
t := initMagnet(w, amBody.Magnet, []string{}, []string{})
|
|
if t == nil {
|
|
return
|
|
}
|
|
|
|
amRes.InfoHash = t.InfoHash().String()
|
|
amRes.Name = t.Name()
|
|
|
|
if amBody.AllFiles {
|
|
amRes.PlaylistURL = makePlayStreamURL(t.InfoHash().String(), "", false)
|
|
for _, tFile := range t.Files() {
|
|
amRes.Files = append(amRes.Files, addMagnetFiles{
|
|
FileName: tFile.DisplayPath(),
|
|
StreamURL: makePlayStreamURL(t.InfoHash().String(), tFile.DisplayPath(), true),
|
|
FileSizeBytes: int(tFile.Length()),
|
|
})
|
|
}
|
|
makeJSONResponse(w, &amRes)
|
|
return
|
|
}
|
|
|
|
if len(amBody.Files) > 0 {
|
|
amRes.PlaylistURL = makePlayStreamURL(t.InfoHash().String(), "", false)
|
|
for _, selFile := range amBody.Files {
|
|
tFile := getTorrentFile(t.Files(), selFile, false)
|
|
if tFile == nil {
|
|
continue
|
|
}
|
|
amRes.PlaylistURL += "&file=" + url.QueryEscape(tFile.DisplayPath())
|
|
amRes.Files = append(amRes.Files, addMagnetFiles{
|
|
FileName: tFile.DisplayPath(),
|
|
StreamURL: makePlayStreamURL(t.InfoHash().String(), tFile.DisplayPath(), true),
|
|
FileSizeBytes: int(tFile.Length()),
|
|
})
|
|
}
|
|
makeJSONResponse(w, &amRes)
|
|
return
|
|
}
|
|
|
|
for _, tFile := range t.Files() {
|
|
amRes.Files = append(amRes.Files, addMagnetFiles{
|
|
FileName: tFile.DisplayPath(),
|
|
FileSizeBytes: int(tFile.Length()),
|
|
})
|
|
}
|
|
makeJSONResponse(w, &amRes)
|
|
}
|
|
|
|
func streamTorrent(w http.ResponseWriter, r *http.Request) {
|
|
query := r.URL.Query()
|
|
infoHashParams, ihOk := query["infohash"]
|
|
fileNameParams, fnOk := query["file"]
|
|
|
|
if !ihOk || !fnOk || len(infoHashParams) == 0 || len(fileNameParams) == 0 {
|
|
httpJSONError(w, "InfoHash or File is not provided", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
infoHash := infoHashParams[0]
|
|
fileName := fileNameParams[0]
|
|
|
|
t := getTorrent(w, infoHash)
|
|
if t == nil {
|
|
return
|
|
}
|
|
|
|
tFile := getTorrentFile(t.Files(), fileName, true)
|
|
if tFile == nil {
|
|
httpJSONError(w, "File not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
fileReader := tFile.NewReader()
|
|
defer fileReader.Close()
|
|
|
|
readaheadSize := calculateReadahead(tFile.Length())
|
|
fileReader.SetReadahead(readaheadSize)
|
|
|
|
// Headers for optimized streaming
|
|
w.Header().Set("Accept-Ranges", "bytes")
|
|
w.Header().Set("Content-Type", getContentType(fileName))
|
|
w.Header().Set("Cache-Control", "public, max-age=3600")
|
|
|
|
http.ServeContent(w, r, tFile.DisplayPath(), time.Now(), fileReader)
|
|
}
|
|
|
|
// calculateReadahead calculates optimal readahead size
|
|
func calculateReadahead(fileSize int64) int64 {
|
|
switch {
|
|
case fileSize < 100*1024*1024: // < 100MB
|
|
return fileSize / 20 // 5%
|
|
case fileSize < 1024*1024*1024: // < 1GB
|
|
return fileSize / 50 // 2%
|
|
default:
|
|
return fileSize / 100 // 1%
|
|
}
|
|
}
|
|
|
|
// getContentType determines file MIME type
|
|
func getContentType(filename string) string {
|
|
ext := strings.ToLower(filepath.Ext(filename))
|
|
switch ext {
|
|
// Video formats
|
|
case ".mp4":
|
|
return "video/mp4"
|
|
case ".mkv":
|
|
return "video/x-matroska"
|
|
case ".avi":
|
|
return "video/x-msvideo"
|
|
case ".mov":
|
|
return "video/quicktime"
|
|
case ".wmv":
|
|
return "video/x-ms-wmv"
|
|
case ".webm":
|
|
return "video/webm"
|
|
case ".flv":
|
|
return "video/x-flv"
|
|
case ".m4v":
|
|
return "video/x-m4v"
|
|
case ".3gp":
|
|
return "video/3gpp"
|
|
case ".3g2":
|
|
return "video/3gpp2"
|
|
case ".ts":
|
|
return "video/mp2t"
|
|
case ".mts":
|
|
return "video/mp2t"
|
|
case ".m2ts":
|
|
return "video/mp2t"
|
|
case ".vob":
|
|
return "video/dvd"
|
|
case ".ogv":
|
|
return "video/ogg"
|
|
case ".asf":
|
|
return "video/x-ms-asf"
|
|
case ".rm":
|
|
return "application/vnd.rn-realmedia"
|
|
case ".rmvb":
|
|
return "application/vnd.rn-realmedia-vbr"
|
|
case ".f4v":
|
|
return "video/x-f4v"
|
|
case ".mpg", ".mpeg":
|
|
return "video/mpeg"
|
|
case ".m1v":
|
|
return "video/mpeg"
|
|
case ".m2v":
|
|
return "video/mpeg"
|
|
case ".divx":
|
|
return "video/divx"
|
|
case ".xvid":
|
|
return "video/x-msvideo"
|
|
// Audio formats
|
|
case ".mp3":
|
|
return "audio/mpeg"
|
|
case ".flac":
|
|
return "audio/flac"
|
|
case ".wav":
|
|
return "audio/wav"
|
|
case ".aac":
|
|
return "audio/aac"
|
|
case ".ogg":
|
|
return "audio/ogg"
|
|
case ".wma":
|
|
return "audio/x-ms-wma"
|
|
case ".m4a":
|
|
return "audio/mp4"
|
|
case ".opus":
|
|
return "audio/opus"
|
|
case ".ac3":
|
|
return "audio/ac3"
|
|
case ".dts":
|
|
return "audio/dts"
|
|
default:
|
|
return "application/octet-stream"
|
|
}
|
|
}
|
|
|
|
func removeTorrent(w http.ResponseWriter, r *http.Request) {
|
|
query := r.URL.Query()
|
|
infoHashParams, ihOk := query["infohash"]
|
|
|
|
if !ihOk || len(infoHashParams) == 0 {
|
|
httpJSONError(w, "InfoHash is not provided", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
infoHash := infoHashParams[0]
|
|
t := getTorrent(w, infoHash)
|
|
if t == nil {
|
|
return
|
|
}
|
|
|
|
name := t.Name()
|
|
|
|
// Immediate response before cleanup
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte("Torrent removal initiated"))
|
|
|
|
// Asynchronous deletion after response
|
|
workerPool.Submit(func() {
|
|
t.Drop()
|
|
|
|
// Clean cache
|
|
torrentCache.Delete(infoHash)
|
|
|
|
// Remove files
|
|
if err := os.RemoveAll(filepath.Join(torrentcliCfg.DataDir, name)); err != nil {
|
|
log.Printf("[ERROR] Failed to remove torrent files [%s]: %s", name, err)
|
|
return
|
|
}
|
|
|
|
log.Printf("[INFO] Successfully removed torrent: %s", name)
|
|
})
|
|
}
|
|
|
|
func listTorrents(w http.ResponseWriter, r *http.Request) {
|
|
query := r.URL.Query()
|
|
infoHashParams, ihOk := query["infohash"]
|
|
|
|
var ltRes listTorrentsRes
|
|
allTorrents := torrentCli.Torrents()
|
|
|
|
if ihOk && len(infoHashParams) > 0 {
|
|
t := getTorrent(w, infoHashParams[0])
|
|
if t == nil {
|
|
return
|
|
}
|
|
allTorrents = []*torrent.Torrent{t}
|
|
}
|
|
|
|
// Parallel processing of statistics
|
|
type result struct {
|
|
stats torrentStatsRes
|
|
index int
|
|
}
|
|
|
|
results := make(chan result, len(allTorrents))
|
|
var wg sync.WaitGroup
|
|
|
|
for i, t := range allTorrents {
|
|
wg.Add(1)
|
|
go func(idx int, torrent *torrent.Torrent) {
|
|
defer wg.Done()
|
|
stats := parseTorrentStats(torrent)
|
|
results <- result{stats: stats, index: idx}
|
|
}(i, t)
|
|
}
|
|
|
|
go func() {
|
|
wg.Wait()
|
|
close(results)
|
|
}()
|
|
|
|
// Collect results in order
|
|
statsMap := make(map[int]torrentStatsRes)
|
|
for res := range results {
|
|
statsMap[res.index] = res.stats
|
|
}
|
|
|
|
// Rebuild list in order
|
|
ltRes.Torrents = make([]torrentStatsRes, 0, len(allTorrents))
|
|
for i := 0; i < len(allTorrents); i++ {
|
|
if stats, exists := statsMap[i]; exists {
|
|
ltRes.Torrents = append(ltRes.Torrents, stats)
|
|
}
|
|
}
|
|
|
|
if !ihOk && len(ltRes.Torrents) == 0 {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
makeJSONResponse(w, <Res)
|
|
}
|
|
|
|
func AddTorrent(w http.ResponseWriter, r *http.Request) {
|
|
// Limit upload file size
|
|
r.ParseMultipartForm(100 << 20) // 100MB max
|
|
|
|
file, _, err := r.FormFile("file")
|
|
if err != nil {
|
|
log.Printf("[ERROR] Upload error: %s", err)
|
|
httpJSONError(w, "File upload error", http.StatusBadRequest)
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
metainf, err := metainfo.Load(file)
|
|
if err != nil {
|
|
log.Printf("[ERROR] MetaInfo load error: %s", err)
|
|
httpJSONError(w, "Invalid torrent file", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
torrent, err := torrentCli.AddTorrent(metainf)
|
|
if err != nil {
|
|
log.Printf("[ERROR] Add torrent error: %s", err)
|
|
httpJSONError(w, "Failed to add torrent", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Wait for information with timeout
|
|
if err := getInfoWithTimeout(torrent, 30*time.Second); err != nil {
|
|
log.Printf("[WARN] Timeout waiting for torrent info: %s", err)
|
|
}
|
|
|
|
// Cache metadata
|
|
cacheTorrentMetadata(torrent)
|
|
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
w.Write([]byte(torrent.InfoHash().HexString()))
|
|
}
|
|
|
|
func Init(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
w.Write([]byte("Torrent server is running - Optimized version"))
|
|
}
|
|
|
|
func playTorrent(w http.ResponseWriter, r *http.Request) {
|
|
infoHash, ihOk := r.URL.Query()["infohash"]
|
|
magnet, magOk := r.URL.Query()["magnet"]
|
|
displayName := r.URL.Query()["dn"]
|
|
trackers := r.URL.Query()["tr"]
|
|
files, fOk := r.URL.Query()["file"]
|
|
|
|
if !magOk && !ihOk {
|
|
httpJSONError(w, "Magnet link or InfoHash is not provided", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
var t *torrent.Torrent
|
|
if magOk && !ihOk {
|
|
t = initMagnet(w, magnet[0], displayName, trackers)
|
|
}
|
|
|
|
if ihOk && !magOk {
|
|
t = getTorrent(w, infoHash[0])
|
|
}
|
|
|
|
if t == nil {
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Disposition", "attachment; filename=\""+t.InfoHash().String()+".m3u\"")
|
|
playList := "#EXTM3U\n"
|
|
|
|
httpScheme := "http"
|
|
if r.Header.Get("X-Forwarded-Proto") != "" {
|
|
httpScheme = r.Header.Get("X-Forwarded-Proto")
|
|
}
|
|
|
|
if !fOk {
|
|
for _, tFile := range t.Files() {
|
|
playList += appendFilePlaylist(httpScheme, r.Host, t.InfoHash().String(), tFile.DisplayPath())
|
|
}
|
|
}
|
|
|
|
for _, file := range files {
|
|
tFile := getTorrentFile(t.Files(), file, false)
|
|
if tFile == nil {
|
|
continue
|
|
}
|
|
playList += appendFilePlaylist(httpScheme, r.Host, t.InfoHash().String(), tFile.DisplayPath())
|
|
}
|
|
|
|
w.Write([]byte(playList))
|
|
}
|
|
|
|
type errorRes struct {
|
|
Error string
|
|
}
|
|
|
|
type addMagnetBody struct {
|
|
Magnet string
|
|
AllFiles bool
|
|
Files []string
|
|
}
|
|
|
|
type addMagnetRes struct {
|
|
InfoHash string
|
|
Name string
|
|
PlaylistURL string
|
|
Files []addMagnetFiles
|
|
}
|
|
|
|
type addMagnetFiles struct {
|
|
FileName string
|
|
StreamURL string
|
|
FileSizeBytes int
|
|
}
|
|
|
|
type listTorrentsRes struct {
|
|
Torrents []torrentStatsRes
|
|
}
|
|
|
|
type torrentStatsRes struct {
|
|
InfoHash string
|
|
Name string
|
|
TotalPeers int
|
|
ActivePeers int
|
|
PendingPeers int
|
|
HalfOpenPeers int
|
|
Files torrentStatsFiles
|
|
}
|
|
|
|
type torrentStatsFiles struct {
|
|
OnTorrent []torrentStatsFilesOnTorrent
|
|
OnDisk []torrentStatsFilesOnDisk
|
|
}
|
|
|
|
type torrentStatsFilesOnTorrent struct {
|
|
FileName string
|
|
FileSizeBytes int
|
|
}
|
|
|
|
type torrentStatsFilesOnDisk struct {
|
|
FileName string
|
|
StreamURL string
|
|
BytesDownloaded int
|
|
FileSizeBytes int
|
|
}
|
|
|
|
type Config struct {
|
|
Address string `json:"address"`
|
|
Path string `json:"path"`
|
|
}
|