unarr/internal/library/resolve.go
Deivid Soto 9df38c95a3 fix(library): classify resolution by width + height, not height alone
Cinematic widescreen content (1920×804 at 2.39:1, 3840×1600 21:9, etc.)
was being misclassified: a 1080p source presented as 1920×804 fell to
720p because 804 < 900. Same shape for 2160p sources letterboxed below
2000px tall.

ResolveResolution now takes (width, height) and picks the larger of the
width-derived and height-derived buckets, so anamorphic/letterboxed
sources land in the right bucket.
2026-05-27 11:54:29 +02:00

187 lines
5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package library
import (
"regexp"
"strings"
"github.com/torrentclaw/unarr/internal/library/mediainfo"
)
var (
seasonRegex = regexp.MustCompile(`(?i)S(\d{1,2})E(\d{1,2})`)
seasonOnly = regexp.MustCompile(`(?i)S(\d{1,2})(?:\b|$)`)
altEpRegex = regexp.MustCompile(`(?i)(\d{1,2})x(\d{2})`)
)
// ResolveResolution maps video dimensions to a standard resolution label.
// Uses both width and height so cinematic aspect ratios (2.35:1, 2.39:1, 21:9)
// are not misclassified — e.g. a 1080p source presented as 1920×804 letterboxed
// would fall to 720p if classified by height alone.
func ResolveResolution(width, height int) string {
byHeight := resolutionByHeight(height)
byWidth := resolutionByWidth(width)
return maxResolution(byHeight, byWidth)
}
func resolutionByHeight(height int) string {
switch {
case height >= 2000:
return "2160p"
case height >= 900:
return "1080p"
case height >= 600:
return "720p"
case height >= 400:
return "480p"
default:
return ""
}
}
func resolutionByWidth(width int) string {
switch {
case width >= 3400:
return "2160p"
case width >= 1800:
return "1080p"
case width >= 1200:
return "720p"
case width >= 800:
return "480p"
default:
return ""
}
}
var resolutionRank = map[string]int{
"": 0,
"480p": 1,
"720p": 2,
"1080p": 3,
"2160p": 4,
}
func maxResolution(a, b string) string {
if resolutionRank[a] >= resolutionRank[b] {
return a
}
return b
}
// DeriveContentType guesses "movie" or "show" from parsed metadata.
func DeriveContentType(item LibraryItem) string {
if item.Season > 0 || item.Episode > 0 {
return "show"
}
// Check filename for season/episode patterns
if seasonRegex.MatchString(item.FileName) || altEpRegex.MatchString(item.FileName) || seasonOnly.MatchString(item.FileName) {
return "show"
}
return "movie"
}
// ParseSeasonEpisode extracts season and episode numbers from a filename.
func ParseSeasonEpisode(filename string) (season, episode int) {
// S01E05
if m := seasonRegex.FindStringSubmatch(filename); len(m) > 2 {
season = atoi(m[1])
episode = atoi(m[2])
return
}
// 1x05
if m := altEpRegex.FindStringSubmatch(filename); len(m) > 2 {
season = atoi(m[1])
episode = atoi(m[2])
return
}
// S01 only (season pack)
if m := seasonOnly.FindStringSubmatch(filename); len(m) > 1 {
season = atoi(m[1])
return
}
return 0, 0
}
// PrimaryAudioTrack returns the codec and channel count of the default or first audio track.
func PrimaryAudioTrack(tracks []mediainfo.AudioTrack) (codec string, channels int) {
if len(tracks) == 0 {
return "", 0
}
for _, t := range tracks {
if t.Default {
return t.Codec, t.Channels
}
}
return tracks[0].Codec, tracks[0].Channels
}
// AudioLanguages extracts unique language codes from audio tracks.
func AudioLanguages(tracks []mediainfo.AudioTrack) []string {
return mediainfo.ComputeLanguages(tracks)
}
// SubtitleLanguages extracts unique language codes from subtitle tracks.
func SubtitleLanguages(tracks []mediainfo.SubtitleTrack) []string {
seen := make(map[string]struct{})
for _, t := range tracks {
if t.Lang != "" && t.Lang != "und" {
seen[t.Lang] = struct{}{}
}
}
result := make([]string, 0, len(seen))
for l := range seen {
result = append(result, l)
}
return result
}
// CleanTitle extracts a clean title from a filename for searching.
// Removes extension, replaces separators with spaces, strips release artifacts.
func CleanTitle(filename string) string {
// Remove extension
name := strings.TrimSuffix(filename, extOf(filename))
// Remove release group at end BEFORE replacing separators (e.g. "-SPARKS", "-FGT")
name = regexp.MustCompile(`-[A-Za-z0-9]+$`).ReplaceAllString(name, "")
// Remove brackets
name = regexp.MustCompile(`[\[\(].*?[\]\)]`).ReplaceAllString(name, "")
// Remove web domains BEFORE replacing separators (dots are still dots here)
name = regexp.MustCompile(`(?i)[a-z0-9]+\.(com|org|net|mx|io|to|cc|se)`).ReplaceAllString(name, "")
// Replace common separators with spaces
name = strings.NewReplacer(".", " ", "_", " ", "-", " ").Replace(name)
// Remove quality/codec/release artifacts
name = regexp.MustCompile(`(?i)\b(2160p|1080p|720p|480p|4K|UHD|BluRay|BDRip|WEBRip|WEB-DL|HDTV|DVDRip|BRRip|x264|x265|HEVC|AVC|AV1|AAC|DTS|AC3|Atmos|FLAC|10bit|HDR10?\+?|DV|DoVi|PROPER|REPACK|REMUX|EXTENDED|DUAL|MULTi|UHDremux|4Kremux\d*)\b`).ReplaceAllString(name, "")
// Remove standalone numbers that look like resolution/format (e.g. "2160", "1080")
name = regexp.MustCompile(`\b(2160|1080|720|480)\b`).ReplaceAllString(name, "")
// Remove year
name = regexp.MustCompile(`\b(19|20)\d{2}\b`).ReplaceAllString(name, "")
// Collapse whitespace
name = regexp.MustCompile(`\s+`).ReplaceAllString(name, " ")
return strings.TrimSpace(name)
}
func extOf(filename string) string {
for i := len(filename) - 1; i >= 0; i-- {
if filename[i] == '.' {
return filename[i:]
}
}
return ""
}
func atoi(s string) int {
n := 0
for _, c := range s {
if c >= '0' && c <= '9' {
n = n*10 + int(c-'0')
}
}
return n
}