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
114
internal/parser/torrent.go
Normal file
114
internal/parser/torrent.go
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
package parser
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ParsedTorrent contains information extracted from a magnet URI, hash, or torrent name.
|
||||
type ParsedTorrent struct {
|
||||
InfoHash string
|
||||
Name string
|
||||
Quality string
|
||||
Codec string
|
||||
Year string
|
||||
IsMagnet bool
|
||||
}
|
||||
|
||||
var (
|
||||
hashRegex = regexp.MustCompile(`^[a-fA-F0-9]{40}$`)
|
||||
qualityRegex = regexp.MustCompile(`(?i)(2160p|1080p|720p|480p|4K|UHD)`)
|
||||
codecRegex = regexp.MustCompile(`(?i)(x264|x265|h\.?264|h\.?265|HEVC|AVC|AV1|VP9|XviD|DivX)`)
|
||||
yearRegex = regexp.MustCompile(`(?:^|[\s.(])((?:19|20)\d{2})(?:[\s.)]|$)`)
|
||||
artifactsRegex = regexp.MustCompile(`(?i)(BluRay|BDRip|HDRip|WEBRip|WEB-DL|HDTV|DVDRip|BRRip|CAM|TS|TC|PROPER|REPACK|REMASTERED|REMUX|EXTENDED|UNRATED|IMAX|DUAL|MULTi|AAC|DTS|DD5\.1|AC3|Atmos|FLAC|EAC3|10bit|HDR10?\+?|DV|DoVi|SDR|YTS|YIFY|RARBG|NTG|SPARKS|AMIABLE|FGT|\[.*?\]|\(.*?\))`)
|
||||
whitespaceRegex = regexp.MustCompile(`\s+`)
|
||||
)
|
||||
|
||||
// Parse parses a magnet URI, info hash, or torrent name.
|
||||
func Parse(input string) ParsedTorrent {
|
||||
input = strings.TrimSpace(input)
|
||||
|
||||
if strings.HasPrefix(input, "magnet:") {
|
||||
return parseMagnet(input)
|
||||
}
|
||||
|
||||
if hashRegex.MatchString(input) {
|
||||
return ParsedTorrent{
|
||||
InfoHash: strings.ToLower(input),
|
||||
}
|
||||
}
|
||||
|
||||
// Treat as a torrent name/filename
|
||||
return parseName(input)
|
||||
}
|
||||
|
||||
func parseMagnet(uri string) ParsedTorrent {
|
||||
result := ParsedTorrent{IsMagnet: true}
|
||||
|
||||
u, err := url.Parse(uri)
|
||||
if err != nil {
|
||||
return result
|
||||
}
|
||||
|
||||
xt := u.Query().Get("xt")
|
||||
if strings.HasPrefix(xt, "urn:btih:") {
|
||||
result.InfoHash = strings.ToLower(strings.TrimPrefix(xt, "urn:btih:"))
|
||||
}
|
||||
|
||||
dn := u.Query().Get("dn")
|
||||
if dn != "" {
|
||||
result.Name = dn
|
||||
parsed := parseName(dn)
|
||||
result.Quality = parsed.Quality
|
||||
result.Codec = parsed.Codec
|
||||
result.Year = parsed.Year
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func parseName(name string) ParsedTorrent {
|
||||
result := ParsedTorrent{Name: name}
|
||||
|
||||
if m := qualityRegex.FindString(name); m != "" {
|
||||
result.Quality = strings.ToLower(m)
|
||||
if result.Quality == "4k" || result.Quality == "uhd" {
|
||||
result.Quality = "2160p"
|
||||
}
|
||||
}
|
||||
|
||||
if m := codecRegex.FindString(name); m != "" {
|
||||
result.Codec = m
|
||||
}
|
||||
|
||||
if m := yearRegex.FindStringSubmatch(name); len(m) > 1 {
|
||||
result.Year = m[1]
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// ExtractSearchQuery cleans a torrent name to use as a search query.
|
||||
func ExtractSearchQuery(name string) string {
|
||||
q := name
|
||||
|
||||
// Remove common release artifacts
|
||||
for _, re := range []*regexp.Regexp{qualityRegex, codecRegex} {
|
||||
q = re.ReplaceAllString(q, "")
|
||||
}
|
||||
|
||||
q = artifactsRegex.ReplaceAllString(q, "")
|
||||
|
||||
// Replace dots and underscores with spaces
|
||||
q = strings.NewReplacer(".", " ", "_", " ", "-", " ").Replace(q)
|
||||
|
||||
// Remove year
|
||||
q = yearRegex.ReplaceAllString(q, " ")
|
||||
|
||||
// Collapse whitespace
|
||||
q = whitespaceRegex.ReplaceAllString(q, " ")
|
||||
q = strings.TrimSpace(q)
|
||||
|
||||
return q
|
||||
}
|
||||
98
internal/parser/torrent_test.go
Normal file
98
internal/parser/torrent_test.go
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
package parser
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseMagnet(t *testing.T) {
|
||||
magnet := "magnet:?xt=urn:btih:ABC123DEF456ABC123DEF456ABC123DEF456ABC1&dn=Oppenheimer.2023.1080p.BluRay.x265"
|
||||
p := Parse(magnet)
|
||||
|
||||
if !p.IsMagnet {
|
||||
t.Error("expected IsMagnet=true")
|
||||
}
|
||||
if p.InfoHash != "abc123def456abc123def456abc123def456abc1" {
|
||||
t.Errorf("InfoHash = %q, want lowercase 40-char hash", p.InfoHash)
|
||||
}
|
||||
if p.Quality != "1080p" {
|
||||
t.Errorf("Quality = %q, want 1080p", p.Quality)
|
||||
}
|
||||
if p.Codec != "x265" {
|
||||
t.Errorf("Codec = %q, want x265", p.Codec)
|
||||
}
|
||||
if p.Year != "2023" {
|
||||
t.Errorf("Year = %q, want 2023", p.Year)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInfoHash(t *testing.T) {
|
||||
hash := "abc123def456abc123def456abc123def456abc1" // exactly 40 hex chars
|
||||
p := Parse(hash)
|
||||
|
||||
if p.IsMagnet {
|
||||
t.Error("expected IsMagnet=false for plain hash")
|
||||
}
|
||||
if p.InfoHash != hash {
|
||||
t.Errorf("InfoHash = %q, want %q", p.InfoHash, hash)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseName(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
quality string
|
||||
codec string
|
||||
year string
|
||||
}{
|
||||
{"The.Matrix.1999.1080p.BluRay.x264", "1080p", "x264", "1999"},
|
||||
{"Oppenheimer.2023.2160p.UHD.BluRay.x265", "2160p", "x265", "2023"},
|
||||
{"Movie.720p.HDTV.HEVC", "720p", "HEVC", ""},
|
||||
{"Show.480p.WEB.AV1", "480p", "AV1", ""},
|
||||
{"No.Quality.Info.Here", "", "", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
p := Parse(tt.input)
|
||||
if p.Quality != tt.quality {
|
||||
t.Errorf("Quality = %q, want %q", p.Quality, tt.quality)
|
||||
}
|
||||
if p.Codec != tt.codec {
|
||||
t.Errorf("Codec = %q, want %q", p.Codec, tt.codec)
|
||||
}
|
||||
if p.Year != tt.year {
|
||||
t.Errorf("Year = %q, want %q", p.Year, tt.year)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractSearchQuery(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
}{
|
||||
{"The.Matrix.1999.1080p.BluRay.x264-GROUP"},
|
||||
{"Oppenheimer.2023.2160p.UHD.BluRay.x265.DTS-HD"},
|
||||
{"Breaking.Bad.S01E01.720p.WEB-DL"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := ExtractSearchQuery(tt.input)
|
||||
if got == "" {
|
||||
t.Errorf("ExtractSearchQuery(%q) returned empty string", tt.input)
|
||||
}
|
||||
// Should not contain quality/codec artifacts
|
||||
if strings.Contains(got, "1080p") || strings.Contains(got, "2160p") || strings.Contains(got, "720p") {
|
||||
t.Errorf("ExtractSearchQuery(%q) = %q, should not contain resolution", tt.input, got)
|
||||
}
|
||||
if strings.Contains(got, "x264") || strings.Contains(got, "x265") {
|
||||
t.Errorf("ExtractSearchQuery(%q) = %q, should not contain codec", tt.input, got)
|
||||
}
|
||||
if strings.Contains(got, "BluRay") {
|
||||
t.Errorf("ExtractSearchQuery(%q) = %q, should not contain source", tt.input, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue