Files
MeBox/internal/service/site_download.go
T
2026-06-26 18:44:58 +08:00

176 lines
4.4 KiB
Go

package service
import (
"context"
"errors"
"fmt"
"io"
"mime"
"net/http"
"net/url"
"path"
"strings"
"time"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/model"
)
// ResolveDownloadURL converts tracker-specific search result URLs into a URL
// that a downloader can fetch directly. M-Team, NexusPHP and similar sites
// often expose a signed/detail endpoint in search results; qBittorrent cannot
// call those APIs with the configured site credentials, so subscriptions need
// the same resolution path as the manual download button.
func (s *SiteService) ResolveDownloadURL(ctx context.Context, raw string) string {
if strings.TrimSpace(raw) == "" {
return raw
}
matched := s.matchSiteForURL(ctx, raw)
if matched == nil {
return raw
}
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
return raw
}
id := u.Query().Get("id")
if id == "" {
return raw
}
adapter := GetAdapterForType(matched.Type)
if adapter == nil {
return raw
}
cfg := s.siteModelToConfig(matched)
timeout := cfg.Timeout
if timeout <= 0 {
timeout = 15 * time.Second
}
resolveCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
resolved, err := adapter.GetDownloadURL(resolveCtx, cfg, id)
if err != nil || resolved == "" {
if s.log != nil {
s.log.Warn("resolve PT download URL failed",
zap.String("site", matched.Name),
zap.String("raw", redactSensitiveDownloadURL(raw)),
zap.Error(err))
}
return raw
}
return resolved
}
func redactSensitiveDownloadURL(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
if strings.HasPrefix(strings.ToLower(raw), "magnet:") {
return "magnet:?xt=***"
}
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
return "[redacted-download-url]"
}
u.RawQuery = ""
u.Fragment = ""
return u.String()
}
func (s *SiteService) FetchTorrentFile(ctx context.Context, raw string) ([]byte, string, error) {
matched := s.matchSiteForURL(ctx, raw)
if matched == nil {
return nil, "", errors.New("no matching PT site for torrent URL")
}
cfg := s.siteModelToConfig(matched)
timeout := cfg.Timeout
if timeout <= 0 {
timeout = 30 * time.Second
}
req, err := buildRequest(ctx, http.MethodGet, raw, cfg, nil)
if err != nil {
return nil, "", err
}
req.Header.Set("Accept", "application/x-bittorrent,application/octet-stream,*/*")
client := newHTTPClient(cfg, timeout)
resp, err := client.Do(req)
if err != nil {
return nil, "", err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return nil, "", fmt.Errorf("torrent fetch: HTTP %d", resp.StatusCode)
}
const maxTorrentSize = 32 << 20
data, err := io.ReadAll(io.LimitReader(resp.Body, maxTorrentSize+1))
if err != nil {
return nil, "", err
}
if len(data) == 0 {
return nil, "", errors.New("torrent fetch: empty body")
}
if len(data) > maxTorrentSize {
return nil, "", errors.New("torrent fetch: body too large")
}
if strings.Contains(strings.ToLower(resp.Header.Get("Content-Type")), "text/html") {
return nil, "", errors.New("torrent fetch: upstream returned HTML")
}
if torrentInfoHash(data) == "" {
return nil, "", errors.New("torrent fetch: upstream did not return a valid torrent")
}
return data, torrentFilename(raw, resp.Header.Get("Content-Disposition")), nil
}
func (s *SiteService) matchSiteForURL(ctx context.Context, raw string) *model.Site {
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
return nil
}
host := strings.ToLower(u.Host)
sites, err := s.List(ctx)
if err != nil || len(sites) == 0 {
return nil
}
for i := range sites {
if siteHostMatches(host, sites[i].URL) || siteHostMatches(host, sites[i].RSSURL) {
return &sites[i]
}
}
return nil
}
func siteHostMatches(host, raw string) bool {
if raw == "" {
return false
}
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
return false
}
siteHost := strings.ToLower(u.Host)
return strings.EqualFold(siteHost, host) || strings.HasSuffix(host, "."+siteHost)
}
func torrentFilename(rawURL, disposition string) string {
if disposition != "" {
if _, params, err := mime.ParseMediaType(disposition); err == nil {
if filename := strings.TrimSpace(params["filename"]); filename != "" {
return filename
}
}
}
if u, err := url.Parse(rawURL); err == nil {
if name := strings.TrimSpace(path.Base(u.Path)); name != "" && name != "." && name != "/" {
if !strings.HasSuffix(strings.ToLower(name), ".torrent") {
name += ".torrent"
}
return name
}
}
return "download.torrent"
}