autobrr/internal/domain/filter.go
kenstir 4009554d10
feat(filters): skip duplicates (#1711)
* feat(filters): skip duplicates

* fix: add interface instead of any

* fix(filters): tonullint

* feat(filters): skip dupes check month day

* chore: cleanup

* feat(db): set autoincrement id

* feat(filters): add repack and proper to dupe profile

* feat(filters): add default dupe profiles

* feat(duplicates): check audio and website

* feat(duplicates): update tests

* feat(duplicates): add toggles on addform

* feat(duplicates): fix sqlite upgrade path and initialize duplicate profiles

* feat(duplicates): simplify sqlite upgrade

avoiding temp table and unwieldy select.  Besides, FK constraints
are turned off anyway in #229.

* feat(duplicates): change CheckIsDuplicateRelease treatment of PROPER and REPACK

"Proper" and "Repack" are not parallel to the other conditions like "Title",
so they do not belong as dedup conditions.  "PROPER" means there was an issue in
the previous release, and so a PROPER is never a duplicate, even if it replaces
another PROPER.  Similarly, "REPACK" means there was an issue in the previous
release by that group, and so it is a duplicate only if we previously took a
release from a DIFFERENT group.

I have not removed Proper and Repack from the UI or the schema yet.

* feat(duplicates): update postgres schema to match sqlite

* feat(duplicates): fix web build errors

* feat(duplicates): fix postgres errors

* feat(filters): do leftjoin for duplicate profile

* fix(filters): partial update dupe profile

* go fmt `internal/domain/filter.go`

* feat(duplicates): restore straightforward logic for proper/repack

* feat(duplicates): remove mostly duplicate TV duplicate profiles

Having one profile seems the cleanest.  If somebody wants multiple
resolutions then they can add Resolution to the duplicate profile.
Tested this profile with both weekly episodic releases and daily
show releases.

* feat(release): add db indexes and sub_title

* feat(release): add IsDuplicate tests

* feat(release): update action handler

* feat(release): add more tests for skip duplicates

* feat(duplicates): check audio

* feat(duplicates): add more tests

* feat(duplicates): match edition cut and more

* fix(duplicates): tests

* fix(duplicates): missing imports

* fix(duplicates): tests

* feat(duplicates): handle sub_title edition and language in ui

* fix(duplicates): tests

* feat(duplicates): check name against normalized hash

* fix(duplicates): tests

* chore: update .gitignore to ignore .pnpm-store

* fix: tests

* fix(filters): tests

* fix: bad conflict merge

* fix: update release type in test

* fix: use vendored hot-toast

* fix: release_test.go

* fix: rss_test.go

* feat(duplicates): improve title hashing for unique check

* feat(duplicates): further improve title hashing for unique check with lang

* feat(duplicates): fix tests

* feat(duplicates): add macros IsDuplicate and DuplicateProfile ID and name

* feat(duplicates): add normalized hash match option

* fix: headlessui-state prop warning

* fix(duplicates): add missing year in daily ep normalize

* fix(duplicates): check rejections len

---------

Co-authored-by: ze0s <ze0s@riseup.net>
2024-12-25 22:33:46 +01:00

1279 lines
44 KiB
Go

// Copyright (c) 2021 - 2024, Ludvig Lundgren and the autobrr contributors.
// SPDX-License-Identifier: GPL-2.0-or-later
package domain
import (
"context"
"fmt"
"os/exec"
"strconv"
"strings"
"time"
"github.com/autobrr/autobrr/pkg/errors"
"github.com/autobrr/autobrr/pkg/regexcache"
"github.com/autobrr/autobrr/pkg/sanitize"
"github.com/autobrr/autobrr/pkg/wildcard"
"github.com/dustin/go-humanize"
"github.com/go-andiamo/splitter"
)
/*
Works the same way as for autodl-irssi
https://autodl-community.github.io/autodl-irssi/configuration/filter/
*/
type FilterRepo interface {
ListFilters(ctx context.Context) ([]Filter, error)
Find(ctx context.Context, params FilterQueryParams) ([]Filter, error)
FindByID(ctx context.Context, filterID int) (*Filter, error)
FindByIndexerIdentifier(ctx context.Context, indexer string) ([]*Filter, error)
FindExternalFiltersByID(ctx context.Context, filterId int) ([]FilterExternal, error)
Store(ctx context.Context, filter *Filter) error
Update(ctx context.Context, filter *Filter) error
UpdatePartial(ctx context.Context, filter FilterUpdate) error
ToggleEnabled(ctx context.Context, filterID int, enabled bool) error
Delete(ctx context.Context, filterID int) error
StoreIndexerConnection(ctx context.Context, filterID int, indexerID int) error
StoreIndexerConnections(ctx context.Context, filterID int, indexers []Indexer) error
StoreFilterExternal(ctx context.Context, filterID int, externalFilters []FilterExternal) error
DeleteIndexerConnections(ctx context.Context, filterID int) error
DeleteFilterExternal(ctx context.Context, filterID int) error
GetDownloadsByFilterId(ctx context.Context, filterID int) (*FilterDownloads, error)
}
type FilterDownloads struct {
HourCount int
DayCount int
WeekCount int
MonthCount int
TotalCount int
}
func (f *FilterDownloads) String() string {
return fmt.Sprintf("Hour: %d, Day: %d, Week: %d, Month: %d, Total: %d", f.HourCount, f.DayCount, f.WeekCount, f.MonthCount, f.TotalCount)
}
type FilterMaxDownloadsUnit string
const (
FilterMaxDownloadsHour FilterMaxDownloadsUnit = "HOUR"
FilterMaxDownloadsDay FilterMaxDownloadsUnit = "DAY"
FilterMaxDownloadsWeek FilterMaxDownloadsUnit = "WEEK"
FilterMaxDownloadsMonth FilterMaxDownloadsUnit = "MONTH"
FilterMaxDownloadsEver FilterMaxDownloadsUnit = "EVER"
)
type SmartEpisodeParams struct {
Title string
Season int
Episode int
Year int
Month int
Day int
Repack bool
Proper bool
Group string
}
func (p *SmartEpisodeParams) IsDailyEpisode() bool {
return p.Year != 0 && p.Month != 0 && p.Day != 0
}
type FilterQueryParams struct {
Sort map[string]string
Filters struct {
Indexers []string
}
Search string
}
type Filter struct {
ID int `json:"id"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
MinSize string `json:"min_size,omitempty"`
MaxSize string `json:"max_size,omitempty"`
Delay int `json:"delay,omitempty"`
Priority int32 `json:"priority"`
MaxDownloads int `json:"max_downloads,omitempty"`
MaxDownloadsUnit FilterMaxDownloadsUnit `json:"max_downloads_unit,omitempty"`
MatchReleases string `json:"match_releases,omitempty"`
ExceptReleases string `json:"except_releases,omitempty"`
UseRegex bool `json:"use_regex,omitempty"`
MatchReleaseGroups string `json:"match_release_groups,omitempty"`
ExceptReleaseGroups string `json:"except_release_groups,omitempty"`
AnnounceTypes []string `json:"announce_types,omitempty"`
Scene bool `json:"scene,omitempty"`
Origins []string `json:"origins,omitempty"`
ExceptOrigins []string `json:"except_origins,omitempty"`
Bonus []string `json:"bonus,omitempty"`
Freeleech bool `json:"freeleech,omitempty"`
FreeleechPercent string `json:"freeleech_percent,omitempty"`
SmartEpisode bool `json:"smart_episode"`
Shows string `json:"shows,omitempty"`
Seasons string `json:"seasons,omitempty"`
Episodes string `json:"episodes,omitempty"`
Resolutions []string `json:"resolutions,omitempty"` // SD, 480i, 480p, 576p, 720p, 810p, 1080i, 1080p.
Codecs []string `json:"codecs,omitempty"` // XviD, DivX, x264, h.264 (or h264), mpeg2 (or mpeg-2), VC-1 (or VC1), WMV, Remux, h.264 Remux (or h264 Remux), VC-1 Remux (or VC1 Remux).
Sources []string `json:"sources,omitempty"` // DSR, PDTV, HDTV, HR.PDTV, HR.HDTV, DVDRip, DVDScr, BDr, BD5, BD9, BDRip, BRRip, DVDR, MDVDR, HDDVD, HDDVDRip, BluRay, WEB-DL, TVRip, CAM, R5, TELESYNC, TS, TELECINE, TC. TELESYNC and TS are synonyms (you don't need both). Same for TELECINE and TC
Containers []string `json:"containers,omitempty"`
MatchHDR []string `json:"match_hdr,omitempty"`
ExceptHDR []string `json:"except_hdr,omitempty"`
MatchOther []string `json:"match_other,omitempty"`
ExceptOther []string `json:"except_other,omitempty"`
Years string `json:"years,omitempty"`
Months string `json:"months,omitempty"`
Days string `json:"days,omitempty"`
Artists string `json:"artists,omitempty"`
Albums string `json:"albums,omitempty"`
MatchReleaseTypes []string `json:"match_release_types,omitempty"` // Album,Single,EP
ExceptReleaseTypes string `json:"except_release_types,omitempty"`
Formats []string `json:"formats,omitempty"` // MP3, FLAC, Ogg, AAC, AC3, DTS
Quality []string `json:"quality,omitempty"` // 192, 320, APS (VBR), V2 (VBR), V1 (VBR), APX (VBR), V0 (VBR), q8.x (VBR), Lossless, 24bit Lossless, Other
Media []string `json:"media,omitempty"` // CD, DVD, Vinyl, Soundboard, SACD, DAT, Cassette, WEB, Other
PerfectFlac bool `json:"perfect_flac,omitempty"`
Cue bool `json:"cue,omitempty"`
Log bool `json:"log,omitempty"`
LogScore int `json:"log_score,omitempty"`
MatchCategories string `json:"match_categories,omitempty"`
ExceptCategories string `json:"except_categories,omitempty"`
MatchUploaders string `json:"match_uploaders,omitempty"`
ExceptUploaders string `json:"except_uploaders,omitempty"`
MatchRecordLabels string `json:"match_record_labels,omitempty"`
ExceptRecordLabels string `json:"except_record_labels,omitempty"`
MatchLanguage []string `json:"match_language,omitempty"`
ExceptLanguage []string `json:"except_language,omitempty"`
Tags string `json:"tags,omitempty"`
ExceptTags string `json:"except_tags,omitempty"`
TagsAny string `json:"tags_any,omitempty"`
ExceptTagsAny string `json:"except_tags_any,omitempty"`
TagsMatchLogic string `json:"tags_match_logic,omitempty"`
ExceptTagsMatchLogic string `json:"except_tags_match_logic,omitempty"`
MatchReleaseTags string `json:"match_release_tags,omitempty"`
ExceptReleaseTags string `json:"except_release_tags,omitempty"`
UseRegexReleaseTags bool `json:"use_regex_release_tags,omitempty"`
MatchDescription string `json:"match_description,omitempty"`
ExceptDescription string `json:"except_description,omitempty"`
UseRegexDescription bool `json:"use_regex_description,omitempty"`
MinSeeders int `json:"min_seeders,omitempty"`
MaxSeeders int `json:"max_seeders,omitempty"`
MinLeechers int `json:"min_leechers,omitempty"`
MaxLeechers int `json:"max_leechers,omitempty"`
ActionsCount int `json:"actions_count"`
ActionsEnabledCount int `json:"actions_enabled_count"`
IsAutoUpdated bool `json:"is_auto_updated"`
Actions []*Action `json:"actions,omitempty"`
External []FilterExternal `json:"external,omitempty"`
Indexers []Indexer `json:"indexers"`
ReleaseProfileDuplicateID int64 `json:"release_profile_duplicate_id,omitempty"`
DuplicateHandling *DuplicateReleaseProfile `json:"release_profile_duplicate"`
Downloads *FilterDownloads `json:"-"`
Rejections []string `json:"-"`
RejectReasons *RejectionReasons `json:"-"`
}
type FilterExternal struct {
ID int `json:"id"`
Name string `json:"name"`
Index int `json:"index"`
Type FilterExternalType `json:"type"`
Enabled bool `json:"enabled"`
ExecCmd string `json:"exec_cmd,omitempty"`
ExecArgs string `json:"exec_args,omitempty"`
ExecExpectStatus int `json:"exec_expect_status,omitempty"`
WebhookHost string `json:"webhook_host,omitempty"`
WebhookMethod string `json:"webhook_method,omitempty"`
WebhookData string `json:"webhook_data,omitempty"`
WebhookHeaders string `json:"webhook_headers,omitempty"`
WebhookExpectStatus int `json:"webhook_expect_status,omitempty"`
WebhookRetryStatus string `json:"webhook_retry_status,omitempty"`
WebhookRetryAttempts int `json:"webhook_retry_attempts,omitempty"`
WebhookRetryDelaySeconds int `json:"webhook_retry_delay_seconds,omitempty"`
FilterId int `json:"-"`
}
func (f FilterExternal) NeedTorrentDownloaded() bool {
if strings.Contains(f.ExecArgs, "TorrentHash") || strings.Contains(f.WebhookData, "TorrentHash") {
return true
}
if strings.Contains(f.ExecArgs, "TorrentPathName") || strings.Contains(f.WebhookData, "TorrentPathName") {
return true
}
if strings.Contains(f.WebhookData, "TorrentDataRawBytes") {
return true
}
return false
}
type FilterExternalType string
const (
ExternalFilterTypeExec FilterExternalType = "EXEC"
ExternalFilterTypeWebhook FilterExternalType = "WEBHOOK"
)
type FilterUpdate struct {
ID int `json:"id"`
Name *string `json:"name,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
MinSize *string `json:"min_size,omitempty"`
MaxSize *string `json:"max_size,omitempty"`
Delay *int `json:"delay,omitempty"`
Priority *int32 `json:"priority,omitempty"`
AnnounceTypes *[]string `json:"announce_types,omitempty"`
MaxDownloads *int `json:"max_downloads,omitempty"`
MaxDownloadsUnit *FilterMaxDownloadsUnit `json:"max_downloads_unit,omitempty"`
MatchReleases *string `json:"match_releases,omitempty"`
ExceptReleases *string `json:"except_releases,omitempty"`
UseRegex *bool `json:"use_regex,omitempty"`
MatchReleaseGroups *string `json:"match_release_groups,omitempty"`
ExceptReleaseGroups *string `json:"except_release_groups,omitempty"`
MatchReleaseTags *string `json:"match_release_tags,omitempty"`
ExceptReleaseTags *string `json:"except_release_tags,omitempty"`
UseRegexReleaseTags *bool `json:"use_regex_release_tags,omitempty"`
MatchDescription *string `json:"match_description,omitempty"`
ExceptDescription *string `json:"except_description,omitempty"`
UseRegexDescription *bool `json:"use_regex_description,omitempty"`
Scene *bool `json:"scene,omitempty"`
Origins *[]string `json:"origins,omitempty"`
ExceptOrigins *[]string `json:"except_origins,omitempty"`
Bonus *[]string `json:"bonus,omitempty"`
Freeleech *bool `json:"freeleech,omitempty"`
FreeleechPercent *string `json:"freeleech_percent,omitempty"`
SmartEpisode *bool `json:"smart_episode,omitempty"`
Shows *string `json:"shows,omitempty"`
Seasons *string `json:"seasons,omitempty"`
Episodes *string `json:"episodes,omitempty"`
Resolutions *[]string `json:"resolutions,omitempty"` // SD, 480i, 480p, 576p, 720p, 810p, 1080i, 1080p.
Codecs *[]string `json:"codecs,omitempty"` // XviD, DivX, x264, h.264 (or h264), mpeg2 (or mpeg-2), VC-1 (or VC1), WMV, Remux, h.264 Remux (or h264 Remux), VC-1 Remux (or VC1 Remux).
Sources *[]string `json:"sources,omitempty"` // DSR, PDTV, HDTV, HR.PDTV, HR.HDTV, DVDRip, DVDScr, BDr, BD5, BD9, BDRip, BRRip, DVDR, MDVDR, HDDVD, HDDVDRip, BluRay, WEB-DL, TVRip, CAM, R5, TELESYNC, TS, TELECINE, TC. TELESYNC and TS are synonyms (you don't need both). Same for TELECINE and TC
Containers *[]string `json:"containers,omitempty"`
MatchHDR *[]string `json:"match_hdr,omitempty"`
ExceptHDR *[]string `json:"except_hdr,omitempty"`
MatchOther *[]string `json:"match_other,omitempty"`
ExceptOther *[]string `json:"except_other,omitempty"`
Years *string `json:"years,omitempty"`
Months *string `json:"months,omitempty"`
Days *string `json:"days,omitempty"`
Artists *string `json:"artists,omitempty"`
Albums *string `json:"albums,omitempty"`
MatchReleaseTypes *[]string `json:"match_release_types,omitempty"` // Album,Single,EP
ExceptReleaseTypes *string `json:"except_release_types,omitempty"`
Formats *[]string `json:"formats,omitempty"` // MP3, FLAC, Ogg, AAC, AC3, DTS
Quality *[]string `json:"quality,omitempty"` // 192, 320, APS (VBR), V2 (VBR), V1 (VBR), APX (VBR), V0 (VBR), q8.x (VBR), Lossless, 24bit Lossless, Other
Media *[]string `json:"media,omitempty"` // CD, DVD, Vinyl, Soundboard, SACD, DAT, Cassette, WEB, Other
PerfectFlac *bool `json:"perfect_flac,omitempty"`
Cue *bool `json:"cue,omitempty"`
Log *bool `json:"log,omitempty"`
LogScore *int `json:"log_score,omitempty"`
MatchCategories *string `json:"match_categories,omitempty"`
ExceptCategories *string `json:"except_categories,omitempty"`
MatchUploaders *string `json:"match_uploaders,omitempty"`
ExceptUploaders *string `json:"except_uploaders,omitempty"`
MatchRecordLabels *string `json:"match_record_labels,omitempty"`
ExceptRecordLabels *string `json:"except_record_labels,omitempty"`
MatchLanguage *[]string `json:"match_language,omitempty"`
ExceptLanguage *[]string `json:"except_language,omitempty"`
Tags *string `json:"tags,omitempty"`
ExceptTags *string `json:"except_tags,omitempty"`
TagsAny *string `json:"tags_any,omitempty"`
ExceptTagsAny *string `json:"except_tags_any,omitempty"`
TagsMatchLogic *string `json:"tags_match_logic,omitempty"`
ExceptTagsMatchLogic *string `json:"except_tags_match_logic,omitempty"`
MinSeeders *int `json:"min_seeders,omitempty"`
MaxSeeders *int `json:"max_seeders,omitempty"`
MinLeechers *int `json:"min_leechers,omitempty"`
MaxLeechers *int `json:"max_leechers,omitempty"`
ReleaseProfileDuplicateID *int64 `json:"release_profile_duplicate_id,omitempty"`
Actions []*Action `json:"actions,omitempty"`
External []FilterExternal `json:"external,omitempty"`
Indexers []Indexer `json:"indexers,omitempty"`
}
func (f *Filter) Validate() error {
if f.Name == "" {
return errors.New("validation: name can't be empty")
}
if _, _, err := f.parsedSizeLimits(); err != nil {
return fmt.Errorf("error validating filter size limits: %w", err)
}
for _, external := range f.External {
if external.Type == ExternalFilterTypeExec {
if external.ExecCmd != "" && external.Enabled {
// check if program exists
_, err := exec.LookPath(external.ExecCmd)
if err != nil {
return errors.Wrap(err, "could not find external exec command: %s", external.ExecCmd)
}
}
}
}
for _, action := range f.Actions {
if action.Type == ActionTypeExec {
if action.ExecCmd != "" && action.Enabled {
// check if program exists
_, err := exec.LookPath(action.ExecCmd)
if err != nil {
return errors.Wrap(err, "could not find action exec command: %s", action.ExecCmd)
}
}
}
}
return nil
}
func (f *Filter) Sanitize() error {
f.Shows = sanitize.FilterString(f.Shows)
if !f.UseRegex {
f.MatchReleases = sanitize.FilterString(f.MatchReleases)
f.ExceptReleases = sanitize.FilterString(f.ExceptReleases)
}
if !f.UseRegexDescription {
f.MatchDescription = sanitize.FilterString(f.MatchDescription)
f.ExceptDescription = sanitize.FilterString(f.ExceptDescription)
}
f.MatchReleaseGroups = sanitize.FilterString(f.MatchReleaseGroups)
f.ExceptReleaseGroups = sanitize.FilterString(f.ExceptReleaseGroups)
f.MatchCategories = sanitize.FilterString(f.MatchCategories)
f.ExceptCategories = sanitize.FilterString(f.ExceptCategories)
f.MatchUploaders = sanitize.FilterString(f.MatchUploaders)
f.ExceptUploaders = sanitize.FilterString(f.ExceptUploaders)
f.TagsAny = sanitize.FilterString(f.TagsAny)
f.ExceptTags = sanitize.FilterString(f.ExceptTags)
if !f.UseRegexReleaseTags {
f.MatchReleaseTags = sanitize.FilterString(f.MatchReleaseTags)
f.ExceptReleaseTags = sanitize.FilterString(f.ExceptReleaseTags)
}
f.Years = sanitize.FilterString(f.Years)
f.Months = sanitize.FilterString(f.Months)
f.Days = sanitize.FilterString(f.Days)
f.Artists = sanitize.FilterString(f.Artists)
f.Albums = sanitize.FilterString(f.Albums)
f.MatchRecordLabels = sanitize.FilterString(f.MatchRecordLabels)
f.ExceptRecordLabels = sanitize.FilterString(f.ExceptRecordLabels)
return nil
}
func (f *Filter) CheckFilter(r *Release) (*RejectionReasons, bool) {
f.RejectReasons = NewRejectionReasons()
// max downloads check. If reached return early so other filters can be checked as quick as possible.
if f.MaxDownloads > 0 && !f.checkMaxDownloads() {
f.RejectReasons.Addf("max downloads", fmt.Sprintf("[max downloads] reached %d per %s", f.MaxDownloads, f.MaxDownloadsUnit), f.Downloads.String(), fmt.Sprintf("reached %d per %s", f.MaxDownloads, f.MaxDownloadsUnit))
return f.RejectReasons, false
}
if len(f.Bonus) > 0 && !sliceContainsSlice(r.Bonus, f.Bonus) {
f.RejectReasons.Add("bonus", r.Bonus, f.Bonus)
}
if f.Freeleech && r.Freeleech != f.Freeleech {
f.RejectReasons.Add("freeleech", r.Freeleech, f.Freeleech)
}
if f.FreeleechPercent != "" && !checkFreeleechPercent(r.FreeleechPercent, f.FreeleechPercent) {
f.RejectReasons.Add("freeleech percent", r.FreeleechPercent, f.FreeleechPercent)
}
if len(f.AnnounceTypes) > 0 && !basicContainsSlice(string(r.AnnounceType), f.AnnounceTypes) {
f.RejectReasons.Add("match announce type", r.AnnounceType, f.AnnounceTypes)
}
if len(f.Origins) > 0 && !containsSlice(r.Origin, f.Origins) {
f.RejectReasons.Add("match origin", r.Origin, f.Origins)
}
if len(f.ExceptOrigins) > 0 && containsSlice(r.Origin, f.ExceptOrigins) {
f.RejectReasons.Add("except origin", r.Origin, f.ExceptOrigins)
}
// title is the parsed title
if f.Shows != "" && !contains(r.Title, f.Shows) {
f.RejectReasons.Add("shows", r.Title, f.Shows)
}
if f.Seasons != "" && !containsIntStrings(r.Season, f.Seasons) {
f.RejectReasons.Add("season", r.Season, f.Seasons)
}
if f.Episodes != "" && !containsIntStrings(r.Episode, f.Episodes) {
f.RejectReasons.Add("episodes", r.Episode, f.Episodes)
}
// matchRelease
// match against regex
if f.UseRegex {
if f.MatchReleases != "" && !matchRegex(r.TorrentName, f.MatchReleases) {
f.RejectReasons.Add("match releases: REGEX", r.TorrentName, f.MatchReleases)
}
if f.ExceptReleases != "" && matchRegex(r.TorrentName, f.ExceptReleases) {
f.RejectReasons.Add("except releases: REGEX", r.TorrentName, f.ExceptReleases)
}
} else {
if f.MatchReleases != "" && !containsFuzzy(r.TorrentName, f.MatchReleases) {
f.RejectReasons.Add("match releases", r.TorrentName, f.MatchReleases)
}
if f.ExceptReleases != "" && containsFuzzy(r.TorrentName, f.ExceptReleases) {
f.RejectReasons.Add("except releases", r.TorrentName, f.ExceptReleases)
}
}
if f.MatchReleaseGroups != "" && !contains(r.Group, f.MatchReleaseGroups) {
f.RejectReasons.Add("match release groups", r.Group, f.MatchReleaseGroups)
}
if f.ExceptReleaseGroups != "" && contains(r.Group, f.ExceptReleaseGroups) {
f.RejectReasons.Add("except release groups", r.Group, f.ExceptReleaseGroups)
}
// check raw releaseTags string
if f.UseRegexReleaseTags {
if f.MatchReleaseTags != "" && !matchRegex(r.ReleaseTags, f.MatchReleaseTags) {
f.RejectReasons.Add("match release tags: REGEX", r.ReleaseTags, f.MatchReleaseTags)
}
if f.ExceptReleaseTags != "" && matchRegex(r.ReleaseTags, f.ExceptReleaseTags) {
f.RejectReasons.Add("except release tags: REGEX", r.ReleaseTags, f.ExceptReleaseTags)
}
} else {
if f.MatchReleaseTags != "" && !containsFuzzy(r.ReleaseTags, f.MatchReleaseTags) {
f.RejectReasons.Add("match release tags", r.ReleaseTags, f.MatchReleaseTags)
}
if f.ExceptReleaseTags != "" && containsFuzzy(r.ReleaseTags, f.ExceptReleaseTags) {
f.RejectReasons.Add("except release tags", r.ReleaseTags, f.ExceptReleaseTags)
}
}
if (f.MatchUploaders != "" || f.ExceptUploaders != "") && !f.checkUploader(r) {
// f.checkUploader sets the rejections
}
if (f.MatchRecordLabels != "" || f.ExceptRecordLabels != "") && !f.checkRecordLabel(r) {
// f.checkRecordLabel sets the rejections
}
if len(f.MatchLanguage) > 0 && !sliceContainsSlice(r.Language, f.MatchLanguage) {
f.RejectReasons.Add("match language", r.Language, f.MatchLanguage)
}
if len(f.ExceptLanguage) > 0 && sliceContainsSlice(r.Language, f.ExceptLanguage) {
f.RejectReasons.Add("except language", r.Language, f.ExceptLanguage)
}
if len(f.Resolutions) > 0 && !containsSlice(r.Resolution, f.Resolutions) {
f.RejectReasons.Add("resolution", r.Resolution, f.Resolutions)
}
if len(f.Codecs) > 0 && !sliceContainsSlice(r.Codec, f.Codecs) {
f.RejectReasons.Add("codec", r.Codec, f.Codecs)
}
if len(f.Sources) > 0 && !containsSlice(r.Source, f.Sources) {
f.RejectReasons.Add("source", r.Source, f.Sources)
}
if len(f.Containers) > 0 && !containsSlice(r.Container, f.Containers) {
f.RejectReasons.Add("container", r.Container, f.Containers)
}
if len(f.MatchHDR) > 0 && !matchHDR(r.HDR, f.MatchHDR) {
f.RejectReasons.Add("match hdr", r.HDR, f.MatchHDR)
}
if len(f.ExceptHDR) > 0 && matchHDR(r.HDR, f.ExceptHDR) {
f.RejectReasons.Add("except hdr", r.HDR, f.ExceptHDR)
}
// Other is parsed into the Other slice from rls
if len(f.MatchOther) > 0 && !sliceContainsSlice(r.Other, f.MatchOther) {
f.RejectReasons.Add("match other", r.Other, f.MatchOther)
}
// Other is parsed into the Other slice from rls
if len(f.ExceptOther) > 0 && sliceContainsSlice(r.Other, f.ExceptOther) {
f.RejectReasons.Add("except other", r.Other, f.ExceptOther)
}
if f.Years != "" && !containsIntStrings(r.Year, f.Years) {
f.RejectReasons.Add("year", r.Year, f.Years)
}
if f.Months != "" && !containsIntStrings(r.Month, f.Months) {
f.RejectReasons.Add("month", r.Month, f.Months)
}
if f.Days != "" && !containsIntStrings(r.Day, f.Days) {
f.RejectReasons.Add("day", r.Day, f.Days)
}
if f.MatchCategories != "" {
var categories []string
categories = append(categories, r.Categories...)
if r.Category != "" {
categories = append(categories, r.Category)
}
if !contains(r.Category, f.MatchCategories) && !containsAny(categories, f.MatchCategories) {
f.RejectReasons.Add("match category", strings.Join(categories, ","), f.MatchCategories)
}
}
if f.ExceptCategories != "" {
var categories []string
categories = append(categories, r.Categories...)
if r.Category != "" {
categories = append(categories, r.Category)
}
if contains(r.Category, f.ExceptCategories) && containsAny(categories, f.ExceptCategories) {
f.RejectReasons.Add("except category", strings.Join(categories, ","), f.ExceptCategories)
}
}
// music related
if len(f.MatchReleaseTypes) > 0 && !containsSlice(r.Category, f.MatchReleaseTypes) {
f.RejectReasons.Add("release type", r.Category, f.MatchReleaseTypes)
}
if f.MinSize != "" && !f.checkSizeFilter(r) {
f.RejectReasons.Add("min size", r.Size, f.MinSize)
}
if f.MaxSize != "" && !f.checkSizeFilter(r) {
f.RejectReasons.Add("max size", r.Size, f.MaxSize)
}
if f.Tags != "" {
if f.TagsMatchLogic == "ALL" && !containsAll(r.Tags, f.Tags) {
f.RejectReasons.Add("match tags: ALL", r.Tags, f.Tags)
} else if !containsAny(r.Tags, f.Tags) { // TagsMatchLogic is set to "" by default, this makes sure that "" and "ANY" are treated the same way.
f.RejectReasons.Add("match tags: ANY", r.Tags, f.Tags)
}
}
if f.ExceptTags != "" {
if f.ExceptTagsMatchLogic == "ALL" && containsAll(r.Tags, f.ExceptTags) {
f.RejectReasons.Add("except tags: ALL", r.Tags, f.ExceptTags)
} else if containsAny(r.Tags, f.ExceptTags) { // ExceptTagsMatchLogic is set to "" by default, this makes sure that "" and "ANY" are treated the same way.
f.RejectReasons.Add("except tags: ANY", r.Tags, f.ExceptTags)
}
}
if len(f.Artists) > 0 && !contains(r.Artists, f.Artists) {
f.RejectReasons.Add("artists", r.Artists, f.Artists)
}
if len(f.Albums) > 0 && !contains(r.Title, f.Albums) {
f.RejectReasons.Add("albums", r.Title, f.Albums)
}
// Perfect flac requires Cue, Log, Log Score 100, FLAC and 24bit Lossless
if f.PerfectFlac && !f.isPerfectFLAC(r) {
f.RejectReasons.Add("perfect flac", r.Audio, "Cue, Log, Log Score 100, FLAC and 24bit Lossless")
}
if len(f.Formats) > 0 && !sliceContainsSlice(r.Audio, f.Formats) {
f.RejectReasons.Add("formats", r.Audio, f.Formats)
}
if len(f.Quality) > 0 && !containsMatchBasic(r.Audio, f.Quality) {
f.RejectReasons.Add("quality", r.Audio, f.Quality)
}
if len(f.Media) > 0 && !containsSlice(r.Source, f.Media) {
f.RejectReasons.Add("media", r.Source, f.Media)
}
if f.Cue && !containsAny(r.Audio, "Cue") {
f.RejectReasons.Add("cue", r.Audio, "Cue")
}
if f.Log && !containsAny(r.Audio, "Log") {
f.RejectReasons.Add("log", r.Audio, "Log")
}
if f.Log && f.LogScore != 0 && r.LogScore != f.LogScore {
f.RejectReasons.Add("log score", r.LogScore, f.LogScore)
}
// check description string
if f.UseRegexDescription {
if f.MatchDescription != "" && !matchRegex(r.Description, f.MatchDescription) {
f.RejectReasons.Add("match description: REGEX", r.Description, f.MatchDescription)
}
if f.ExceptDescription != "" && matchRegex(r.Description, f.ExceptDescription) {
f.RejectReasons.Add("except description: REGEX", r.Description, f.ExceptDescription)
}
} else {
if f.MatchDescription != "" && !containsFuzzy(r.Description, f.MatchDescription) {
f.RejectReasons.Add("match description", r.Description, f.MatchDescription)
}
if f.ExceptDescription != "" && containsFuzzy(r.Description, f.ExceptDescription) {
f.RejectReasons.Add("except description", r.Description, f.ExceptDescription)
}
}
// Min and Max Seeders/Leechers is only for Torznab feeds
if f.MinSeeders > 0 {
if f.MinSeeders > r.Seeders {
f.RejectReasons.Add("min seeders", r.Seeders, f.MinSeeders)
}
}
if f.MaxSeeders > 0 {
if f.MaxSeeders < r.Seeders {
f.RejectReasons.Add("max seeders", r.Seeders, f.MaxSeeders)
}
}
if f.MinLeechers > 0 {
if f.MinLeechers > r.Leechers {
f.RejectReasons.Add("min leechers", r.Leechers, f.MinLeechers)
}
}
if f.MaxLeechers > 0 {
if f.MaxLeechers < r.Leechers {
f.RejectReasons.Add("max leechers", r.Leechers, f.MaxLeechers)
}
}
if f.RejectReasons.Len() > 0 {
return f.RejectReasons, false
}
return f.RejectReasons, true
}
func (f *Filter) checkMaxDownloads() bool {
if f.Downloads == nil {
return false
}
var count int
switch f.MaxDownloadsUnit {
case FilterMaxDownloadsHour:
count = f.Downloads.HourCount
case FilterMaxDownloadsDay:
count = f.Downloads.DayCount
case FilterMaxDownloadsWeek:
count = f.Downloads.WeekCount
case FilterMaxDownloadsMonth:
count = f.Downloads.MonthCount
case FilterMaxDownloadsEver:
count = f.Downloads.TotalCount
}
return count < f.MaxDownloads
}
// isPerfectFLAC Perfect is "CD FLAC Cue Log 100% Lossless or 24bit Lossless"
func (f *Filter) isPerfectFLAC(r *Release) bool {
if !contains(r.Source, "CD") {
return false
}
if !containsAny(r.Audio, "Cue") {
return false
}
if !containsAny(r.Audio, "Log") {
return false
}
if !containsAny(r.Audio, "Log100") || r.LogScore != 100 {
return false
}
if !containsAny(r.Audio, "FLAC") {
return false
}
if !containsAnySlice(r.Audio, []string{"Lossless", "24bit Lossless"}) {
return false
}
return true
}
// checkSizeFilter compares the filter size limits to a release's size if it is
// known from the announce line.
func (f *Filter) checkSizeFilter(r *Release) bool {
if r.Size == 0 {
r.AdditionalSizeCheckRequired = true
return true
} else {
r.AdditionalSizeCheckRequired = false
}
sizeOK, err := f.CheckReleaseSize(r.Size)
if err != nil {
f.RejectReasons.Add("size: ERROR", fmt.Sprintf("error checking release size against filter: %v", err), f.MinSize)
return false
}
if !sizeOK {
return false
}
return true
}
// checkUploader checks if the uploader is within the given list.
// if the haystack is not empty but the uploader is, then a further
// investigation is needed
func (f *Filter) checkUploader(r *Release) bool {
// only support additional uploader check for RED and OPS
if r.Uploader == "" && (r.Indexer.Identifier == "redacted" || r.Indexer.Identifier == "ops") {
r.AdditionalUploaderCheckRequired = true
return true
}
if f.MatchUploaders != "" && !contains(r.Uploader, f.MatchUploaders) {
f.RejectReasons.Add("match uploaders", r.Uploader, f.MatchUploaders)
}
if f.ExceptUploaders != "" && contains(r.Uploader, f.ExceptUploaders) {
f.RejectReasons.Add("except uploaders", r.Uploader, f.ExceptUploaders)
}
return true
}
// checkRecordLabel checks if the record label is within the given list.
// if the haystack is not empty but the record label is, then a further
// investigation is needed
func (f *Filter) checkRecordLabel(r *Release) bool {
if r.RecordLabel == "" && (r.Indexer.Identifier == "redacted" || r.Indexer.Identifier == "ops") {
r.AdditionalRecordLabelCheckRequired = true
return true
}
if f.MatchRecordLabels != "" && !contains(r.RecordLabel, f.MatchRecordLabels) {
f.RejectReasons.Add("match record labels", r.RecordLabel, f.MatchRecordLabels)
}
if f.ExceptRecordLabels != "" && contains(r.RecordLabel, f.ExceptRecordLabels) {
f.RejectReasons.Add("except record labels", r.RecordLabel, f.ExceptRecordLabels)
}
return true
}
// IsPerfectFLAC Perfect is "CD FLAC Cue Log 100% Lossless or 24bit Lossless"
func (f *Filter) IsPerfectFLAC(r *Release) ([]string, bool) {
rejections := []string{}
if r.Source != "CD" {
rejections = append(rejections, fmt.Sprintf("wanted Source CD, got %s", r.Source))
}
if r.AudioFormat != "FLAC" {
rejections = append(rejections, fmt.Sprintf("wanted Format FLAC, got %s", r.AudioFormat))
}
if !r.HasCue {
rejections = append(rejections, fmt.Sprintf("wanted Cue, got %t", r.HasCue))
}
if !r.HasLog {
rejections = append(rejections, fmt.Sprintf("wanted Log, got %t", r.HasLog))
}
if r.LogScore != 100 {
rejections = append(rejections, fmt.Sprintf("wanted Log Score 100, got %d", r.LogScore))
}
if !containsSlice(r.Bitrate, []string{"Lossless", "24bit Lossless"}) {
rejections = append(rejections, fmt.Sprintf("wanted Bitrate Lossless / 24bit Lossless, got %s", r.Bitrate))
}
return rejections, len(rejections) == 0
}
func matchRegex(tag string, filterList string) bool {
if tag == "" {
return false
}
sp, err := splitter.NewSplitter(',',
splitter.DoubleQuotes,
splitter.Parenthesis,
splitter.CurlyBrackets,
splitter.SquareBrackets,
)
if err != nil {
return false
}
filters, err := sp.Split(filterList)
if err != nil {
return false
}
for _, filter := range filters {
if filter == "" {
continue
}
re, err := regexcache.Compile(`(?i)(?:` + filter + `)`)
if err != nil {
return false
}
match := re.MatchString(tag)
if match {
return true
}
}
return false
}
// checkFilterIntStrings "1,2,3-20"
func containsIntStrings(value int, filterList string) bool {
filters := strings.Split(filterList, ",")
for _, filter := range filters {
filter = strings.Replace(filter, "%", "", -1)
filter = strings.TrimSpace(filter)
if strings.Contains(filter, "-") {
minMax := strings.Split(filter, "-")
if len(minMax) == 2 {
// to int
minValue, err := strconv.ParseInt(minMax[0], 10, 32)
if err != nil {
return false
}
maxValue, err := strconv.ParseInt(minMax[1], 10, 32)
if err != nil {
return false
}
if minValue > maxValue {
// handle error
return false
} else {
// if announcePercent is greater than minValue and less than maxValue return true
if value >= int(minValue) && value <= int(maxValue) {
return true
}
}
}
}
filterInt, err := strconv.ParseInt(filter, 10, 32)
if err != nil {
return false
}
if int(filterInt) == value {
return true
}
}
return false
}
func contains(tag string, filter string) bool {
return containsMatch([]string{tag}, strings.Split(filter, ","))
}
func containsFuzzy(tag string, filter string) bool {
return containsMatchFuzzy([]string{tag}, strings.Split(filter, ","))
}
func containsSlice(tag string, filters []string) bool {
return containsMatch([]string{tag}, filters)
}
func containsAny(tags []string, filter string) bool {
return containsMatch(tags, strings.Split(filter, ","))
}
func containsAll(tags []string, filter string) bool {
return containsAllMatch(tags, strings.Split(filter, ","))
}
func containsAnyOther(filter string, tags ...string) bool {
return containsMatch(tags, strings.Split(filter, ","))
}
func sliceContainsSlice(tags []string, filters []string) bool {
return containsMatchBasic(tags, filters)
}
func containsMatchFuzzy(tags []string, filters []string) bool {
advanced := make([]string, 0, len(filters))
for _, tag := range tags {
if tag == "" {
continue
}
tag = strings.ToLower(tag)
clear(advanced)
for _, filter := range filters {
if filter == "" {
continue
}
filter = strings.TrimSpace(filter)
filter = strings.ToLower(filter)
// check if line contains * or ?, if so try wildcard match, otherwise try substring match
a := strings.ContainsAny(filter, "?|*")
if a {
advanced = append(advanced, filter)
} else if strings.Contains(tag, filter) {
return true
}
}
if wildcard.MatchSlice(advanced, tag) {
return true
}
}
return false
}
func containsMatch(tags []string, filters []string) bool {
advanced := make([]string, 0, len(filters))
for _, tag := range tags {
if tag == "" {
continue
}
tag = strings.ToLower(tag)
clear(advanced)
for _, filter := range filters {
if filter == "" {
continue
}
filter = strings.TrimSpace(filter)
filter = strings.ToLower(filter)
// check if line contains * or ?, if so try wildcard match, otherwise try substring match
a := strings.ContainsAny(filter, "?|*")
if a {
advanced = append(advanced, filter)
} else if tag == filter {
return true
}
}
if wildcard.MatchSlice(advanced, tag) {
return true
}
}
return false
}
func containsAllMatch(tags []string, filters []string) bool {
for _, filter := range filters {
if filter == "" {
continue
}
filter = strings.TrimSpace(filter)
filter = strings.ToLower(filter)
found := false
wildFilter := strings.ContainsAny(filter, "?|*")
for _, tag := range tags {
if tag == "" {
continue
}
tag = strings.ToLower(tag)
if tag == filter {
found = true
break
} else if wildFilter {
if wildcard.Match(filter, tag) {
found = true
break
}
}
}
if !found {
return false
}
}
return true
}
func containsMatchBasic(tags []string, filters []string) bool {
for _, tag := range tags {
if tag == "" {
continue
}
tag = strings.ToLower(tag)
for _, filter := range filters {
if filter == "" {
continue
}
filter = strings.TrimSpace(filter)
filter = strings.ToLower(filter)
if tag == filter {
return true
}
}
}
return false
}
func containsAnySlice(tags []string, filters []string) bool {
advanced := make([]string, 0, len(filters))
for _, tag := range tags {
if tag == "" {
continue
}
tag = strings.ToLower(tag)
clear(advanced)
for _, filter := range filters {
if filter == "" {
continue
}
filter = strings.TrimSpace(filter)
filter = strings.ToLower(filter)
// check if line contains * or ?, if so try wildcard match, otherwise try substring match
a := strings.ContainsAny(filter, "?|*")
if a {
advanced = append(advanced, filter)
} else if tag == filter {
return true
}
}
if wildcard.MatchSlice(advanced, tag) {
return true
}
}
return false
}
func basicContainsSlice(tag string, filters []string) bool {
return basicContainsMatch([]string{tag}, filters)
}
func basicContainsMatch(tags []string, filters []string) bool {
for _, tag := range tags {
if tag == "" {
continue
}
for _, filter := range filters {
if filter == "" {
continue
}
if tag == filter {
return true
}
}
}
return false
}
func checkFreeleechPercent(announcePercent int, filterPercent string) bool {
filters := strings.Split(filterPercent, ",")
for _, filter := range filters {
filter = strings.Replace(filter, "%", "", -1)
filter = strings.TrimSpace(filter)
if strings.Contains(filter, "-") {
minMax := strings.Split(filter, "-")
if len(minMax) == 2 {
// to int
minValue, err := strconv.ParseInt(minMax[0], 10, 32)
if err != nil {
return false
}
maxValue, err := strconv.ParseInt(minMax[1], 10, 32)
if err != nil {
return false
}
if minValue > maxValue {
// handle error
return false
} else {
// if announcePercent is greater than minValue and less than maxValue return true
if announcePercent >= int(minValue) && announcePercent <= int(maxValue) {
return true
}
}
}
}
filterPercentInt, err := strconv.ParseInt(filter, 10, 32)
if err != nil {
return false
}
if int(filterPercentInt) == announcePercent {
return true
}
}
return false
}
func matchHDR(releaseValues []string, filterValues []string) bool {
for _, filter := range filterValues {
if filter == "" {
continue
}
filter = strings.TrimSpace(filter)
filter = strings.ToLower(filter)
parts := strings.Split(filter, " ")
if len(parts) == 2 {
partsMatched := 0
for _, part := range parts {
for _, tag := range releaseValues {
if tag == "" {
continue
}
tag = strings.ToLower(tag)
if tag == part {
partsMatched++
}
if len(parts) == partsMatched {
return true
}
}
}
} else {
for _, tag := range releaseValues {
if tag == "" {
continue
}
tag = strings.ToLower(tag)
if tag == filter {
return true
}
}
}
}
return false
}
func (f *Filter) CheckReleaseSize(releaseSize uint64) (bool, error) {
minBytes, maxBytes, err := f.parsedSizeLimits()
if err != nil {
return false, err
}
if minBytes != nil && releaseSize <= *minBytes {
f.RejectReasons.Addf("release size", "release size %d bytes is smaller than filter min size %d bytes", releaseSize, *minBytes)
return false, nil
}
if maxBytes != nil && releaseSize >= *maxBytes {
f.RejectReasons.Addf("release size", "release size %d bytes is larger than filter max size %d bytes", releaseSize, *maxBytes)
return false, nil
}
return true, nil
}
func (f *Filter) CheckUploader(uploader string) (bool, error) {
if f.MatchUploaders != "" && !contains(uploader, f.MatchUploaders) {
f.RejectReasons.Add("match uploader", uploader, f.MatchUploaders)
return false, nil
}
if f.ExceptUploaders != "" && contains(uploader, f.ExceptUploaders) {
f.RejectReasons.Add("except uploader", uploader, f.ExceptUploaders)
return false, nil
}
return true, nil
}
func (f *Filter) CheckRecordLabel(recordLabel string) (bool, error) {
if f.MatchRecordLabels != "" && !contains(recordLabel, f.MatchRecordLabels) {
f.RejectReasons.Add("match record label", recordLabel, f.MatchRecordLabels)
return false, nil
}
if f.ExceptRecordLabels != "" && contains(recordLabel, f.ExceptRecordLabels) {
f.RejectReasons.Add("except record label", recordLabel, f.ExceptRecordLabels)
return false, nil
}
return true, nil
}
// parsedSizeLimits parses filter bytes limits (expressed as a string) into a
// uint64 number of bytes. The bounds are returned as *uint64 number of bytes,
// with "nil" representing "no limit". We break out filter size limit parsing
// into a discrete step so that we can more easily check parsability at filter
// creation time.
func (f *Filter) parsedSizeLimits() (*uint64, *uint64, error) {
minBytes, err := parseBytes(f.MinSize)
if err != nil {
return nil, nil, errors.Wrap(err, "could not parse filter min size")
}
maxBytes, err := parseBytes(f.MaxSize)
if err != nil {
return nil, nil, errors.Wrap(err, "could not parse filter max size")
}
return minBytes, maxBytes, nil
}
// parseBytes parses a string representation of a file size into a number of
// bytes. It returns a *uint64 where "nil" represents "none" (corresponding to
// the empty string)
func parseBytes(s string) (*uint64, error) {
if s == "" {
return nil, nil
}
b, err := humanize.ParseBytes(s)
return &b, err
}