feat: torrent streaming with hls transcoding (#1)
* feat: add ffmpeg for hls streaming * feat: torrent streaming with hls transcoding - add nyaa.si torrent search client - add streaming service using anacrolix/torrent - add hls transcoding via ffmpeg for browser playback - add watch page with episode selection - add socks5 proxy support via TORRENT_PROXY env - switch to modernc.org/sqlite (pure go, no cgo conflicts) - update dockerfile with ffmpeg
This commit is contained in:
285
internal/nyaa/client.go
Normal file
285
internal/nyaa/client.go
Normal file
@@ -0,0 +1,285 @@
|
||||
package nyaa
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/golang-lru/v2/expirable"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
httpClient *http.Client
|
||||
baseURL string
|
||||
cache *expirable.LRU[string, []Torrent]
|
||||
magnetCache *expirable.LRU[string, string]
|
||||
}
|
||||
|
||||
type Torrent struct {
|
||||
Title string `json:"title"`
|
||||
Magnet string `json:"magnet"`
|
||||
Size string `json:"size"`
|
||||
Seeders int `json:"seeders"`
|
||||
Leechers int `json:"leechers"`
|
||||
Date string `json:"date"`
|
||||
Episode int `json:"episode"`
|
||||
ViewURL string `json:"view_url"`
|
||||
}
|
||||
|
||||
// RSS feed structures
|
||||
type rssResponse struct {
|
||||
XMLName xml.Name `xml:"rss"`
|
||||
Channel rssChannel `xml:"channel"`
|
||||
}
|
||||
|
||||
type rssChannel struct {
|
||||
Items []rssItem `xml:"item"`
|
||||
}
|
||||
|
||||
type rssItem struct {
|
||||
Title string `xml:"title"`
|
||||
Link string `xml:"link"`
|
||||
GUID string `xml:"guid"`
|
||||
PubDate string `xml:"pubDate"`
|
||||
Seeders string `xml:"seeders"`
|
||||
Leechers string `xml:"leechers"`
|
||||
Size string `xml:"size"`
|
||||
}
|
||||
|
||||
func NewClient() *Client {
|
||||
cache := expirable.NewLRU[string, []Torrent](200, nil, time.Minute*15)
|
||||
magnetCache := expirable.NewLRU[string, string](500, nil, time.Hour*24)
|
||||
return &Client{
|
||||
httpClient: &http.Client{Timeout: 15 * time.Second},
|
||||
baseURL: "https://nyaa.si",
|
||||
cache: cache,
|
||||
magnetCache: magnetCache,
|
||||
}
|
||||
}
|
||||
|
||||
// SearchAnime searches for anime torrents on nyaa.si
|
||||
// category 1_2 = Anime - English-translated
|
||||
func (c *Client) SearchAnime(query string) ([]Torrent, error) {
|
||||
if cached, ok := c.cache.Get(query); ok {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
// Build search URL with RSS feed
|
||||
params := url.Values{}
|
||||
params.Set("f", "0") // filter: no filter
|
||||
params.Set("c", "1_2") // category: Anime - English-translated
|
||||
params.Set("q", query)
|
||||
params.Set("s", "seeders") // sort by seeders
|
||||
params.Set("o", "desc") // descending order
|
||||
params.Set("page", "rss") // RSS format
|
||||
|
||||
reqURL := fmt.Sprintf("%s/?%s", c.baseURL, params.Encode())
|
||||
|
||||
resp, err := c.httpClient.Get(reqURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("nyaa request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("nyaa returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var rss rssResponse
|
||||
if err := xml.NewDecoder(resp.Body).Decode(&rss); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse nyaa rss: %w", err)
|
||||
}
|
||||
|
||||
torrents := make([]Torrent, 0, len(rss.Channel.Items))
|
||||
for _, item := range rss.Channel.Items {
|
||||
seeders, _ := strconv.Atoi(item.Seeders)
|
||||
leechers, _ := strconv.Atoi(item.Leechers)
|
||||
|
||||
// Extract torrent ID from download link
|
||||
// Link format: https://nyaa.si/download/1234567.torrent
|
||||
viewURL := extractViewURL(item.Link)
|
||||
|
||||
t := Torrent{
|
||||
Title: item.Title,
|
||||
Magnet: "", // Will be fetched on demand
|
||||
Size: item.Size,
|
||||
Seeders: seeders,
|
||||
Leechers: leechers,
|
||||
Date: item.PubDate,
|
||||
Episode: parseEpisodeNumber(item.Title),
|
||||
ViewURL: viewURL,
|
||||
}
|
||||
|
||||
// Check if GUID is already a magnet link
|
||||
if strings.HasPrefix(item.GUID, "magnet:") {
|
||||
t.Magnet = item.GUID
|
||||
}
|
||||
|
||||
torrents = append(torrents, t)
|
||||
}
|
||||
|
||||
// Fetch magnets for top results (limit to avoid rate limiting)
|
||||
for i := range torrents {
|
||||
if i >= 20 {
|
||||
break
|
||||
}
|
||||
if torrents[i].Magnet == "" && torrents[i].ViewURL != "" {
|
||||
magnet, err := c.fetchMagnet(torrents[i].ViewURL)
|
||||
if err == nil {
|
||||
torrents[i].Magnet = magnet
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.cache.Add(query, torrents)
|
||||
return torrents, nil
|
||||
}
|
||||
|
||||
// fetchMagnet scrapes the nyaa view page to get the magnet link
|
||||
func (c *Client) fetchMagnet(viewURL string) (string, error) {
|
||||
if cached, ok := c.magnetCache.Get(viewURL); ok {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Get(viewURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("nyaa returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Find magnet link in page
|
||||
// Pattern: href="magnet:?xt=urn:btih:..."
|
||||
magnetRe := regexp.MustCompile(`href="(magnet:\?xt=urn:btih:[^"]+)"`)
|
||||
matches := magnetRe.FindSubmatch(body)
|
||||
if len(matches) < 2 {
|
||||
return "", fmt.Errorf("magnet link not found")
|
||||
}
|
||||
|
||||
magnet := string(matches[1])
|
||||
c.magnetCache.Add(viewURL, magnet)
|
||||
return magnet, nil
|
||||
}
|
||||
|
||||
// extractViewURL converts download URL to view URL
|
||||
func extractViewURL(downloadURL string) string {
|
||||
// https://nyaa.si/download/1234567.torrent -> https://nyaa.si/view/1234567
|
||||
re := regexp.MustCompile(`/download/(\d+)\.torrent`)
|
||||
matches := re.FindStringSubmatch(downloadURL)
|
||||
if len(matches) < 2 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("https://nyaa.si/view/%s", matches[1])
|
||||
}
|
||||
|
||||
// SearchEpisode searches for a specific episode of an anime
|
||||
func (c *Client) SearchEpisode(animeTitle string, episode int) ([]Torrent, error) {
|
||||
// Format episode number with leading zero for single digits
|
||||
epStr := fmt.Sprintf("%02d", episode)
|
||||
query := fmt.Sprintf("%s %s", animeTitle, epStr)
|
||||
|
||||
torrents, err := c.SearchAnime(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Filter to torrents that match the episode number
|
||||
var filtered []Torrent
|
||||
for _, t := range torrents {
|
||||
if t.Episode == episode || t.Episode == 0 {
|
||||
// Episode 0 means we couldn't parse it, include anyway
|
||||
filtered = append(filtered, t)
|
||||
}
|
||||
}
|
||||
|
||||
// If no filtered results, return all (search might be specific enough)
|
||||
if len(filtered) == 0 {
|
||||
return torrents, nil
|
||||
}
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
// parseEpisodeNumber tries to extract episode number from torrent title
|
||||
func parseEpisodeNumber(title string) int {
|
||||
patterns := []*regexp.Regexp{
|
||||
regexp.MustCompile(`(?i)[-–]\s*(\d{1,4})(?:v\d)?(?:\s|\[|$)`), // - 01 or - 01v2
|
||||
regexp.MustCompile(`(?i)\bE(\d{1,4})(?:v\d)?(?:\s|\[|$)`), // E01
|
||||
regexp.MustCompile(`(?i)S\d{1,2}E(\d{1,4})(?:v\d)?(?:\s|\[|$)`), // S01E01
|
||||
regexp.MustCompile(`(?i)Episode\s*(\d{1,4})(?:v\d)?(?:\s|\[|$)`), // Episode 01
|
||||
regexp.MustCompile(`(?i)\s(\d{2,4})(?:v\d)?\s*[\[\(]`), // 01 [quality]
|
||||
}
|
||||
|
||||
for _, re := range patterns {
|
||||
if matches := re.FindStringSubmatch(title); len(matches) > 1 {
|
||||
if ep, err := strconv.Atoi(matches[1]); err == nil {
|
||||
return ep
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// FilterByQuality returns torrents matching the quality preference
|
||||
func FilterByQuality(torrents []Torrent, quality string) []Torrent {
|
||||
if quality == "" {
|
||||
return torrents
|
||||
}
|
||||
|
||||
var filtered []Torrent
|
||||
qualityLower := strings.ToLower(quality)
|
||||
|
||||
for _, t := range torrents {
|
||||
titleLower := strings.ToLower(t.Title)
|
||||
if strings.Contains(titleLower, qualityLower) {
|
||||
filtered = append(filtered, t)
|
||||
}
|
||||
}
|
||||
|
||||
if len(filtered) == 0 {
|
||||
return torrents
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// BestTorrent returns the torrent with the most seeders that has a magnet
|
||||
func BestTorrent(torrents []Torrent) *Torrent {
|
||||
if len(torrents) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var best *Torrent
|
||||
for i := range torrents {
|
||||
if torrents[i].Magnet == "" {
|
||||
continue
|
||||
}
|
||||
if best == nil || torrents[i].Seeders > best.Seeders {
|
||||
best = &torrents[i]
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to first with magnet
|
||||
if best == nil {
|
||||
for i := range torrents {
|
||||
if torrents[i].Magnet != "" {
|
||||
return &torrents[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return best
|
||||
}
|
||||
Reference in New Issue
Block a user