Search, inspect, stream, and download torrents from the terminal. Replaces the entire *arr stack with a single binary.
424 lines
11 KiB
Go
424 lines
11 KiB
Go
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()
|
|
}
|