Skip to content

Commit

Permalink
refactor(processor): improve release comparing (#67)
Browse files Browse the repository at this point in the history
* refactor(processor): improve release comparing

* chore(config): add compareRepackStatus to config

* refactor(processor): rewrote release comparer & added more logging

* chore(tests): adjust formatting tests

* fix(processor): log formatting

* fix(release): copy paste error

* fix(release): continue after condition not matching

* fix(processor): incorrect response codes

* chore(processor): clearer logging

* chore: add missing license header

* chore(processor): improve logging again

* feat(config): fuzzyMatching options

* chore: remove fuzzy matching debug logging

* chore(config): added default value info

* docs(readme): added info about fuzzy matching

* chore(release): update comments to reflect name changes

* fix(release): don't modify slice when comparing

* fix(slices): check for empty hdr slice
  • Loading branch information
nuxencs authored Jan 26, 2024
1 parent 2f8a89c commit fd58c78
Show file tree
Hide file tree
Showing 10 changed files with 285 additions and 94 deletions.
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,22 @@ using the parsed folder name the files will be hardlinked into the exact folder
You can take a look at the [Webhook](#webhook) section to see what you would need to add in your autobrr filter to
make use of this feature.

### Fuzzy Matching

In this section, you can toggle comparing rules. I will explain each of them in more detail here.

1. **skipRepackCompare**: When set to `true`, the comparer skips checking the repack status of the season pack release
against the episodes in your client. The episode in the example will only be accepted as a match by seasonpackarr if
you enable this option:
- Announce name: `Show.S01.1080p.WEB-DL.DDPA5.1.H.264-RlsGrp`
- Episode name: `Show.S01E01.1080p.WEB-DL.REPACK.DDPA5.1.H.264-RlsGrp`

2. **simplifyHdrCompare**: If set to `true`, this option simplifies the HDR formats `HDR10`, `HDR10+`, and `HDR+` to
just `HDR`. This increases the likelihood of matching renamed releases that specify a more advanced HDR format in the
announce name than in the episode title:
- Announce name: `Show.S01.2160p.WEB-DL.DDPA5.1.DV.HDR10+.H.265-RlsGrp`
- Episode name: `Show.S01E01.2160p.WEB-DL.DDPA5.1.DV.HDR.H.265-RlsGrp`

## autobrr Filter setup

Support for multiple Sonarr and qBittorrent instances with different pre import directories was added with v0.4.0, so
Expand Down
18 changes: 18 additions & 0 deletions config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,24 @@ logLevel: "DEBUG"
#
# parseTorrentFile: false

# Fuzzy Matching
# You can decide for which criteria the matching should be less strict, e.g. repack status and HDR format
#
fuzzyMatching:
# Skip Repack Compare
# Toggle comparing of the repack status of a release, e.g. repacked episodes will be treated the same as a non-repacked ones
#
# Default: false
#
skipRepackCompare: false

# Simplify HDR Compare
# Toggle simplification of HDR formats for comparing, e.g. HDR10+ will be treated the same as HDR
#
# Default: false
#
simplifyHdrCompare: false

# API Token
# If not defined, removes api authentication
#
Expand Down
24 changes: 23 additions & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,24 @@ logLevel: "DEBUG"
#
# parseTorrentFile: false
# Fuzzy Matching
# You can decide for which criteria the matching should be less strict, e.g. repack status and HDR format
#
fuzzyMatching:
# Skip Repack Compare
# Toggle comparing of the repack status of a release, e.g. repacked episodes will be treated the same as a non-repacked ones
#
# Default: false
#
skipRepackCompare: false
# Simplify HDR Compare
# Toggle simplification of HDR formats for comparing, e.g. HDR10+ will be treated the same as HDR
#
# Default: false
#
simplifyHdrCompare: false
# API Token
# If not defined, removes api authentication
#
Expand Down Expand Up @@ -277,7 +295,11 @@ func (c *AppConfig) defaults() {
SmartMode: false,
SmartModeThreshold: 0.75,
ParseTorrentFile: false,
APIToken: "",
FuzzyMatching: domain.FuzzyMatching{
SkipRepackCompare: false,
SimplifyHdrCompare: false,
},
APIToken: "",
}
}

Expand Down
6 changes: 6 additions & 0 deletions internal/domain/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ type Client struct {
PreImportPath string `yaml:"preImportPath"`
}

type FuzzyMatching struct {
SkipRepackCompare bool `yaml:"skipRepackCompare"`
SimplifyHdrCompare bool `yaml:"simplifyHdrCompare"`
}

type Config struct {
Version string
ConfigPath string
Expand All @@ -25,5 +30,6 @@ type Config struct {
SmartMode bool `yaml:"smartMode"`
SmartModeThreshold float32 `yaml:"smartModeThreshold"`
ParseTorrentFile bool `yaml:"parseTorrentFile"`
FuzzyMatching FuzzyMatching `yaml:"fuzzyMatching"`
APIToken string `yaml:"apiToken"`
}
14 changes: 14 additions & 0 deletions internal/domain/release.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Copyright (c) 2023 - 2024, nuxen and the seasonpackarr contributors.
// SPDX-License-Identifier: GPL-2.0-or-later

package domain

import (
"github.com/autobrr/go-qbittorrent"
"github.com/moistari/rls"
)

type Entry struct {
T qbittorrent.Torrent
R rls.Release
}
121 changes: 72 additions & 49 deletions internal/http/processor.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,14 @@ import (
"seasonpackarr/internal/config"
"seasonpackarr/internal/domain"
"seasonpackarr/internal/logger"
"seasonpackarr/internal/release"
"seasonpackarr/internal/utils"

"github.com/autobrr/go-qbittorrent"
"github.com/moistari/rls"
"github.com/rs/zerolog"

"golang.org/x/exp/slices"
)

type processor struct {
Expand All @@ -27,11 +30,6 @@ type processor struct {
req *request
}

type entry struct {
t qbittorrent.Torrent
r rls.Release
}

type request struct {
Name string
Torrent json.RawMessage
Expand All @@ -40,7 +38,7 @@ type request struct {
}

type entryTime struct {
e map[string][]entry
e map[string][]domain.Entry
d map[string]rls.Release
t time.Time
err error
Expand Down Expand Up @@ -125,7 +123,7 @@ func (p processor) getAllTorrents(client *domain.Client) entryTime {
}

nt := time.Now()
res = &entryTime{e: make(map[string][]entry), t: nt.Add(nt.Sub(cur)), d: res.d}
res = &entryTime{e: make(map[string][]domain.Entry), t: nt.Add(nt.Sub(cur)), d: res.d}

for _, t := range torrents {
r, ok := res.d[t.Name]
Expand All @@ -135,7 +133,7 @@ func (p processor) getAllTorrents(client *domain.Client) entryTime {
}

s := utils.GetFormattedTitle(r)
res.e[s] = append(res.e[s], entry{t: t, r: r})
res.e[s] = append(res.e[s], domain.Entry{T: t, R: r})
}

torrentMap.Store(set, res)
Expand Down Expand Up @@ -187,8 +185,8 @@ func (p processor) ProcessSeasonPack(w netHTTP.ResponseWriter, r *netHTTP.Reques
return
}

requestrls := entry{r: rls.ParseString(p.req.Name)}
v, ok := mp.e[utils.GetFormattedTitle(requestrls.r)]
requestrls := domain.Entry{R: rls.ParseString(p.req.Name)}
v, ok := mp.e[utils.GetFormattedTitle(requestrls.R)]
if !ok {
p.log.Info().Msgf("no matching releases in client %q: %q", clientName, p.req.Name)
netHTTP.Error(w, fmt.Sprintf("no matching releases in client %q: %q", clientName, p.req.Name), 200)
Expand All @@ -199,17 +197,66 @@ func (p processor) ProcessSeasonPack(w netHTTP.ResponseWriter, r *netHTTP.Reques
p.log.Debug().Msgf("formatted season pack name: %q", packDirName)

for _, child := range v {
if checkCandidates(&requestrls, &child) == 210 {
if release.CheckCandidates(&requestrls, &child, p.cfg.Config.FuzzyMatching) == 210 {
p.log.Info().Msgf("release already in client %q: %q", clientName, p.req.Name)
netHTTP.Error(w, fmt.Sprintf("release already in client %q: %q", clientName, p.req.Name), 210)
return
}
}

var matchedEpisodes []int
var responseCodes []int

for _, child := range v {
switch res := checkCandidates(&requestrls, &child); res {
switch res := release.CheckCandidates(&requestrls, &child, p.cfg.Config.FuzzyMatching); res {
case 201:
p.log.Info().Msgf("resolution did not match: request(%s => %s), client(%s => %s)",
requestrls.R.String(), requestrls.R.Resolution, child.R.String(), child.R.Resolution)
responseCodes = append(responseCodes, res)
continue

case 202:
p.log.Info().Msgf("source did not match: request(%s => %s), client(%s => %s)",
requestrls.R.String(), requestrls.R.Source, child.R.String(), child.R.Source)
responseCodes = append(responseCodes, res)
continue

case 203:
p.log.Info().Msgf("release group did not match: request(%s => %s), client(%s => %s)",
requestrls.R.String(), requestrls.R.Group, child.R.String(), child.R.Group)
responseCodes = append(responseCodes, res)
continue

case 204:
p.log.Info().Msgf("cut did not match: request(%s => %s), client(%s => %s)",
requestrls.R.String(), requestrls.R.Cut, child.R.String(), child.R.Cut)
responseCodes = append(responseCodes, res)
continue

case 205:
p.log.Info().Msgf("edition did not match: request(%s => %s), client(%s => %s)",
requestrls.R.String(), requestrls.R.Edition, child.R.String(), child.R.Edition)
responseCodes = append(responseCodes, res)
continue

case 206:
p.log.Info().Msgf("repack status did not match: request(%s => %s), client(%s => %s)",
requestrls.R.String(), requestrls.R.Other, child.R.String(), child.R.Other)
responseCodes = append(responseCodes, res)
continue

case 207:
p.log.Info().Msgf("hdr metadata did not match: request(%s => %s), client(%s => %s)",
requestrls.R.String(), requestrls.R.HDR, child.R.String(), child.R.HDR)
responseCodes = append(responseCodes, res)
continue

case 208:
p.log.Info().Msgf("streaming service did not match: request(%s => %s), client(%s => %s)",
requestrls.R.String(), requestrls.R.Collection, child.R.String(), child.R.Collection)
responseCodes = append(responseCodes, res)
continue

case 210:
p.log.Info().Msgf("release already in client %q: %q", clientName, p.req.Name)
netHTTP.Error(w, fmt.Sprintf("release already in client %q: %q", clientName, p.req.Name), res)
Expand All @@ -221,9 +268,9 @@ func (p processor) ProcessSeasonPack(w netHTTP.ResponseWriter, r *netHTTP.Reques
return

case 250:
m, err := p.getFiles(child.t.Hash)
m, err := p.getFiles(child.T.Hash)
if err != nil {
p.log.Error().Err(err).Msgf("error getting files: %q", child.t.Name)
p.log.Error().Err(err).Msgf("error getting files: %q", child.T.Name)
continue
}

Expand All @@ -233,8 +280,8 @@ func (p processor) ProcessSeasonPack(w netHTTP.ResponseWriter, r *netHTTP.Reques
break
}

episodeRls := rls.ParseString(child.t.Name)
episodePath := filepath.Join(child.t.SavePath, fileName)
episodeRls := rls.ParseString(child.T.Name)
episodePath := filepath.Join(child.T.SavePath, fileName)
packPath := filepath.Join(client.PreImportPath, packDirName, filepath.Base(fileName))

matchedEpisodes = append(matchedEpisodes, episodeRls.Episode)
Expand All @@ -253,11 +300,18 @@ func (p processor) ProcessSeasonPack(w netHTTP.ResponseWriter, r *netHTTP.Reques

newMatches := append(oldMatches.([]matchPaths), currentMatch...)
matchesMap.Store(p.req.Name, newMatches)
p.log.Debug().Msgf("matched torrent from client %q: %q %q", clientName, child.t.Name, child.t.Hash)
p.log.Debug().Msgf("matched torrent from client %q: %q %q", clientName, child.T.Name, child.T.Hash)
responseCodes = append(responseCodes, res)
continue
}
}

if !slices.Contains(responseCodes, 250) {
p.log.Info().Msgf("no matching releases in client %q: %q", clientName, p.req.Name)
netHTTP.Error(w, fmt.Sprintf("no matching releases in client %q: %q", clientName, p.req.Name), 200)
return
}

if matchesSlice, ok := matchesMap.Load(p.req.Name); ok {
if p.cfg.Config.SmartMode {
reqRls := rls.ParseString(p.req.Name)
Expand All @@ -270,7 +324,7 @@ func (p processor) ProcessSeasonPack(w netHTTP.ResponseWriter, r *netHTTP.Reques
}
matchedEpisodes = utils.DedupeSlice(matchedEpisodes)

percentEpisodes := percentOfTotalEpisodes(totalEpisodes, matchedEpisodes)
percentEpisodes := release.PercentOfTotalEpisodes(totalEpisodes, matchedEpisodes)

if percentEpisodes < p.cfg.Config.SmartModeThreshold {
// delete match from matchesMap if threshold is not met
Expand Down Expand Up @@ -359,34 +413,3 @@ func (p processor) ParseTorrent(w netHTTP.ResponseWriter, r *netHTTP.Request) {
netHTTP.Error(w, fmt.Sprintf("created hardlink of %q into %q", match.episodePath, match.packPath), 250)
}
}

func checkCandidates(requestrls, child *entry) int {
rlsRelease := requestrls.r
rlsInClient := child.r

// check if season pack and no extension
if fmt.Sprint(rlsRelease.Type) == "series" && rlsRelease.Ext == "" {
// compare formatted titles
if utils.GetFormattedTitle(rlsInClient) == utils.GetFormattedTitle(rlsRelease) {
// check if same episode
if rlsInClient.Episode == rlsRelease.Episode {
// release is already in client
return 210
}
// season pack with matching episodes
return 250
}
}
// not a season pack
return 211
}

func percentOfTotalEpisodes(totalEpisodes int, matchedEpisodes []int) float32 {
if totalEpisodes == 0 {
return 0
}
count := len(matchedEpisodes)
percent := float32(count) / float32(totalEpisodes)

return percent
}
Loading

0 comments on commit fd58c78

Please sign in to comment.