feat(filters): smart episode (#563)

* feat(filters): initial smart episode

* feat(smart-episode): pseudo-logic

* feat(filters): check releases

* feat(filters): update logic

* feat(web): smart episode (#562)

* add frontend part for smart episode feature

* change description for smart episode help text

* fix wording

* feat(filters): smart-episode logic

Co-authored-by: Kyle Sanderson <kyle.leet@gmail.com>
Co-authored-by: xoaaC <35452459+xoaaC@users.noreply.github.com>
This commit is contained in:
ze0s 2022-12-14 19:07:04 +01:00 committed by GitHub
parent 45e03c10b6
commit 38795be9ea
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 132 additions and 17 deletions

View file

@ -101,7 +101,7 @@ func main() {
downloadClientService = download_client.NewService(log, downloadClientRepo) downloadClientService = download_client.NewService(log, downloadClientRepo)
actionService = action.NewService(log, actionRepo, downloadClientService, bus) actionService = action.NewService(log, actionRepo, downloadClientService, bus)
indexerService = indexer.NewService(log, cfg.Config, indexerRepo, indexerAPIService, schedulingService) indexerService = indexer.NewService(log, cfg.Config, indexerRepo, indexerAPIService, schedulingService)
filterService = filter.NewService(log, filterRepo, actionRepo, indexerAPIService, indexerService) filterService = filter.NewService(log, filterRepo, actionRepo, releaseRepo, indexerAPIService, indexerService)
releaseService = release.NewService(log, releaseRepo, actionService, filterService) releaseService = release.NewService(log, releaseRepo, actionService, filterService)
ircService = irc.NewService(log, ircRepo, releaseService, indexerService, notificationService) ircService = irc.NewService(log, ircRepo, releaseService, indexerService, notificationService)
feedService = feed.NewService(log, feedRepo, feedCacheRepo, releaseService, schedulingService) feedService = feed.NewService(log, feedRepo, feedCacheRepo, releaseService, schedulingService)

View file

@ -190,6 +190,7 @@ func (r *FilterRepo) FindByID(ctx context.Context, filterID int) (*domain.Filter
"scene", "scene",
"freeleech", "freeleech",
"freeleech_percent", "freeleech_percent",
"smart_episode",
"shows", "shows",
"seasons", "seasons",
"episodes", "episodes",
@ -249,7 +250,7 @@ func (r *FilterRepo) FindByID(ctx context.Context, filterID int) (*domain.Filter
var useRegex, scene, freeleech, hasLog, hasCue, perfectFlac, extScriptEnabled, extWebhookEnabled sql.NullBool var useRegex, scene, freeleech, hasLog, hasCue, perfectFlac, extScriptEnabled, extWebhookEnabled sql.NullBool
var delay, maxDownloads, logScore, extWebhookStatus, extScriptStatus sql.NullInt32 var delay, maxDownloads, logScore, extWebhookStatus, extScriptStatus sql.NullInt32
if err := row.Scan(&f.ID, &f.Enabled, &f.Name, &minSize, &maxSize, &delay, &f.Priority, &maxDownloads, &maxDownloadsUnit, &matchReleases, &exceptReleases, &useRegex, &matchReleaseGroups, &exceptReleaseGroups, &matchReleaseTags, &exceptReleaseTags, &f.UseRegexReleaseTags, &scene, &freeleech, &freeleechPercent, &shows, &seasons, &episodes, pq.Array(&f.Resolutions), pq.Array(&f.Codecs), pq.Array(&f.Sources), pq.Array(&f.Containers), pq.Array(&f.MatchHDR), pq.Array(&f.ExceptHDR), pq.Array(&f.MatchOther), pq.Array(&f.ExceptOther), &years, &artists, &albums, pq.Array(&f.MatchReleaseTypes), pq.Array(&f.Formats), pq.Array(&f.Quality), pq.Array(&f.Media), &logScore, &hasLog, &hasCue, &perfectFlac, &matchCategories, &exceptCategories, &matchUploaders, &exceptUploaders, &tags, &exceptTags, pq.Array(&f.Origins), pq.Array(&f.ExceptOrigins), &extScriptEnabled, &extScriptCmd, &extScriptArgs, &extScriptStatus, &extWebhookEnabled, &extWebhookHost, &extWebhookData, &extWebhookStatus, &f.CreatedAt, &f.UpdatedAt); err != nil { if err := row.Scan(&f.ID, &f.Enabled, &f.Name, &minSize, &maxSize, &delay, &f.Priority, &maxDownloads, &maxDownloadsUnit, &matchReleases, &exceptReleases, &useRegex, &matchReleaseGroups, &exceptReleaseGroups, &matchReleaseTags, &exceptReleaseTags, &f.UseRegexReleaseTags, &scene, &freeleech, &freeleechPercent, &f.SmartEpisode, &shows, &seasons, &episodes, pq.Array(&f.Resolutions), pq.Array(&f.Codecs), pq.Array(&f.Sources), pq.Array(&f.Containers), pq.Array(&f.MatchHDR), pq.Array(&f.ExceptHDR), pq.Array(&f.MatchOther), pq.Array(&f.ExceptOther), &years, &artists, &albums, pq.Array(&f.MatchReleaseTypes), pq.Array(&f.Formats), pq.Array(&f.Quality), pq.Array(&f.Media), &logScore, &hasLog, &hasCue, &perfectFlac, &matchCategories, &exceptCategories, &matchUploaders, &exceptUploaders, &tags, &exceptTags, pq.Array(&f.Origins), pq.Array(&f.ExceptOrigins), &extScriptEnabled, &extScriptCmd, &extScriptArgs, &extScriptStatus, &extWebhookEnabled, &extWebhookHost, &extWebhookData, &extWebhookStatus, &f.CreatedAt, &f.UpdatedAt); err != nil {
return nil, errors.Wrap(err, "error scanning row") return nil, errors.Wrap(err, "error scanning row")
} }
@ -346,6 +347,7 @@ func (r *FilterRepo) findByIndexerIdentifier(ctx context.Context, tx *Tx, indexe
"f.scene", "f.scene",
"f.freeleech", "f.freeleech",
"f.freeleech_percent", "f.freeleech_percent",
"f.smart_episode",
"f.shows", "f.shows",
"f.seasons", "f.seasons",
"f.episodes", "f.episodes",
@ -415,7 +417,7 @@ func (r *FilterRepo) findByIndexerIdentifier(ctx context.Context, tx *Tx, indexe
var useRegex, scene, freeleech, hasLog, hasCue, perfectFlac, extScriptEnabled, extWebhookEnabled sql.NullBool var useRegex, scene, freeleech, hasLog, hasCue, perfectFlac, extScriptEnabled, extWebhookEnabled sql.NullBool
var delay, maxDownloads, logScore, extWebhookStatus, extScriptStatus sql.NullInt32 var delay, maxDownloads, logScore, extWebhookStatus, extScriptStatus sql.NullInt32
if err := rows.Scan(&f.ID, &f.Enabled, &f.Name, &minSize, &maxSize, &delay, &f.Priority, &maxDownloads, &maxDownloadsUnit, &matchReleases, &exceptReleases, &useRegex, &matchReleaseGroups, &exceptReleaseGroups, &matchReleaseTags, &exceptReleaseTags, &f.UseRegexReleaseTags, &scene, &freeleech, &freeleechPercent, &shows, &seasons, &episodes, pq.Array(&f.Resolutions), pq.Array(&f.Codecs), pq.Array(&f.Sources), pq.Array(&f.Containers), pq.Array(&f.MatchHDR), pq.Array(&f.ExceptHDR), pq.Array(&f.MatchOther), pq.Array(&f.ExceptOther), &years, &artists, &albums, pq.Array(&f.MatchReleaseTypes), pq.Array(&f.Formats), pq.Array(&f.Quality), pq.Array(&f.Media), &logScore, &hasLog, &hasCue, &perfectFlac, &matchCategories, &exceptCategories, &matchUploaders, &exceptUploaders, &tags, &exceptTags, pq.Array(&f.Origins), pq.Array(&f.ExceptOrigins), &extScriptEnabled, &extScriptCmd, &extScriptArgs, &extScriptStatus, &extWebhookEnabled, &extWebhookHost, &extWebhookData, &extWebhookStatus, &f.CreatedAt, &f.UpdatedAt); err != nil { if err := rows.Scan(&f.ID, &f.Enabled, &f.Name, &minSize, &maxSize, &delay, &f.Priority, &maxDownloads, &maxDownloadsUnit, &matchReleases, &exceptReleases, &useRegex, &matchReleaseGroups, &exceptReleaseGroups, &matchReleaseTags, &exceptReleaseTags, &f.UseRegexReleaseTags, &scene, &freeleech, &freeleechPercent, &f.SmartEpisode, &shows, &seasons, &episodes, pq.Array(&f.Resolutions), pq.Array(&f.Codecs), pq.Array(&f.Sources), pq.Array(&f.Containers), pq.Array(&f.MatchHDR), pq.Array(&f.ExceptHDR), pq.Array(&f.MatchOther), pq.Array(&f.ExceptOther), &years, &artists, &albums, pq.Array(&f.MatchReleaseTypes), pq.Array(&f.Formats), pq.Array(&f.Quality), pq.Array(&f.Media), &logScore, &hasLog, &hasCue, &perfectFlac, &matchCategories, &exceptCategories, &matchUploaders, &exceptUploaders, &tags, &exceptTags, pq.Array(&f.Origins), pq.Array(&f.ExceptOrigins), &extScriptEnabled, &extScriptCmd, &extScriptArgs, &extScriptStatus, &extWebhookEnabled, &extWebhookHost, &extWebhookData, &extWebhookStatus, &f.CreatedAt, &f.UpdatedAt); err != nil {
return nil, errors.Wrap(err, "error scanning row") return nil, errors.Wrap(err, "error scanning row")
} }
@ -490,6 +492,7 @@ func (r *FilterRepo) Store(ctx context.Context, filter domain.Filter) (*domain.F
"scene", "scene",
"freeleech", "freeleech",
"freeleech_percent", "freeleech_percent",
"smart_episode",
"shows", "shows",
"seasons", "seasons",
"episodes", "episodes",
@ -549,6 +552,7 @@ func (r *FilterRepo) Store(ctx context.Context, filter domain.Filter) (*domain.F
filter.Scene, filter.Scene,
filter.Freeleech, filter.Freeleech,
filter.FreeleechPercent, filter.FreeleechPercent,
filter.SmartEpisode,
filter.Shows, filter.Shows,
filter.Seasons, filter.Seasons,
filter.Episodes, filter.Episodes,
@ -627,6 +631,7 @@ func (r *FilterRepo) Update(ctx context.Context, filter domain.Filter) (*domain.
Set("scene", filter.Scene). Set("scene", filter.Scene).
Set("freeleech", filter.Freeleech). Set("freeleech", filter.Freeleech).
Set("freeleech_percent", filter.FreeleechPercent). Set("freeleech_percent", filter.FreeleechPercent).
Set("smart_episode", filter.SmartEpisode).
Set("shows", filter.Shows). Set("shows", filter.Shows).
Set("seasons", filter.Seasons). Set("seasons", filter.Seasons).
Set("episodes", filter.Episodes). Set("episodes", filter.Episodes).
@ -743,6 +748,9 @@ func (r *FilterRepo) UpdatePartial(ctx context.Context, filter domain.FilterUpda
if filter.FreeleechPercent != nil { if filter.FreeleechPercent != nil {
q = q.Set("freeleech_percent", filter.FreeleechPercent) q = q.Set("freeleech_percent", filter.FreeleechPercent)
} }
if filter.SmartEpisode != nil {
q = q.Set("smart_episode", filter.SmartEpisode)
}
if filter.Shows != nil { if filter.Shows != nil {
q = q.Set("shows", filter.Shows) q = q.Set("shows", filter.Shows)
} }

View file

@ -83,6 +83,7 @@ CREATE TABLE filter
scene BOOLEAN, scene BOOLEAN,
freeleech BOOLEAN, freeleech BOOLEAN,
freeleech_percent TEXT, freeleech_percent TEXT,
smart_episode BOOLEAN DEFAULT FALSE,
shows TEXT, shows TEXT,
seasons TEXT, seasons TEXT,
episodes TEXT, episodes TEXT,
@ -619,4 +620,7 @@ CREATE INDEX indexer_identifier_index
`ALTER TABLE indexer `ALTER TABLE indexer
ADD COLUMN base_url TEXT; ADD COLUMN base_url TEXT;
`, `,
`ALTER TABLE "filter"
ADD COLUMN smart_episode BOOLEAN DEFAULT false;
`,
} }

View file

@ -447,3 +447,70 @@ func (repo *ReleaseRepo) Delete(ctx context.Context) error {
return nil return nil
} }
func (repo *ReleaseRepo) CanDownloadShow(ctx context.Context, title string, season int, episode int) (bool, error) {
// TODO support non season episode shows
// if rls.Day > 0 {
// // Maybe in the future
// // SELECT '' FROM release WHERE Title LIKE %q AND ((Year == %d AND Month == %d AND Day > %d) OR (Year == %d AND Month > %d) OR (Year > %d))"
// qs := sql.Query("SELECT torrent_name FROM release WHERE Title LIKE %q AND Year >= %d", rls.Title, rls.Year)
//
// for q := range qs.Rows() {
// r := rls.ParseTitle(q)
// if r.Year > rls.Year {
// return false, fmt.Errorf("stale release year")
// }
//
// if r.Month > rls.Month {
// return false, fmt.Errorf("stale release month")
// }
//
// if r.Month == rls.Month && r.Day > rls.Day {
// return false, fmt.Errorf("stale release day")
// }
// }
//}
queryBuilder := repo.db.squirrel.
Select("COUNT(*)").
From("release").
Where("title LIKE ?", fmt.Sprint("%", title, "%"))
if season > 0 && episode > 0 {
queryBuilder = queryBuilder.Where(sq.Or{
sq.And{
sq.Eq{"season": season},
sq.Gt{"episode": episode},
},
sq.Gt{"season": season},
})
} else if season > 0 && episode == 0 {
queryBuilder = queryBuilder.Where(sq.Gt{"season": season})
} else {
/* No support for this scenario today. Specifically multi-part specials.
* The Database presently does not have Subtitle as a field, but is coming at a future date. */
return true, nil
}
query, args, err := queryBuilder.ToSql()
if err != nil {
return false, errors.Wrap(err, "error building query")
}
row := repo.db.handler.QueryRowContext(ctx, query, args...)
if err := row.Err(); err != nil {
return false, err
}
var count int
if err := row.Scan(&count); err != nil {
return false, err
}
if count > 0 {
return false, nil
}
return true, nil
}

View file

@ -83,6 +83,7 @@ CREATE TABLE filter
scene BOOLEAN, scene BOOLEAN,
freeleech BOOLEAN, freeleech BOOLEAN,
freeleech_percent TEXT, freeleech_percent TEXT,
smart_episode BOOLEAN DEFAULT FALSE,
shows TEXT, shows TEXT,
seasons TEXT, seasons TEXT,
episodes TEXT, episodes TEXT,
@ -963,4 +964,7 @@ ALTER TABLE irc_network_dg_tmp
`ALTER TABLE indexer `ALTER TABLE indexer
ADD COLUMN base_url TEXT; ADD COLUMN base_url TEXT;
`, `,
`ALTER TABLE "filter"
ADD COLUMN smart_episode BOOLEAN DEFAULT false;
`,
} }

View file

@ -81,6 +81,7 @@ type Filter struct {
Bonus []string `json:"bonus,omitempty"` Bonus []string `json:"bonus,omitempty"`
Freeleech bool `json:"freeleech,omitempty"` Freeleech bool `json:"freeleech,omitempty"`
FreeleechPercent string `json:"freeleech_percent,omitempty"` FreeleechPercent string `json:"freeleech_percent,omitempty"`
SmartEpisode bool `json:"smart_episode"`
Shows string `json:"shows,omitempty"` Shows string `json:"shows,omitempty"`
Seasons string `json:"seasons,omitempty"` Seasons string `json:"seasons,omitempty"`
Episodes string `json:"episodes,omitempty"` Episodes string `json:"episodes,omitempty"`
@ -153,6 +154,7 @@ type FilterUpdate struct {
Bonus *[]string `json:"bonus,omitempty"` Bonus *[]string `json:"bonus,omitempty"`
Freeleech *bool `json:"freeleech,omitempty"` Freeleech *bool `json:"freeleech,omitempty"`
FreeleechPercent *string `json:"freeleech_percent,omitempty"` FreeleechPercent *string `json:"freeleech_percent,omitempty"`
SmartEpisode *bool `json:"smart_episode,omitempty"`
Shows *string `json:"shows,omitempty"` Shows *string `json:"shows,omitempty"`
Seasons *string `json:"seasons,omitempty"` Seasons *string `json:"seasons,omitempty"`
Episodes *string `json:"episodes,omitempty"` Episodes *string `json:"episodes,omitempty"`

View file

@ -32,6 +32,7 @@ type ReleaseRepo interface {
Stats(ctx context.Context) (*ReleaseStats, error) Stats(ctx context.Context) (*ReleaseStats, error)
StoreReleaseActionStatus(ctx context.Context, actionStatus *ReleaseActionStatus) error StoreReleaseActionStatus(ctx context.Context, actionStatus *ReleaseActionStatus) error
Delete(ctx context.Context) error Delete(ctx context.Context) error
CanDownloadShow(ctx context.Context, title string, season int, episode int) (bool, error)
} }
type Release struct { type Release struct {

View file

@ -25,7 +25,7 @@ type Service interface {
FindByID(ctx context.Context, filterID int) (*domain.Filter, error) FindByID(ctx context.Context, filterID int) (*domain.Filter, error)
FindByIndexerIdentifier(indexer string) ([]domain.Filter, error) FindByIndexerIdentifier(indexer string) ([]domain.Filter, error)
Find(ctx context.Context, params domain.FilterQueryParams) ([]domain.Filter, error) Find(ctx context.Context, params domain.FilterQueryParams) ([]domain.Filter, error)
CheckFilter(f domain.Filter, release *domain.Release) (bool, error) CheckFilter(ctx context.Context, f domain.Filter, release *domain.Release) (bool, error)
ListFilters(ctx context.Context) ([]domain.Filter, error) ListFilters(ctx context.Context) ([]domain.Filter, error)
Store(ctx context.Context, filter domain.Filter) (*domain.Filter, error) Store(ctx context.Context, filter domain.Filter) (*domain.Filter, error)
Update(ctx context.Context, filter domain.Filter) (*domain.Filter, error) Update(ctx context.Context, filter domain.Filter) (*domain.Filter, error)
@ -33,23 +33,27 @@ type Service interface {
Duplicate(ctx context.Context, filterID int) (*domain.Filter, error) Duplicate(ctx context.Context, filterID int) (*domain.Filter, error)
ToggleEnabled(ctx context.Context, filterID int, enabled bool) error ToggleEnabled(ctx context.Context, filterID int, enabled bool) error
Delete(ctx context.Context, filterID int) error Delete(ctx context.Context, filterID int) error
AdditionalSizeCheck(f domain.Filter, release *domain.Release) (bool, error)
CanDownloadShow(ctx context.Context, release *domain.Release) (bool, error)
} }
type service struct { type service struct {
log zerolog.Logger log zerolog.Logger
repo domain.FilterRepo repo domain.FilterRepo
actionRepo domain.ActionRepo actionRepo domain.ActionRepo
indexerSvc indexer.Service releaseRepo domain.ReleaseRepo
apiService indexer.APIService indexerSvc indexer.Service
apiService indexer.APIService
} }
func NewService(log logger.Logger, repo domain.FilterRepo, actionRepo domain.ActionRepo, apiService indexer.APIService, indexerSvc indexer.Service) Service { func NewService(log logger.Logger, repo domain.FilterRepo, actionRepo domain.ActionRepo, releaseRepo domain.ReleaseRepo, apiService indexer.APIService, indexerSvc indexer.Service) Service {
return &service{ return &service{
log: log.With().Str("module", "filter").Logger(), log: log.With().Str("module", "filter").Logger(),
repo: repo, repo: repo,
actionRepo: actionRepo, actionRepo: actionRepo,
apiService: apiService, releaseRepo: releaseRepo,
indexerSvc: indexerSvc, apiService: apiService,
indexerSvc: indexerSvc,
} }
} }
@ -293,7 +297,7 @@ func (s *service) Delete(ctx context.Context, filterID int) error {
return nil return nil
} }
func (s *service) CheckFilter(f domain.Filter, release *domain.Release) (bool, error) { func (s *service) CheckFilter(ctx context.Context, f domain.Filter, release *domain.Release) (bool, error) {
s.log.Trace().Msgf("filter.Service.CheckFilter: checking filter: %v %+v", f.Name, f) s.log.Trace().Msgf("filter.Service.CheckFilter: checking filter: %v %+v", f.Name, f)
s.log.Trace().Msgf("filter.Service.CheckFilter: checking filter: %v for release: %+v", f.Name, release) s.log.Trace().Msgf("filter.Service.CheckFilter: checking filter: %v for release: %+v", f.Name, release)
@ -305,6 +309,21 @@ func (s *service) CheckFilter(f domain.Filter, release *domain.Release) (bool, e
} }
if matchedFilter { if matchedFilter {
// smartEpisode check
if f.SmartEpisode {
canDownloadShow, err := s.CanDownloadShow(ctx, release)
if err != nil {
s.log.Trace().Msgf("filter.Service.CheckFilter: failed smart episode check: %s", f.Name)
return false, nil
}
if !canDownloadShow {
s.log.Trace().Msgf("filter.Service.CheckFilter: failed smart episode check: %s", f.Name)
release.AddRejectionF("smart episode check: not new: (%s) season: %d ep: %d", release.Title, release.Season, release.Episode)
return false, nil
}
}
// if matched, do additional size check if needed, attach actions and return the filter // if matched, do additional size check if needed, attach actions and return the filter
s.log.Debug().Msgf("filter.Service.CheckFilter: found and matched filter: %+v", f.Name) s.log.Debug().Msgf("filter.Service.CheckFilter: found and matched filter: %+v", f.Name)
@ -462,6 +481,10 @@ func checkSizeFilter(minSize string, maxSize string, releaseSize uint64) (bool,
return true, nil return true, nil
} }
func (s *service) CanDownloadShow(ctx context.Context, release *domain.Release) (bool, error) {
return s.releaseRepo.CanDownloadShow(ctx, release.Title, release.Season, release.Episode)
}
func (s *service) execCmd(release *domain.Release, cmd string, args string) (int, error) { func (s *service) execCmd(release *domain.Release, cmd string, args string) (int, error) {
s.log.Debug().Msgf("filter exec release: %v", release.TorrentName) s.log.Debug().Msgf("filter exec release: %v", release.TorrentName)

View file

@ -117,7 +117,7 @@ func (s *service) Process(release *domain.Release) {
release.FilterID = f.ID release.FilterID = f.ID
// test filter // test filter
match, err := s.filterSvc.CheckFilter(f, release) match, err := s.filterSvc.CheckFilter(ctx, f, release)
if err != nil { if err != nil {
l.Error().Err(err).Msg("release.Process: error checking filter") l.Error().Err(err).Msg("release.Process: error checking filter")
return return

View file

@ -251,6 +251,7 @@ export default function FilterDetails() {
except_other: filter.except_other || [], except_other: filter.except_other || [],
seasons: filter.seasons, seasons: filter.seasons,
episodes: filter.episodes, episodes: filter.episodes,
smart_episode: filter.smart_episode,
match_releases: filter.match_releases, match_releases: filter.match_releases,
except_releases: filter.except_releases, except_releases: filter.except_releases,
match_release_groups: filter.match_release_groups, match_release_groups: filter.match_release_groups,
@ -375,6 +376,10 @@ export function MoviesTv() {
<TextField name="seasons" label="Seasons" columns={8} placeholder="eg. 1,3,2-6" /> <TextField name="seasons" label="Seasons" columns={8} placeholder="eg. 1,3,2-6" />
<TextField name="episodes" label="Episodes" columns={4} placeholder="eg. 2,4,10-20" /> <TextField name="episodes" label="Episodes" columns={4} placeholder="eg. 2,4,10-20" />
</div> </div>
<div className="mt-6">
<CheckboxField name="smart_episode" label="Smart Episode" sublabel="Do not match episodes older than the last one matched."/> {/*Do not match older or already existing episodes.*/}
</div>
</div> </div>
<div className="mt-6 lg:pb-8"> <div className="mt-6 lg:pb-8">

View file

@ -26,6 +26,7 @@ interface Filter {
shows: string; shows: string;
seasons: string; seasons: string;
episodes: string; episodes: string;
smart_episode: boolean;
resolutions: string[]; resolutions: string[];
codecs: string[]; codecs: string[];
sources: string[]; sources: string[];