feat: initial commit — unarr CLI
Search, inspect, stream, and download torrents from the terminal. Replaces the entire *arr stack with a single binary.
This commit is contained in:
commit
29cf0a0126
85 changed files with 10178 additions and 0 deletions
191
internal/ui/format.go
Normal file
191
internal/ui/format.go
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FormatSize converts bytes to human-readable format.
|
||||
func FormatSize(sizeBytes *int64) string {
|
||||
if sizeBytes == nil {
|
||||
return "?"
|
||||
}
|
||||
return FormatBytes(*sizeBytes)
|
||||
}
|
||||
|
||||
// FormatBytes converts bytes to human-readable format.
|
||||
func FormatBytes(b int64) string {
|
||||
if b == 0 {
|
||||
return "0 B"
|
||||
}
|
||||
const unit = 1024
|
||||
if b < unit {
|
||||
return fmt.Sprintf("%d B", b)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for n := b / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
val := float64(b) / float64(div)
|
||||
units := []string{"KB", "MB", "GB", "TB"}
|
||||
if exp >= len(units) {
|
||||
exp = len(units) - 1
|
||||
}
|
||||
return fmt.Sprintf("%.1f %s", val, units[exp])
|
||||
}
|
||||
|
||||
// QualityIndicator returns a colored emoji for quality score (0-100 scale).
|
||||
func QualityIndicator(score *int) string {
|
||||
if score == nil {
|
||||
return " "
|
||||
}
|
||||
s := *score
|
||||
switch {
|
||||
case s >= 70:
|
||||
return "🟢"
|
||||
case s >= 40:
|
||||
return "🟡"
|
||||
default:
|
||||
return "🔴"
|
||||
}
|
||||
}
|
||||
|
||||
// SeedHealthIndicator returns a colored emoji for seed count.
|
||||
func SeedHealthIndicator(seeds int) string {
|
||||
switch {
|
||||
case seeds > 100:
|
||||
return "🟢"
|
||||
case seeds >= 10:
|
||||
return "🟡"
|
||||
default:
|
||||
return "🔴"
|
||||
}
|
||||
}
|
||||
|
||||
// FormatRating returns a display string for a rating.
|
||||
func FormatRating(rating *string) string {
|
||||
if rating == nil {
|
||||
return "-"
|
||||
}
|
||||
return *rating
|
||||
}
|
||||
|
||||
// FormatYear returns a display string for a year.
|
||||
func FormatYear(year *int) string {
|
||||
if year == nil {
|
||||
return "-"
|
||||
}
|
||||
return strconv.Itoa(*year)
|
||||
}
|
||||
|
||||
// FormatContentType returns a short display for content type.
|
||||
func FormatContentType(ct string) string {
|
||||
switch strings.ToLower(ct) {
|
||||
case "movie":
|
||||
return "Movie"
|
||||
case "show":
|
||||
return "Show"
|
||||
default:
|
||||
return ct
|
||||
}
|
||||
}
|
||||
|
||||
// Ptr returns a pointer to a value. Useful for tests.
|
||||
func Ptr[T any](v T) *T {
|
||||
return &v
|
||||
}
|
||||
|
||||
// TruncateString truncates a string to maxLen with ellipsis.
|
||||
func TruncateString(s string, maxLen int) string {
|
||||
runes := []rune(s)
|
||||
if len(runes) <= maxLen {
|
||||
return s
|
||||
}
|
||||
if maxLen <= 3 {
|
||||
return string(runes[:maxLen])
|
||||
}
|
||||
return string(runes[:maxLen-3]) + "..."
|
||||
}
|
||||
|
||||
// FormatLanguages joins language codes.
|
||||
func FormatLanguages(langs []string) string {
|
||||
if len(langs) == 0 {
|
||||
return "-"
|
||||
}
|
||||
return strings.Join(langs, ", ")
|
||||
}
|
||||
|
||||
// FormatSeedRatio returns a display for seed/leech ratio.
|
||||
func FormatSeedRatio(seeders, leechers int) string {
|
||||
if leechers == 0 {
|
||||
if seeders == 0 {
|
||||
return "0:0"
|
||||
}
|
||||
return fmt.Sprintf("%d:0", seeders)
|
||||
}
|
||||
ratio := float64(seeders) / float64(leechers)
|
||||
return fmt.Sprintf("%.0f:1", math.Round(ratio))
|
||||
}
|
||||
|
||||
// FormatTimeAgo returns a human-readable "time ago" string.
|
||||
func FormatTimeAgo(t string) string {
|
||||
parsed, err := time.Parse(time.RFC3339, t)
|
||||
if err != nil {
|
||||
return t
|
||||
}
|
||||
diff := time.Since(parsed)
|
||||
switch {
|
||||
case diff < time.Minute:
|
||||
return "just now"
|
||||
case diff < time.Hour:
|
||||
m := int(diff.Minutes())
|
||||
return fmt.Sprintf("%dm ago", m)
|
||||
case diff < 24*time.Hour:
|
||||
h := int(diff.Hours())
|
||||
return fmt.Sprintf("%dh ago", h)
|
||||
case diff < 30*24*time.Hour:
|
||||
d := int(diff.Hours() / 24)
|
||||
return fmt.Sprintf("%dd ago", d)
|
||||
default:
|
||||
m := int(diff.Hours() / 24 / 30)
|
||||
return fmt.Sprintf("%dmo ago", m)
|
||||
}
|
||||
}
|
||||
|
||||
// FormatNumber formats a number with thousands separator.
|
||||
func FormatNumber(n int) string {
|
||||
negative := n < 0
|
||||
if negative {
|
||||
n = -n
|
||||
}
|
||||
s := strconv.Itoa(n)
|
||||
if len(s) <= 3 {
|
||||
if negative {
|
||||
return "-" + s
|
||||
}
|
||||
return s
|
||||
}
|
||||
var result []byte
|
||||
for i, c := range s {
|
||||
if i > 0 && (len(s)-i)%3 == 0 {
|
||||
result = append(result, ',')
|
||||
}
|
||||
result = append(result, byte(c))
|
||||
}
|
||||
if negative {
|
||||
return "-" + string(result)
|
||||
}
|
||||
return string(result)
|
||||
}
|
||||
|
||||
// StringOrDash returns the string value or "-" if nil.
|
||||
func StringOrDash(s *string) string {
|
||||
if s == nil {
|
||||
return "-"
|
||||
}
|
||||
return *s
|
||||
}
|
||||
165
internal/ui/format_test.go
Normal file
165
internal/ui/format_test.go
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFormatSize(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input *int64
|
||||
want string
|
||||
}{
|
||||
{"nil", nil, "?"},
|
||||
{"zero", ptr(int64(0)), "0 B"},
|
||||
{"bytes", ptr(int64(500)), "500 B"},
|
||||
{"kilobytes", ptr(int64(1024)), "1.0 KB"},
|
||||
{"megabytes", ptr(int64(52428800)), "50.0 MB"},
|
||||
{"gigabytes", ptr(int64(4294967296)), "4.0 GB"},
|
||||
{"terabyte", ptr(int64(1099511627776)), "1.0 TB"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := FormatSize(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("FormatSize(%v) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatBytes(t *testing.T) {
|
||||
tests := []struct {
|
||||
input int64
|
||||
want string
|
||||
}{
|
||||
{0, "0 B"},
|
||||
{100, "100 B"},
|
||||
{1024, "1.0 KB"},
|
||||
{1048576, "1.0 MB"},
|
||||
{1073741824, "1.0 GB"},
|
||||
{1099511627776, "1.0 TB"},
|
||||
{3221225472, "3.0 GB"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.want, func(t *testing.T) {
|
||||
got := FormatBytes(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("FormatBytes(%d) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatYear(t *testing.T) {
|
||||
tests := []struct {
|
||||
input *int
|
||||
want string
|
||||
}{
|
||||
{nil, "-"},
|
||||
{intPtr(2023), "2023"},
|
||||
{intPtr(1999), "1999"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.want, func(t *testing.T) {
|
||||
got := FormatYear(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("FormatYear(%v) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatNumber(t *testing.T) {
|
||||
tests := []struct {
|
||||
input int
|
||||
want string
|
||||
}{
|
||||
{0, "0"},
|
||||
{999, "999"},
|
||||
{1000, "1,000"},
|
||||
{1234567, "1,234,567"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.want, func(t *testing.T) {
|
||||
got := FormatNumber(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("FormatNumber(%d) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateString(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
maxLen int
|
||||
want string
|
||||
}{
|
||||
{"short", 10, "short"},
|
||||
{"exactly10!", 10, "exactly10!"},
|
||||
{"this is too long", 10, "this is..."},
|
||||
{"ab", 5, "ab"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := TruncateString(tt.input, tt.maxLen)
|
||||
if got != tt.want {
|
||||
t.Errorf("TruncateString(%q, %d) = %q, want %q", tt.input, tt.maxLen, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestQualityIndicator(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input *int
|
||||
want string
|
||||
}{
|
||||
{"nil", nil, " "},
|
||||
{"low", intPtr(30), "🔴"},
|
||||
{"medium", intPtr(60), "🟡"},
|
||||
{"high", intPtr(80), "🟢"},
|
||||
{"perfect", intPtr(100), "🟢"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := QualityIndicator(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("QualityIndicator(%v) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStringOrDash(t *testing.T) {
|
||||
s := "hello"
|
||||
if got := StringOrDash(&s); got != "hello" {
|
||||
t.Errorf("StringOrDash(&hello) = %q, want hello", got)
|
||||
}
|
||||
if got := StringOrDash(nil); got != "-" {
|
||||
t.Errorf("StringOrDash(nil) = %q, want -", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatContentType(t *testing.T) {
|
||||
if got := FormatContentType("movie"); got != "Movie" {
|
||||
t.Errorf("FormatContentType(movie) = %q, want Movie", got)
|
||||
}
|
||||
if got := FormatContentType("show"); got != "Show" {
|
||||
t.Errorf("FormatContentType(show) = %q, want Show", got)
|
||||
}
|
||||
if got := FormatContentType("other"); got != "other" {
|
||||
t.Errorf("FormatContentType(other) = %q, want other", got)
|
||||
}
|
||||
}
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
func intPtr(v int) *int { return &v }
|
||||
424
internal/ui/table.go
Normal file
424
internal/ui/table.go
Normal file
|
|
@ -0,0 +1,424 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/olekukonko/tablewriter"
|
||||
tc "github.com/torrentclaw/go-client"
|
||||
)
|
||||
|
||||
var (
|
||||
titleColor = color.New(color.FgCyan, color.Bold)
|
||||
headerColor = color.New(color.FgWhite, color.Bold)
|
||||
successColor = color.New(color.FgGreen)
|
||||
warnColor = color.New(color.FgYellow)
|
||||
errorColor = color.New(color.FgRed)
|
||||
dimColor = color.New(color.FgHiBlack)
|
||||
boldColor = color.New(color.Bold)
|
||||
)
|
||||
|
||||
// PrintSearchResults renders search results as a colored table.
|
||||
func PrintSearchResults(resp *tc.SearchResponse) {
|
||||
if len(resp.Results) == 0 {
|
||||
warnColor.Println("No results found.")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("\n")
|
||||
dimColor.Printf(" %d results found (page %d)\n\n", resp.Total, resp.Page)
|
||||
|
||||
for _, r := range resp.Results {
|
||||
printSearchResultEntry(os.Stdout, r)
|
||||
}
|
||||
}
|
||||
|
||||
func printSearchResultEntry(w io.Writer, r tc.SearchResult) {
|
||||
year := FormatYear(r.Year)
|
||||
titleColor.Fprintf(w, " %s (%s)", r.Title, year)
|
||||
dimColor.Fprintf(w, " [%s]", FormatContentType(r.ContentType))
|
||||
|
||||
if r.RatingIMDb != nil {
|
||||
fmt.Fprintf(w, " ⭐ %s", *r.RatingIMDb)
|
||||
}
|
||||
if len(r.Genres) > 0 {
|
||||
dimColor.Fprintf(w, " %s", strings.Join(r.Genres, ", "))
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
|
||||
if len(r.Torrents) == 0 {
|
||||
dimColor.Fprintln(w, " No torrents available")
|
||||
fmt.Fprintln(w)
|
||||
return
|
||||
}
|
||||
|
||||
table := tablewriter.NewWriter(w)
|
||||
table.SetHeader([]string{"", "Quality", "Size", "Seeds", "Source", "Codec", "Lang", "Score"})
|
||||
table.SetBorder(false)
|
||||
table.SetColumnSeparator("")
|
||||
table.SetHeaderAlignment(tablewriter.ALIGN_LEFT)
|
||||
table.SetAlignment(tablewriter.ALIGN_LEFT)
|
||||
table.SetCenterSeparator("")
|
||||
table.SetRowSeparator("")
|
||||
table.SetTablePadding(" ")
|
||||
table.SetNoWhiteSpace(true)
|
||||
|
||||
for _, t := range r.Torrents {
|
||||
quality := StringOrDash(t.Quality)
|
||||
size := FormatSize(t.SizeBytes)
|
||||
seeds := fmt.Sprintf("%s %d", SeedHealthIndicator(t.Seeders), t.Seeders)
|
||||
source := t.Source
|
||||
codec := StringOrDash(t.Codec)
|
||||
langs := FormatLanguages(t.Languages)
|
||||
score := ""
|
||||
if t.QualityScore != nil {
|
||||
score = fmt.Sprintf("%s %d", QualityIndicator(t.QualityScore), *t.QualityScore)
|
||||
}
|
||||
|
||||
table.Append([]string{" ", quality, size, seeds, source, codec, TruncateString(langs, 12), score})
|
||||
}
|
||||
|
||||
table.Render()
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
|
||||
// PrintPopularItems renders popular items as a colored table.
|
||||
func PrintPopularItems(items []tc.PopularItem) {
|
||||
if len(items) == 0 {
|
||||
warnColor.Println("No popular items found.")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
headerColor.Println(" 🔥 Popular on unarr")
|
||||
fmt.Println()
|
||||
|
||||
table := tablewriter.NewWriter(os.Stdout)
|
||||
table.SetHeader([]string{"#", "Title", "Year", "Type", "IMDb", "Seeds"})
|
||||
table.SetBorder(false)
|
||||
table.SetColumnSeparator("")
|
||||
table.SetHeaderAlignment(tablewriter.ALIGN_LEFT)
|
||||
table.SetAlignment(tablewriter.ALIGN_LEFT)
|
||||
table.SetCenterSeparator("")
|
||||
table.SetRowSeparator("")
|
||||
table.SetTablePadding(" ")
|
||||
table.SetNoWhiteSpace(true)
|
||||
|
||||
for i, item := range items {
|
||||
table.Append([]string{
|
||||
fmt.Sprintf(" %d", i+1),
|
||||
TruncateString(item.Title, 40),
|
||||
FormatYear(item.Year),
|
||||
FormatContentType(item.ContentType),
|
||||
FormatRating(item.RatingIMDb),
|
||||
FormatNumber(item.MaxSeeders),
|
||||
})
|
||||
}
|
||||
|
||||
table.Render()
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// PrintRecentItems renders recent items as a colored table.
|
||||
func PrintRecentItems(items []tc.RecentItem) {
|
||||
if len(items) == 0 {
|
||||
warnColor.Println("No recent items found.")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
headerColor.Println(" 🆕 Recently Added")
|
||||
fmt.Println()
|
||||
|
||||
table := tablewriter.NewWriter(os.Stdout)
|
||||
table.SetHeader([]string{"#", "Title", "Year", "Type", "IMDb", "Added"})
|
||||
table.SetBorder(false)
|
||||
table.SetColumnSeparator("")
|
||||
table.SetHeaderAlignment(tablewriter.ALIGN_LEFT)
|
||||
table.SetAlignment(tablewriter.ALIGN_LEFT)
|
||||
table.SetCenterSeparator("")
|
||||
table.SetRowSeparator("")
|
||||
table.SetTablePadding(" ")
|
||||
table.SetNoWhiteSpace(true)
|
||||
|
||||
for i, item := range items {
|
||||
table.Append([]string{
|
||||
fmt.Sprintf(" %d", i+1),
|
||||
TruncateString(item.Title, 40),
|
||||
FormatYear(item.Year),
|
||||
FormatContentType(item.ContentType),
|
||||
FormatRating(item.RatingIMDb),
|
||||
FormatTimeAgo(item.CreatedAt),
|
||||
})
|
||||
}
|
||||
|
||||
table.Render()
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// PrintStats renders system statistics.
|
||||
func PrintStats(stats *tc.StatsResponse) {
|
||||
fmt.Println()
|
||||
headerColor.Println(" 📊 unarr Statistics")
|
||||
fmt.Println()
|
||||
|
||||
boldColor.Print(" Content: ")
|
||||
fmt.Printf("%s movies, %s shows\n", FormatNumber(stats.Content.Movies), FormatNumber(stats.Content.Shows))
|
||||
|
||||
boldColor.Print(" Enriched: ")
|
||||
fmt.Printf("%s with TMDb metadata\n", FormatNumber(stats.Content.TMDbEnriched))
|
||||
|
||||
boldColor.Print(" Torrents: ")
|
||||
fmt.Printf("%s total, %s with seeders\n", FormatNumber(stats.Torrents.Total), FormatNumber(stats.Torrents.WithSeeders))
|
||||
|
||||
if len(stats.Torrents.BySource) > 0 {
|
||||
fmt.Println()
|
||||
boldColor.Println(" Sources:")
|
||||
for source, count := range stats.Torrents.BySource {
|
||||
fmt.Printf(" %-20s %s\n", source, FormatNumber(count))
|
||||
}
|
||||
}
|
||||
|
||||
if len(stats.RecentIngestions) > 0 {
|
||||
fmt.Println()
|
||||
boldColor.Println(" Recent Ingestions:")
|
||||
|
||||
table := tablewriter.NewWriter(os.Stdout)
|
||||
table.SetHeader([]string{"", "Source", "Status", "Fetched", "New", "Updated"})
|
||||
table.SetBorder(false)
|
||||
table.SetColumnSeparator("")
|
||||
table.SetHeaderAlignment(tablewriter.ALIGN_LEFT)
|
||||
table.SetAlignment(tablewriter.ALIGN_LEFT)
|
||||
table.SetCenterSeparator("")
|
||||
table.SetRowSeparator("")
|
||||
table.SetTablePadding(" ")
|
||||
table.SetNoWhiteSpace(true)
|
||||
|
||||
for _, ing := range stats.RecentIngestions {
|
||||
status := ing.Status
|
||||
switch status {
|
||||
case "completed":
|
||||
status = successColor.Sprint("✓ done")
|
||||
case "running":
|
||||
status = warnColor.Sprint("⟳ running")
|
||||
case "failed":
|
||||
status = errorColor.Sprint("✗ failed")
|
||||
}
|
||||
table.Append([]string{
|
||||
" ",
|
||||
ing.Source,
|
||||
status,
|
||||
FormatNumber(ing.Fetched),
|
||||
FormatNumber(ing.New),
|
||||
FormatNumber(ing.Updated),
|
||||
})
|
||||
}
|
||||
|
||||
table.Render()
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// PrintInspect renders the TrueSpec inspection output for a torrent.
|
||||
func PrintInspect(title string, year string, torrents []tc.TorrentInfo, magnetURI string) {
|
||||
fmt.Println()
|
||||
titleColor.Printf(" 📋 %s", title)
|
||||
if year != "" && year != "-" {
|
||||
titleColor.Printf(" (%s)", year)
|
||||
}
|
||||
fmt.Println()
|
||||
dimColor.Println(" " + strings.Repeat("─", len(title)+10))
|
||||
|
||||
if len(torrents) == 0 {
|
||||
warnColor.Println(" No torrent details found.")
|
||||
fmt.Println()
|
||||
if magnetURI != "" {
|
||||
dimColor.Println(" Magnet:")
|
||||
fmt.Printf(" %s\n\n", magnetURI)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
t := torrents[0]
|
||||
|
||||
printField := func(label, value string) {
|
||||
boldColor.Printf(" %-12s", label+":")
|
||||
fmt.Println(value)
|
||||
}
|
||||
|
||||
printField("Quality", StringOrDash(t.Quality)+" "+StringOrDash(t.SourceType))
|
||||
|
||||
codecStr := StringOrDash(t.Codec)
|
||||
if t.AudioCodec != nil {
|
||||
codecStr += " / " + *t.AudioCodec
|
||||
}
|
||||
printField("Codec", codecStr)
|
||||
printField("Size", FormatSize(t.SizeBytes))
|
||||
printField("Seeds", fmt.Sprintf("%s %d | Leechers: %d", SeedHealthIndicator(t.Seeders), t.Seeders, t.Leechers))
|
||||
printField("Languages", FormatLanguages(t.Languages))
|
||||
printField("Source", t.Source)
|
||||
|
||||
if t.QualityScore != nil {
|
||||
printField("Score", fmt.Sprintf("%s %d/100 (Quality Score)", QualityIndicator(t.QualityScore), *t.QualityScore))
|
||||
}
|
||||
|
||||
printField("Health", fmt.Sprintf("%s (%s)", SeedHealthIndicator(t.Seeders), FormatSeedRatio(t.Seeders, t.Leechers)))
|
||||
|
||||
if t.HDRType != nil {
|
||||
printField("HDR", *t.HDRType)
|
||||
}
|
||||
if t.ReleaseGroup != nil {
|
||||
printField("Group", *t.ReleaseGroup)
|
||||
}
|
||||
|
||||
var flags []string
|
||||
if t.IsProper != nil && *t.IsProper {
|
||||
flags = append(flags, "PROPER")
|
||||
}
|
||||
if t.IsRepack != nil && *t.IsRepack {
|
||||
flags = append(flags, "REPACK")
|
||||
}
|
||||
if t.IsRemastered != nil && *t.IsRemastered {
|
||||
flags = append(flags, "REMASTERED")
|
||||
}
|
||||
if len(flags) > 0 {
|
||||
printField("Flags", strings.Join(flags, ", "))
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
|
||||
if len(torrents) > 1 {
|
||||
dimColor.Printf(" + %d more torrents available\n\n", len(torrents)-1)
|
||||
|
||||
table := tablewriter.NewWriter(os.Stdout)
|
||||
table.SetHeader([]string{"", "Quality", "Size", "Seeds", "Source", "Score"})
|
||||
table.SetBorder(false)
|
||||
table.SetColumnSeparator("")
|
||||
table.SetHeaderAlignment(tablewriter.ALIGN_LEFT)
|
||||
table.SetAlignment(tablewriter.ALIGN_LEFT)
|
||||
table.SetCenterSeparator("")
|
||||
table.SetRowSeparator("")
|
||||
table.SetTablePadding(" ")
|
||||
table.SetNoWhiteSpace(true)
|
||||
|
||||
for i, tt := range torrents[1:] {
|
||||
score := ""
|
||||
if tt.QualityScore != nil {
|
||||
score = fmt.Sprintf("%s %d", QualityIndicator(tt.QualityScore), *tt.QualityScore)
|
||||
}
|
||||
table.Append([]string{
|
||||
fmt.Sprintf(" %d", i+2),
|
||||
StringOrDash(tt.Quality),
|
||||
FormatSize(tt.SizeBytes),
|
||||
fmt.Sprintf("%s %d", SeedHealthIndicator(tt.Seeders), tt.Seeders),
|
||||
tt.Source,
|
||||
score,
|
||||
})
|
||||
}
|
||||
|
||||
table.Render()
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
if magnetURI != "" {
|
||||
dimColor.Println(" Magnet:")
|
||||
fmt.Printf(" %s\n\n", magnetURI)
|
||||
}
|
||||
}
|
||||
|
||||
// PrintWatchProviders renders streaming and torrent options.
|
||||
func PrintWatchProviders(title string, year string, providers *tc.WatchProvidersResponse, torrents []tc.TorrentInfo) {
|
||||
fmt.Println()
|
||||
titleColor.Printf(" 🎬 %s", title)
|
||||
if year != "" && year != "-" {
|
||||
titleColor.Printf(" (%s)", year)
|
||||
}
|
||||
fmt.Printf(" — Where to watch:\n\n")
|
||||
|
||||
hasStreaming := false
|
||||
|
||||
if providers != nil {
|
||||
if len(providers.Providers.Flatrate) > 0 {
|
||||
hasStreaming = true
|
||||
successColor.Println(" 📺 SUBSCRIPTION (included):")
|
||||
for _, p := range providers.Providers.Flatrate {
|
||||
fmt.Printf(" • %s\n", p.Name)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
if len(providers.Providers.Free) > 0 {
|
||||
hasStreaming = true
|
||||
successColor.Println(" 🆓 FREE:")
|
||||
for _, p := range providers.Providers.Free {
|
||||
fmt.Printf(" • %s\n", p.Name)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
if len(providers.Providers.Rent) > 0 {
|
||||
hasStreaming = true
|
||||
warnColor.Println(" 💰 RENT:")
|
||||
for _, p := range providers.Providers.Rent {
|
||||
fmt.Printf(" • %s\n", p.Name)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
if len(providers.Providers.Buy) > 0 {
|
||||
hasStreaming = true
|
||||
warnColor.Println(" 🛒 BUY:")
|
||||
for _, p := range providers.Providers.Buy {
|
||||
fmt.Printf(" • %s\n", p.Name)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
if !hasStreaming {
|
||||
dimColor.Println(" 📺 No streaming options found for your country.")
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
if len(torrents) > 0 {
|
||||
headerColor.Println(" 🏴☠️ TORRENT:")
|
||||
|
||||
table := tablewriter.NewWriter(os.Stdout)
|
||||
table.SetHeader([]string{"", "Quality", "Size", "Seeds", "Source", "Score"})
|
||||
table.SetBorder(false)
|
||||
table.SetColumnSeparator("")
|
||||
table.SetHeaderAlignment(tablewriter.ALIGN_LEFT)
|
||||
table.SetAlignment(tablewriter.ALIGN_LEFT)
|
||||
table.SetCenterSeparator("")
|
||||
table.SetRowSeparator("")
|
||||
table.SetTablePadding(" ")
|
||||
table.SetNoWhiteSpace(true)
|
||||
|
||||
for _, t := range torrents {
|
||||
score := ""
|
||||
if t.QualityScore != nil {
|
||||
score = fmt.Sprintf("%s %d", QualityIndicator(t.QualityScore), *t.QualityScore)
|
||||
}
|
||||
table.Append([]string{
|
||||
" ",
|
||||
StringOrDash(t.Quality),
|
||||
FormatSize(t.SizeBytes),
|
||||
fmt.Sprintf("%s %d", SeedHealthIndicator(t.Seeders), t.Seeders),
|
||||
t.Source,
|
||||
score,
|
||||
})
|
||||
}
|
||||
|
||||
table.Render()
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
if hasStreaming {
|
||||
successColor.Println(" 💡 Available on streaming services above.")
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue