Sync from /srv/compose/unified-media-manager
This commit is contained in:
221
internal/service/matcher.go
Normal file
221
internal/service/matcher.go
Normal file
@@ -0,0 +1,221 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/TopherMayor/unified-media-manager/internal/db"
|
||||
)
|
||||
|
||||
type MatchResult struct {
|
||||
MediaID int64 `json:"media_id"`
|
||||
MediaType string `json:"media_type"`
|
||||
Title string `json:"title"`
|
||||
Year *int `json:"year,omitempty"`
|
||||
Season *int `json:"season,omitempty"`
|
||||
Episode *int `json:"episode,omitempty"`
|
||||
RootFolder string `json:"root_folder"`
|
||||
Confidence string `json:"confidence"`
|
||||
}
|
||||
|
||||
type MatcherService struct {
|
||||
db *db.DB
|
||||
}
|
||||
|
||||
func NewMatcherService(database *db.DB) *MatcherService {
|
||||
return &MatcherService{db: database}
|
||||
}
|
||||
|
||||
var (
|
||||
seasonEpisodeRe = regexp.MustCompile(`(?i)[sS](\d{1,2})[eE](\d{1,2})`)
|
||||
altSeasonEpsRe = regexp.MustCompile(`(\d{1,2})[xX](\d{1,2})`)
|
||||
bracketRe2 = regexp.MustCompile(`\[.*?\]`)
|
||||
qualityTrailRe = regexp.MustCompile(`(?i)(?:[sS]\d{1,2}[eE]\d{1,2}|\d{3,4}[pi]|720|1080|2160|HDTV|WEB|BluRay|BRRip|BDRip|DVDRip|REMUX|x264|x265|HEVC|AAC|DTS|AC3|DD|FLAC).*$`)
|
||||
sepRe = regexp.MustCompile(`[._-]+`)
|
||||
punctRe = regexp.MustCompile(`[^\w\s]`)
|
||||
multiSpaceRe = regexp.MustCompile(`\s+`)
|
||||
)
|
||||
|
||||
func normalizeTitle(s string) string {
|
||||
s = strings.ToLower(s)
|
||||
s = punctRe.ReplaceAllString(s, " ")
|
||||
s = multiSpaceRe.ReplaceAllString(s, " ")
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
func parseSeasonEpisode(s string) (season, episode int, found bool) {
|
||||
if m := seasonEpisodeRe.FindStringSubmatch(s); m != nil {
|
||||
season = atoi(m[1])
|
||||
episode = atoi(m[2])
|
||||
return season, episode, true
|
||||
}
|
||||
if m := altSeasonEpsRe.FindStringSubmatch(s); m != nil {
|
||||
season = atoi(m[1])
|
||||
episode = atoi(m[2])
|
||||
return season, episode, true
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
func atoi(s string) int {
|
||||
n := 0
|
||||
for _, c := range s {
|
||||
if c >= '0' && c <= '9' {
|
||||
n = n*10 + int(c-'0')
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func extractCleanTitle(releaseName string) string {
|
||||
cleaned := bracketRe2.ReplaceAllString(releaseName, " ")
|
||||
if m := seasonEpisodeRe.FindStringIndex(cleaned); m != nil {
|
||||
cleaned = cleaned[:m[0]]
|
||||
} else if m := qualityTrailRe.FindStringIndex(cleaned); m != nil {
|
||||
cleaned = cleaned[:m[0]]
|
||||
}
|
||||
cleaned = sepRe.ReplaceAllString(cleaned, " ")
|
||||
return normalizeTitle(cleaned)
|
||||
}
|
||||
|
||||
func levenshteinDistance(a, b string) int {
|
||||
la, lb := len(a), len(b)
|
||||
if la == 0 {
|
||||
return lb
|
||||
}
|
||||
if lb == 0 {
|
||||
return la
|
||||
}
|
||||
prev := make([]int, lb+1)
|
||||
curr := make([]int, lb+1)
|
||||
for j := 0; j <= lb; j++ {
|
||||
prev[j] = j
|
||||
}
|
||||
for i := 1; i <= la; i++ {
|
||||
curr[0] = i
|
||||
for j := 1; j <= lb; j++ {
|
||||
cost := 1
|
||||
if a[i-1] == b[j-1] {
|
||||
cost = 0
|
||||
}
|
||||
curr[j] = minOf3(
|
||||
prev[j]+1,
|
||||
curr[j-1]+1,
|
||||
prev[j-1]+cost,
|
||||
)
|
||||
}
|
||||
prev, curr = curr, prev
|
||||
}
|
||||
return prev[lb]
|
||||
}
|
||||
|
||||
func minOf3(a, b, c int) int {
|
||||
if a < b {
|
||||
if a < c {
|
||||
return a
|
||||
}
|
||||
return c
|
||||
}
|
||||
if b < c {
|
||||
return b
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (s *MatcherService) Match(ctx context.Context, releaseName string, mediaType string) (*MatchResult, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
season, episode, hasSE := parseSeasonEpisode(releaseName)
|
||||
cleanTitle := extractCleanTitle(releaseName)
|
||||
|
||||
if cleanTitle == "" {
|
||||
return &MatchResult{Confidence: "none"}, nil
|
||||
}
|
||||
|
||||
qb := NewQueryBuilder(1)
|
||||
qb.AddLiteral("deleted_at IS NULL")
|
||||
qb.AddLiteral("monitored = true")
|
||||
|
||||
if mediaType == "series" || hasSE {
|
||||
qb.AddLiteral("media_type IN ('series', 'episode')")
|
||||
} else if mediaType != "" {
|
||||
qb.AddLiteral("media_type NOT IN ('series', 'episode')")
|
||||
qb.Add("media_type = $%d", mediaType)
|
||||
}
|
||||
|
||||
query := fmt.Sprintf("SELECT %s FROM media%s", mediaColumns, qb.Where())
|
||||
rows, err := s.db.Pool.Query(ctx, query, qb.Args()...)
|
||||
if err != nil {
|
||||
slog.Error("failed to query media for matching", "error", err)
|
||||
return nil, fmt.Errorf("query media candidates: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
candidates, err := scanMediaRows(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan media candidates: %w", err)
|
||||
}
|
||||
|
||||
var exactMatch *Media
|
||||
var fuzzyMatch *Media
|
||||
var fuzzyDist int
|
||||
|
||||
for i := range candidates {
|
||||
c := &candidates[i]
|
||||
norm := normalizeTitle(c.Title)
|
||||
|
||||
if norm == cleanTitle {
|
||||
exactMatch = c
|
||||
break
|
||||
}
|
||||
|
||||
dist := levenshteinDistance(cleanTitle, norm)
|
||||
if dist <= 2 {
|
||||
if fuzzyMatch == nil || dist < fuzzyDist {
|
||||
fuzzyMatch = c
|
||||
fuzzyDist = dist
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
matched := exactMatch
|
||||
confidence := "exact"
|
||||
if matched == nil && fuzzyMatch != nil {
|
||||
matched = fuzzyMatch
|
||||
confidence = "fuzzy"
|
||||
}
|
||||
|
||||
if matched == nil {
|
||||
return &MatchResult{Confidence: "none"}, nil
|
||||
}
|
||||
|
||||
result := &MatchResult{
|
||||
MediaID: matched.ID,
|
||||
MediaType: matched.MediaType,
|
||||
Title: matched.Title,
|
||||
Year: matched.Year,
|
||||
Confidence: confidence,
|
||||
}
|
||||
|
||||
if hasSE {
|
||||
result.Season = &season
|
||||
result.Episode = &episode
|
||||
}
|
||||
|
||||
if matched.RootFolderID != nil {
|
||||
var path string
|
||||
if err := s.db.Pool.QueryRow(ctx,
|
||||
"SELECT path FROM root_folders WHERE id = $1", *matched.RootFolderID).Scan(&path); err != nil {
|
||||
slog.Error("failed to query root folder", "error", err, "root_folder_id", *matched.RootFolderID)
|
||||
} else {
|
||||
result.RootFolder = path
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
Reference in New Issue
Block a user