Library: filter by file properties (quality, codec, HDR, DV, language)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
package arr
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MediaFile is a downloaded file as Radarr (movieFile) and Sonarr
|
||||
// (episodeFile) describe it.
|
||||
type MediaFile struct {
|
||||
Size int64 `json:"size"`
|
||||
SceneName string `json:"sceneName,omitempty"`
|
||||
RelativePath string `json:"relativePath,omitempty"`
|
||||
Quality struct {
|
||||
Quality struct {
|
||||
Name string `json:"name"`
|
||||
Resolution int `json:"resolution"`
|
||||
} `json:"quality"`
|
||||
} `json:"quality"`
|
||||
MediaInfo struct {
|
||||
VideoCodec string `json:"videoCodec"`
|
||||
VideoDynamicRange string `json:"videoDynamicRange"`
|
||||
VideoDynamicRangeType string `json:"videoDynamicRangeType"`
|
||||
} `json:"mediaInfo"`
|
||||
Languages []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"languages"`
|
||||
}
|
||||
|
||||
// Codec names used for filtering.
|
||||
const (
|
||||
CodecX265 = "x265"
|
||||
CodecX264 = "x264"
|
||||
CodecAV1 = "AV1"
|
||||
)
|
||||
|
||||
// FileAttrs are the properties of a file the library can be filtered by.
|
||||
type FileAttrs struct {
|
||||
Resolution int // 2160, 1080, 720, 480 ... or 0 if unknown
|
||||
Codec string // CodecX265, CodecX264, CodecAV1 or ""
|
||||
HDR bool // HDR10, HDR10+, HLG, PQ (also with Dolby Vision on top)
|
||||
DV bool // Dolby Vision
|
||||
Languages []string // audio languages
|
||||
}
|
||||
|
||||
var (
|
||||
reRes = regexp.MustCompile(`(?i)\b(2160|1080|720|576|480)p\b`)
|
||||
re265 = regexp.MustCompile(`(?i)(x\.?265|h\.?265|hevc)`)
|
||||
re264 = regexp.MustCompile(`(?i)(x\.?264|h\.?264|\bavc\b)`)
|
||||
reAV1 = regexp.MustCompile(`(?i)\bav1\b`)
|
||||
reDV = regexp.MustCompile(`(?i)(\bdv\b|dovi|dolby[ .]?vision)`)
|
||||
reHDR = regexp.MustCompile(`(?i)\b(hdr(10(\+|plus)?)?|hlg|pq)\b`)
|
||||
)
|
||||
|
||||
// Attrs derives the filterable properties. Radarr/Sonarr's media info is used
|
||||
// when present, otherwise the names (scene name, path, quality) are searched.
|
||||
func (f MediaFile) Attrs() FileAttrs {
|
||||
names := strings.Join([]string{f.SceneName, f.RelativePath, f.Quality.Quality.Name}, " ")
|
||||
a := FileAttrs{Resolution: f.Quality.Quality.Resolution}
|
||||
if a.Resolution == 0 {
|
||||
if m := reRes.FindStringSubmatch(names); m != nil {
|
||||
a.Resolution = atoi(m[1])
|
||||
}
|
||||
}
|
||||
|
||||
codec := f.MediaInfo.VideoCodec + " " + names
|
||||
switch {
|
||||
case re265.MatchString(f.MediaInfo.VideoCodec):
|
||||
a.Codec = CodecX265
|
||||
case re264.MatchString(f.MediaInfo.VideoCodec):
|
||||
a.Codec = CodecX264
|
||||
case reAV1.MatchString(f.MediaInfo.VideoCodec):
|
||||
a.Codec = CodecAV1
|
||||
case f.MediaInfo.VideoCodec != "":
|
||||
// A codec we do not offer as a filter (VC-1, MPEG-2...): do not guess from the name.
|
||||
case re265.MatchString(codec):
|
||||
a.Codec = CodecX265
|
||||
case re264.MatchString(codec):
|
||||
a.Codec = CodecX264
|
||||
case reAV1.MatchString(codec):
|
||||
a.Codec = CodecAV1
|
||||
}
|
||||
|
||||
dr := f.MediaInfo.VideoDynamicRangeType + " " + f.MediaInfo.VideoDynamicRange
|
||||
if strings.TrimSpace(dr) == "" {
|
||||
dr = names
|
||||
}
|
||||
a.DV = reDV.MatchString(dr)
|
||||
a.HDR = reHDR.MatchString(dr)
|
||||
for _, l := range f.Languages {
|
||||
if l.Name != "" {
|
||||
a.Languages = append(a.Languages, l.Name)
|
||||
}
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func atoi(s string) (n int) {
|
||||
for _, c := range s {
|
||||
n = n*10 + int(c-'0')
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package arr
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func attrs(t *testing.T, raw string) FileAttrs {
|
||||
t.Helper()
|
||||
var f MediaFile
|
||||
if err := json.Unmarshal([]byte(raw), &f); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return f.Attrs()
|
||||
}
|
||||
|
||||
func TestAttrsFromMediaInfo(t *testing.T) {
|
||||
a := attrs(t, `{"quality":{"quality":{"name":"Bluray-2160p","resolution":2160}},
|
||||
"mediaInfo":{"videoCodec":"x265","videoDynamicRange":"HDR","videoDynamicRangeType":"DV HDR10"},
|
||||
"languages":[{"name":"English"},{"name":"German"}]}`)
|
||||
want := FileAttrs{Resolution: 2160, Codec: CodecX265, HDR: true, DV: true, Languages: []string{"English", "German"}}
|
||||
if !reflect.DeepEqual(a, want) {
|
||||
t.Errorf("got %+v, want %+v", a, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttrsVariants(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, raw string
|
||||
check func(FileAttrs) bool
|
||||
}{
|
||||
{"hevc codec", `{"mediaInfo":{"videoCodec":"HEVC"}}`, func(a FileAttrs) bool { return a.Codec == CodecX265 }},
|
||||
{"h264 codec", `{"mediaInfo":{"videoCodec":"h264"}}`, func(a FileAttrs) bool { return a.Codec == CodecX264 }},
|
||||
{"avc codec", `{"mediaInfo":{"videoCodec":"AVC"}}`, func(a FileAttrs) bool { return a.Codec == CodecX264 }},
|
||||
{"av1 codec", `{"mediaInfo":{"videoCodec":"AV1"}}`, func(a FileAttrs) bool { return a.Codec == CodecAV1 }},
|
||||
{"unoffered codec is not guessed from the name", `{"sceneName":"x264.Movie","mediaInfo":{"videoCodec":"VC-1"}}`, func(a FileAttrs) bool { return a.Codec == "" }},
|
||||
{"codec from scene name", `{"sceneName":"Movie.2020.1080p.BluRay.x265-GRP"}`, func(a FileAttrs) bool { return a.Codec == CodecX265 && a.Resolution == 1080 }},
|
||||
{"dv from path", `{"relativePath":"Movie.2160p.DV.HDR10.mkv"}`, func(a FileAttrs) bool { return a.DV && a.HDR && a.Resolution == 2160 }},
|
||||
{"dv only", `{"mediaInfo":{"videoDynamicRangeType":"DV"}}`, func(a FileAttrs) bool { return a.DV && !a.HDR }},
|
||||
{"hlg is hdr", `{"mediaInfo":{"videoDynamicRangeType":"HLG"}}`, func(a FileAttrs) bool { return a.HDR && !a.DV }},
|
||||
{"sdr", `{"mediaInfo":{"videoCodec":"x264"},"sceneName":"Movie.HDRip.x264"}`, func(a FileAttrs) bool { return !a.HDR && !a.DV }},
|
||||
{"hdr word in a title is not enough with media info", `{"relativePath":"Hdr.Story.mkv","mediaInfo":{"videoDynamicRange":"SDR"}}`, func(a FileAttrs) bool { return !a.HDR }},
|
||||
} {
|
||||
if !tc.check(attrs(t, tc.raw)) {
|
||||
t.Errorf("%s: got %+v", tc.name, attrs(t, tc.raw))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user