package torrent import ( "fmt" "io" "net/http" "path" "strings" "time" "github.com/rs/zerolog/log" pb "homelab.lan/music-agregator/gen/music_agregator/torrent/v1" ) type TorrentService struct { client TorrentClient token string } func NewTorrentService(client TorrentClient) *TorrentService { return &TorrentService{ client: client, } } func (service *TorrentService) List(req *pb.ListRequest) (*pb.ListResponse, error) { torrents, err := service.client.List() if err != nil { return nil, err } return &pb.ListResponse{Items: toProtoItems(torrents)}, nil } func (service *TorrentService) Add(req *pb.AddRequest) (*pb.AddResponse, error) { var file TorrentFile if len(req.GetTorrentData()) > 0 { file = TorrentFile{ Filename: req.GetFilename(), Data: req.GetTorrentData(), } } else if req.GetDownloadUrl() != "" { downloaded, err := downloadTorrentFile(req.GetDownloadUrl()) if err != nil { return nil, err } file = *downloaded } else { return nil, fmt.Errorf("either torrent_data or download_url must be provided") } if err := service.client.AddTorrent(file, ""); err != nil { return nil, err } time.Sleep(500 * time.Millisecond) searchName := strings.TrimSuffix(file.Filename, ".torrent") torrents, err := service.client.Find(FindOptions{Name: searchName}) if err != nil { log.Warn().Err(err).Msg("torrent added but failed to find it afterwards") return &pb.AddResponse{}, nil } if len(torrents) == 0 { log.Warn().Str("filename", file.Filename).Msg("torrent added but not found in client") return &pb.AddResponse{}, nil } return &pb.AddResponse{Item: toProtoItem(torrents[0])}, nil } func toProtoItems(torrents []TorrentInfo) []*pb.ListItem { items := make([]*pb.ListItem, len(torrents)) for i, t := range torrents { items[i] = toProtoItem(t) } return items } func toProtoItem(t TorrentInfo) *pb.ListItem { return &pb.ListItem{ Hash: t.Hash, Name: t.Name, Size: t.Size, Progress: t.Progress, Dlspeed: t.DlSpeed, Upspeed: t.UpSpeed, NumSeeds: t.NumSeeds, NumLeechs: t.NumLeechs, State: t.State, Eta: t.ETA, Ratio: t.Ratio, Category: t.Category, Tags: t.Tags, AddedOn: t.AddedOn, CompletionOn: t.CompletionOn, SavePath: t.SavePath, ContentPath: t.ContentPath, Downloaded: t.Downloaded, Uploaded: t.Uploaded, Tracker: t.Tracker, SeedingTime: t.SeedingTime, AmountLeft: t.AmountLeft, Availability: t.Availability, SizeFormatted: formatBytes(t.Size), ProgressFormatted: fmt.Sprintf("%.1f%%", t.Progress*100), DlspeedFormatted: formatSpeed(t.DlSpeed), UpspeedFormatted: formatSpeed(t.UpSpeed), AddedOnFormatted: formatTimestamp(t.AddedOn), CompletionOnFormatted: formatTimestamp(t.CompletionOn), DownloadedFormatted: formatBytes(t.Downloaded), UploadedFormatted: formatBytes(t.Uploaded), AmountLeftFormatted: formatBytes(t.AmountLeft), AvailabilityFormatted: fmt.Sprintf("%.2f", t.Availability), EtaFormatted: formatETA(t.ETA), } } func formatBytes(b int64) string { switch { case b >= 1<<30: return fmt.Sprintf("%.2f GB", float64(b)/float64(1<<30)) case b >= 1<<20: return fmt.Sprintf("%.1f MB", float64(b)/float64(1<<20)) case b >= 1<<10: return fmt.Sprintf("%.0f KB", float64(b)/float64(1<<10)) default: return fmt.Sprintf("%d B", b) } } func formatSpeed(bytesPerSec int64) string { if bytesPerSec == 0 { return "0 B/s" } return formatBytes(bytesPerSec) + "/s" } func formatTimestamp(ts int64) string { if ts <= 0 { return "" } return time.Unix(ts, 0).Format("2006-01-02 15:04:05") } func formatETA(seconds int64) string { if seconds <= 0 || seconds >= 8640000 { return "∞" } d := time.Duration(seconds) * time.Second h := int(d.Hours()) m := int(d.Minutes()) % 60 s := int(d.Seconds()) % 60 if h > 0 { return fmt.Sprintf("%dh %dm %ds", h, m, s) } if m > 0 { return fmt.Sprintf("%dm %ds", m, s) } return fmt.Sprintf("%ds", s) } func downloadTorrentFile(url string) (*TorrentFile, error) { log.Trace().Str("url", url).Msg("downloading torrent file") client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Get(url) if err != nil { log.Error().Err(err).Str("url", url).Msg("downloading torrent file failed") return nil, fmt.Errorf("downloading torrent file: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { log.Error().Int("status", resp.StatusCode).Str("url", url).Msg("torrent download returned non-OK status") return nil, fmt.Errorf("torrent download returned status %d", resp.StatusCode) } data, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("reading torrent file: %w", err) } filename := path.Base(resp.Request.URL.Path) if !strings.HasSuffix(strings.ToLower(filename), ".torrent") { filename += ".torrent" } log.Debug().Str("filename", filename).Int("size", len(data)).Msg("torrent file downloaded") return &TorrentFile{ Filename: filename, Data: data, }, nil }