feat(lists): integrate Omegabrr (#1885)

* feat(lists): integrate Omegabrr

* feat(lists): add missing lists index

* feat(lists): add db repo

* feat(lists): add db migrations

* feat(lists): labels

* feat(lists): url lists and more arrs

* fix(lists): db migrations client_id wrong type

* fix(lists): db fields

* feat(lists): create list form wip

* feat(lists): show in list and create

* feat(lists): update and delete

* feat(lists): trigger via webhook

* feat(lists): add webhook handler

* fix(arr): encode json to pointer

* feat(lists): rename endpoint to lists

* feat(lists): fetch tags from arr

* feat(lists): process plaintext lists

* feat(lists): add background refresh job

* run every 6th hour with a random start delay between 1-35 seconds

* feat(lists): refresh on save and improve logging

* feat(lists): cast arr client to pointer

* feat(lists): improve error handling

* feat(lists): reset shows field with match release

* feat(lists): filter opts all lists

* feat(lists): trigger on update if enabled

* feat(lists): update option for lists

* feat(lists): show connected filters in list

* feat(lists): missing listSvc dep

* feat(lists): cleanup

* feat(lists): typo arr list

* feat(lists): radarr include original

* feat(lists): rename ExcludeAlternateTitle to IncludeAlternateTitle

* fix(lists): arr client type conversion to pointer

* fix(actions): only log panic recover if err not nil

* feat(lists): show spinner on save

* feat(lists): show icon in filters list

* feat(lists): change icon color in filters list

* feat(lists): delete relations on filter delete
This commit is contained in:
ze0s 2024-12-25 13:23:37 +01:00 committed by GitHub
parent b68ae334ca
commit 221bc35371
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
77 changed files with 5025 additions and 254 deletions

View file

@ -0,0 +1,172 @@
package list
import (
"context"
"strings"
"github.com/autobrr/autobrr/internal/domain"
"github.com/autobrr/autobrr/pkg/arr/lidarr"
"github.com/autobrr/autobrr/pkg/errors"
"github.com/rs/zerolog"
)
func (s *service) lidarr(ctx context.Context, list *domain.List) error {
l := s.log.With().Str("list", list.Name).Str("type", "lidarr").Int("client", list.ClientID).Logger()
l.Debug().Msgf("gathering titles...")
titles, artists, err := s.processLidarr(ctx, list, &l)
if err != nil {
return err
}
l.Debug().Msgf("got %d filter titles", len(titles))
// Process titles
var processedTitles []string
for _, title := range titles {
processedTitles = append(processedTitles, processTitle(title, list.MatchRelease)...)
}
if len(processedTitles) == 0 {
l.Debug().Msgf("no titles found to update for list: %v", list.Name)
return nil
}
// Update filter based on MatchRelease
var f domain.FilterUpdate
if list.MatchRelease {
joinedTitles := strings.Join(processedTitles, ",")
if len(joinedTitles) == 0 {
return nil
}
l.Trace().Str("titles", joinedTitles).Msgf("found %d titles", len(joinedTitles))
f.MatchReleases = &joinedTitles
} else {
// Process artists only if MatchRelease is false
var processedArtists []string
for _, artist := range artists {
processedArtists = append(processedArtists, processTitle(artist, list.MatchRelease)...)
}
joinedTitles := strings.Join(processedTitles, ",")
l.Trace().Str("albums", joinedTitles).Msgf("found %d titles", len(joinedTitles))
joinedArtists := strings.Join(processedArtists, ",")
if len(joinedTitles) == 0 && len(joinedArtists) == 0 {
return nil
}
l.Trace().Str("artists", joinedArtists).Msgf("found %d titles", len(joinedArtists))
f.Albums = &joinedTitles
f.Artists = &joinedArtists
}
//joinedTitles := strings.Join(titles, ",")
//
//l.Trace().Msgf("%v", joinedTitles)
//
//if len(joinedTitles) == 0 {
// return nil
//}
for _, filter := range list.Filters {
l.Debug().Msgf("updating filter: %v", filter.ID)
f.ID = filter.ID
if err := s.filterSvc.UpdatePartial(ctx, f); err != nil {
return errors.Wrap(err, "error updating filter: %v", filter.ID)
}
l.Debug().Msgf("successfully updated filter: %v", filter.ID)
}
return nil
}
func (s *service) processLidarr(ctx context.Context, list *domain.List, logger *zerolog.Logger) ([]string, []string, error) {
downloadClient, err := s.downloadClientSvc.GetClient(ctx, int32(list.ClientID))
if err != nil {
return nil, nil, errors.Wrap(err, "could not get client with id %d", list.ClientID)
}
if !downloadClient.Enabled {
return nil, nil, errors.New("client %s %s not enabled", downloadClient.Type, downloadClient.Name)
}
client := downloadClient.Client.(*lidarr.Client)
//var tags []*arr.Tag
//if len(list.TagsExclude) > 0 || len(list.TagsInclude) > 0 {
// t, err := client.GetTags(ctx)
// if err != nil {
// logger.Debug().Msg("could not get tags")
// }
// tags = t
//}
albums, err := client.GetAlbums(ctx, 0)
if err != nil {
return nil, nil, err
}
logger.Debug().Msgf("found %d albums to process", len(albums))
var titles []string
var artists []string
seenArtists := make(map[string]struct{})
for _, album := range albums {
if !list.ShouldProcessItem(album.Monitored) {
continue
}
//if len(list.TagsInclude) > 0 {
// if len(album.Tags) == 0 {
// continue
// }
// if !containsTag(tags, album.Tags, list.TagsInclude) {
// continue
// }
//}
//
//if len(list.TagsExclude) > 0 {
// if containsTag(tags, album.Tags, list.TagsExclude) {
// continue
// }
//}
// Fetch the artist details
artist, err := client.GetArtistByID(ctx, album.ArtistID)
if err != nil {
logger.Error().Err(err).Msgf("Error fetching artist details for album: %v", album.Title)
continue // Skip this album if there's an error fetching the artist
}
if artist.Monitored {
processedTitles := processTitle(album.Title, list.MatchRelease)
titles = append(titles, processedTitles...)
// Debug logging
logger.Debug().Msgf("Processing artist: %s", artist.ArtistName)
if _, exists := seenArtists[artist.ArtistName]; !exists {
artists = append(artists, artist.ArtistName)
seenArtists[artist.ArtistName] = struct{}{}
logger.Debug().Msgf("Added artist: %s", artist.ArtistName) // Log when an artist is added
}
}
}
//sort.Strings(titles)
logger.Debug().Msgf("Processed %d monitored albums with monitored artists, created %d titles, found %d unique artists", len(titles), len(titles), len(artists))
return titles, artists, nil
}

View file

@ -0,0 +1,146 @@
package list
import (
"context"
"sort"
"strings"
"github.com/autobrr/autobrr/internal/domain"
"github.com/autobrr/autobrr/pkg/arr"
"github.com/autobrr/autobrr/pkg/arr/radarr"
"github.com/autobrr/autobrr/pkg/errors"
"github.com/rs/zerolog"
)
func (s *service) radarr(ctx context.Context, list *domain.List) error {
l := s.log.With().Str("list", list.Name).Str("type", "radarr").Int("client", list.ClientID).Logger()
l.Debug().Msgf("gathering titles...")
titles, err := s.processRadarr(ctx, list, &l)
if err != nil {
return err
}
l.Debug().Msgf("got %d filter titles", len(titles))
if len(titles) == 0 {
l.Debug().Msgf("no titles found to update for list: %v", list.Name)
return nil
}
joinedTitles := strings.Join(titles, ",")
l.Trace().Str("titles", joinedTitles).Msgf("found %d titles", len(joinedTitles))
filterUpdate := domain.FilterUpdate{Shows: &joinedTitles}
if list.MatchRelease {
filterUpdate.Shows = &nullString
filterUpdate.MatchReleases = &joinedTitles
}
for _, filter := range list.Filters {
l.Debug().Msgf("updating filter: %v", filter.ID)
filterUpdate.ID = filter.ID
if err := s.filterSvc.UpdatePartial(ctx, filterUpdate); err != nil {
return errors.Wrap(err, "error updating filter: %v", filter.ID)
}
l.Debug().Msgf("successfully updated filter: %v", filter.ID)
}
return nil
}
func (s *service) processRadarr(ctx context.Context, list *domain.List, logger *zerolog.Logger) ([]string, error) {
downloadClient, err := s.downloadClientSvc.GetClient(ctx, int32(list.ClientID))
if err != nil {
return nil, errors.Wrap(err, "could not get client with id %d", list.ClientID)
}
if !downloadClient.Enabled {
return nil, errors.New("client %s %s not enabled", downloadClient.Type, downloadClient.Name)
}
client := downloadClient.Client.(*radarr.Client)
var tags []*arr.Tag
if len(list.TagsExclude) > 0 || len(list.TagsInclude) > 0 {
t, err := client.GetTags(ctx)
if err != nil {
logger.Debug().Msg("could not get tags")
}
tags = t
}
movies, err := client.GetMovies(ctx, 0)
if err != nil {
return nil, err
}
logger.Debug().Msgf("found %d movies to process", len(movies))
titleSet := make(map[string]struct{})
var processedTitles int
for _, movie := range movies {
if !list.ShouldProcessItem(movie.Monitored) {
continue
}
//if !s.shouldProcessItem(movie.Monitored, list) {
// continue
//}
if len(list.TagsInclude) > 0 {
if len(movie.Tags) == 0 {
continue
}
if !containsTag(tags, movie.Tags, list.TagsInclude) {
continue
}
}
if len(list.TagsExclude) > 0 {
if containsTag(tags, movie.Tags, list.TagsExclude) {
continue
}
}
processedTitles++
// Taking the international title and the original title and appending them to the titles array.
for _, title := range []string{movie.Title, movie.OriginalTitle} {
if title != "" {
for _, t := range processTitle(title, list.MatchRelease) {
titleSet[t] = struct{}{}
}
}
}
if list.IncludeAlternateTitles {
for _, title := range movie.AlternateTitles {
altTitles := processTitle(title.Title, list.MatchRelease)
for _, altTitle := range altTitles {
titleSet[altTitle] = struct{}{}
}
}
}
}
uniqueTitles := make([]string, 0, len(titleSet))
for title := range titleSet {
uniqueTitles = append(uniqueTitles, title)
}
sort.Strings(uniqueTitles)
logger.Debug().Msgf("from a total of %d movies we found %d titles and created %d release titles", len(movies), processedTitles, len(uniqueTitles))
return uniqueTitles, nil
}
var nullString = ""

View file

@ -0,0 +1,113 @@
package list
import (
"context"
"sort"
"strings"
"github.com/autobrr/autobrr/internal/domain"
"github.com/autobrr/autobrr/pkg/arr/readarr"
"github.com/autobrr/autobrr/pkg/errors"
"github.com/rs/zerolog"
)
func (s *service) readarr(ctx context.Context, list *domain.List) error {
l := s.log.With().Str("list", list.Name).Str("type", "readarr").Int("client", list.ClientID).Logger()
l.Debug().Msgf("gathering titles...")
titles, err := s.processReadarr(ctx, list, &l)
if err != nil {
return err
}
l.Debug().Msgf("got %d filter titles", len(titles))
if len(titles) == 0 {
l.Debug().Msgf("no titles found to update for list: %v", list.Name)
return nil
}
joinedTitles := strings.Join(titles, ",")
l.Trace().Str("titles", joinedTitles).Msgf("found %d titles", len(joinedTitles))
filterUpdate := domain.FilterUpdate{MatchReleases: &joinedTitles}
for _, filter := range list.Filters {
l.Debug().Msgf("updating filter: %v", filter.ID)
filterUpdate.ID = filter.ID
if err := s.filterSvc.UpdatePartial(ctx, filterUpdate); err != nil {
return errors.Wrap(err, "error updating filter: %v", filter.ID)
}
l.Debug().Msgf("successfully updated filter: %v", filter.ID)
}
return nil
}
func (s *service) processReadarr(ctx context.Context, list *domain.List, logger *zerolog.Logger) ([]string, error) {
downloadClient, err := s.downloadClientSvc.GetClient(ctx, int32(list.ClientID))
if err != nil {
return nil, errors.Wrap(err, "could not get client with id %d", list.ClientID)
}
if !downloadClient.Enabled {
return nil, errors.New("client %s %s not enabled", downloadClient.Type, downloadClient.Name)
}
client := downloadClient.Client.(*readarr.Client)
//var tags []*arr.Tag
//if len(list.TagsExclude) > 0 || len(list.TagsInclude) > 0 {
// t, err := client.GetTags(ctx)
// if err != nil {
// logger.Debug().Msg("could not get tags")
// }
// tags = t
//}
books, err := client.GetBooks(ctx, "")
if err != nil {
return nil, err
}
logger.Debug().Msgf("found %d books to process", len(books))
var titles []string
var processedTitles int
for _, book := range books {
if !list.ShouldProcessItem(book.Monitored) {
continue
}
//if len(list.TagsInclude) > 0 {
// if len(book.Tags) == 0 {
// continue
// }
// if !containsTag(tags, book.Tags, list.TagsInclude) {
// continue
// }
//}
//
//if len(list.TagsExclude) > 0 {
// if containsTag(tags, book.Tags, list.TagsExclude) {
// continue
// }
//}
processedTitles++
titles = append(titles, processTitle(book.Title, list.MatchRelease)...)
}
sort.Strings(titles)
logger.Debug().Msgf("from a total of %d books we found %d titles and created %d release titles", len(books), processedTitles, len(titles))
return titles, nil
}

View file

@ -0,0 +1,147 @@
package list
import (
"context"
"sort"
"strings"
"github.com/autobrr/autobrr/internal/domain"
"github.com/autobrr/autobrr/pkg/arr"
"github.com/autobrr/autobrr/pkg/arr/sonarr"
"github.com/autobrr/autobrr/pkg/errors"
"github.com/rs/zerolog"
)
func (s *service) sonarr(ctx context.Context, list *domain.List) error {
var arrType string
if list.Type == domain.ListTypeWhisparr {
arrType = "whisparr"
} else {
arrType = "sonarr"
}
l := s.log.With().Str("list", list.Name).Str("type", arrType).Int("client", list.ClientID).Logger()
l.Debug().Msgf("gathering titles...")
titles, err := s.processSonarr(ctx, list, &l)
if err != nil {
return err
}
l.Debug().Msgf("got %d filter titles", len(titles))
if len(titles) == 0 {
l.Debug().Msgf("no titles found to update for list: %v", list.Name)
return nil
}
joinedTitles := strings.Join(titles, ",")
l.Trace().Str("titles", joinedTitles).Msgf("found %d titles", len(joinedTitles))
filterUpdate := domain.FilterUpdate{Shows: &joinedTitles}
if list.MatchRelease {
filterUpdate.Shows = &nullString
filterUpdate.MatchReleases = &joinedTitles
}
for _, filter := range list.Filters {
l.Debug().Msgf("updating filter: %v", filter.ID)
filterUpdate.ID = filter.ID
if err := s.filterSvc.UpdatePartial(ctx, filterUpdate); err != nil {
return errors.Wrap(err, "error updating filter: %v", filter.ID)
}
l.Debug().Msgf("successfully updated filter: %v", filter.ID)
}
return nil
}
func (s *service) processSonarr(ctx context.Context, list *domain.List, logger *zerolog.Logger) ([]string, error) {
downloadClient, err := s.downloadClientSvc.GetClient(ctx, int32(list.ClientID))
if err != nil {
return nil, errors.Wrap(err, "could not get client with id %d", list.ClientID)
}
if !downloadClient.Enabled {
return nil, errors.New("client %s %s not enabled", downloadClient.Type, downloadClient.Name)
}
client := downloadClient.Client.(*sonarr.Client)
var tags []*arr.Tag
if len(list.TagsExclude) > 0 || len(list.TagsInclude) > 0 {
t, err := client.GetTags(ctx)
if err != nil {
logger.Debug().Msg("could not get tags")
}
tags = t
}
shows, err := client.GetAllSeries(ctx)
if err != nil {
return nil, err
}
logger.Debug().Msgf("found %d shows to process", len(shows))
titleSet := make(map[string]struct{})
var processedTitles int
for _, show := range shows {
if !list.ShouldProcessItem(show.Monitored) {
continue
}
//if !s.shouldProcessItem(show.Monitored, list) {
// continue
//}
if len(list.TagsInclude) > 0 {
if len(show.Tags) == 0 {
continue
}
if !containsTag(tags, show.Tags, list.TagsInclude) {
continue
}
}
if len(list.TagsExclude) > 0 {
if containsTag(tags, show.Tags, list.TagsExclude) {
continue
}
}
processedTitles++
titles := processTitle(show.Title, list.MatchRelease)
for _, title := range titles {
titleSet[title] = struct{}{}
}
if list.IncludeAlternateTitles {
for _, title := range show.AlternateTitles {
altTitles := processTitle(title.Title, list.MatchRelease)
for _, altTitle := range altTitles {
titleSet[altTitle] = struct{}{}
}
}
}
}
uniqueTitles := make([]string, 0, len(titleSet))
for title := range titleSet {
uniqueTitles = append(uniqueTitles, title)
}
sort.Strings(uniqueTitles)
logger.Debug().Msgf("from a total of %d shows we found %d titles and created %d release titles", len(shows), processedTitles, len(uniqueTitles))
return uniqueTitles, nil
}

View file

@ -0,0 +1,87 @@
package list
import (
"context"
"encoding/json"
"net/http"
"strings"
"github.com/autobrr/autobrr/internal/domain"
"github.com/pkg/errors"
)
func (s *service) mdblist(ctx context.Context, list *domain.List) error {
l := s.log.With().Str("type", "mdblist").Str("list", list.Name).Logger()
if list.URL == "" {
return errors.New("no URL provided for Mdblist")
}
//var titles []string
//green := color.New(color.FgGreen).SprintFunc()
l.Debug().Msgf("fetching titles from %s", list.URL)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, list.URL, nil)
if err != nil {
return errors.Wrapf(err, "could not make new request for URL: %s", list.URL)
}
list.SetRequestHeaders(req)
//setUserAgent(req)
resp, err := s.httpClient.Do(req)
if err != nil {
return errors.Wrapf(err, "failed to fetch titles from URL: %s", list.URL)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return errors.Errorf("failed to fetch titles from URL: %s", list.URL)
}
var data []struct {
Title string `json:"title"`
}
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return errors.Wrapf(err, "failed to decode JSON data from URL: %s", list.URL)
}
filterTitles := []string{}
for _, item := range data {
filterTitles = append(filterTitles, processTitle(item.Title, list.MatchRelease)...)
}
if len(filterTitles) == 0 {
l.Debug().Msgf("no titles found to update for list: %v", list.Name)
return nil
}
joinedTitles := strings.Join(filterTitles, ",")
l.Trace().Str("titles", joinedTitles).Msgf("found %d titles", len(joinedTitles))
filterUpdate := domain.FilterUpdate{Shows: &joinedTitles}
if list.MatchRelease {
filterUpdate.Shows = &nullString
filterUpdate.MatchReleases = &joinedTitles
}
for _, filter := range list.Filters {
l.Debug().Msgf("updating filter: %v", filter.ID)
filterUpdate.ID = filter.ID
if err := s.filterSvc.UpdatePartial(ctx, filterUpdate); err != nil {
return errors.Wrapf(err, "error updating filter: %v", filter.ID)
}
l.Debug().Msgf("successfully updated filter: %v", filter.ID)
}
return nil
}

View file

@ -0,0 +1,123 @@
package list
import (
"context"
"encoding/json"
"net/http"
"strings"
"github.com/autobrr/autobrr/internal/domain"
"github.com/pkg/errors"
)
func (s *service) metacritic(ctx context.Context, list *domain.List) error {
l := s.log.With().Str("type", "metacritic").Str("list", list.Name).Logger()
if list.URL == "" {
return errors.New("no URL provided for metacritic")
}
l.Debug().Msgf("fetching titles from %s", list.URL)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, list.URL, nil)
if err != nil {
return errors.Wrapf(err, "could not make new request for URL: %s", list.URL)
}
list.SetRequestHeaders(req)
//setUserAgent(req)
resp, err := s.httpClient.Do(req)
if err != nil {
return errors.Wrapf(err, "failed to fetch titles from URL: %s", list.URL)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return errors.Errorf("No endpoint found at %v. (404 Not Found)", list.URL)
}
if resp.StatusCode != http.StatusOK {
return errors.Wrapf(err, "failed to fetch titles from URL: %s", list.URL)
}
contentType := resp.Header.Get("Content-Type")
if !strings.HasPrefix(contentType, "application/json") {
return errors.Wrapf(err, "unexpected content type for URL: %s expected application/json got %s", list.URL, contentType)
}
var data struct {
Title string `json:"title"`
Albums []struct {
Artist string `json:"artist"`
Title string `json:"title"`
} `json:"albums"`
}
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return errors.Wrapf(err, "failed to decode JSON data from URL: %s", list.URL)
}
var titles []string
var artists []string
for _, album := range data.Albums {
titles = append(titles, album.Title)
artists = append(artists, album.Artist)
}
// Deduplicate artists
uniqueArtists := []string{}
seenArtists := map[string]struct{}{}
for _, artist := range artists {
if _, ok := seenArtists[artist]; !ok {
uniqueArtists = append(uniqueArtists, artist)
seenArtists[artist] = struct{}{}
}
}
filterTitles := []string{}
for _, title := range titles {
filterTitles = append(filterTitles, processTitle(title, list.MatchRelease)...)
}
filterArtists := []string{}
for _, artist := range uniqueArtists {
filterArtists = append(filterArtists, processTitle(artist, list.MatchRelease)...)
}
if len(filterTitles) == 0 && len(filterArtists) == 0 {
l.Debug().Msgf("no titles found to update filter: %v", list.Name)
return nil
}
joinedArtists := strings.Join(filterArtists, ",")
joinedTitles := strings.Join(filterTitles, ",")
l.Trace().Str("albums", joinedTitles).Msgf("found %d album titles", len(joinedTitles))
l.Trace().Str("artists", joinedTitles).Msgf("found %d artit titles", len(joinedArtists))
filterUpdate := domain.FilterUpdate{Albums: &joinedTitles, Artists: &joinedArtists}
if list.MatchRelease {
filterUpdate.Albums = &nullString
filterUpdate.Artists = &nullString
filterUpdate.MatchReleases = &joinedTitles
}
for _, filter := range list.Filters {
l.Debug().Msgf("updating filter: %v", filter.ID)
filterUpdate.ID = filter.ID
if err := s.filterSvc.UpdatePartial(ctx, filterUpdate); err != nil {
return errors.Wrapf(err, "error updating filter: %v", filter.ID)
}
l.Debug().Msgf("successfully updated filter: %v", filter.ID)
}
return nil
}

View file

@ -0,0 +1,96 @@
package list
import (
"context"
"io"
"net/http"
"strings"
"github.com/autobrr/autobrr/internal/domain"
"github.com/pkg/errors"
)
func (s *service) plaintext(ctx context.Context, list *domain.List) error {
l := s.log.With().Str("type", "plaintext").Str("list", list.Name).Logger()
if list.URL == "" {
return errors.New("no URL provided for plaintext")
}
l.Debug().Msgf("fetching titles from %s", list.URL)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, list.URL, nil)
if err != nil {
return errors.Wrapf(err, "could not make new request for URL: %s", list.URL)
}
list.SetRequestHeaders(req)
//setUserAgent(req)
resp, err := s.httpClient.Do(req)
if err != nil {
return errors.Wrapf(err, "failed to fetch titles from URL: %s", list.URL)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return errors.Wrapf(err, "failed to fetch titles from URL: %s", list.URL)
}
contentType := resp.Header.Get("Content-Type")
if !strings.HasPrefix(contentType, "text/plain") {
return errors.Wrapf(err, "unexpected content type for URL: %s expected text/plain got %s", list.URL, contentType)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return errors.Wrapf(err, "failed to read response body from URL: %s", list.URL)
}
var titles []string
titleLines := strings.Split(string(body), "\n")
for _, titleLine := range titleLines {
title := strings.TrimSpace(titleLine)
if title == "" {
continue
}
titles = append(titles, title)
}
filterTitles := []string{}
for _, title := range titles {
filterTitles = append(filterTitles, processTitle(title, list.MatchRelease)...)
}
if len(filterTitles) == 0 {
l.Debug().Msgf("no titles found to update for list: %v", list.Name)
return nil
}
joinedTitles := strings.Join(filterTitles, ",")
l.Trace().Str("titles", joinedTitles).Msgf("found %d titles", len(joinedTitles))
filterUpdate := domain.FilterUpdate{Shows: &joinedTitles}
if list.MatchRelease {
filterUpdate.Shows = &nullString
filterUpdate.MatchReleases = &joinedTitles
}
for _, filter := range list.Filters {
l.Debug().Msgf("updating filter: %v", filter.ID)
filterUpdate.ID = filter.ID
if err := s.filterSvc.UpdatePartial(ctx, filterUpdate); err != nil {
return errors.Wrapf(err, "error updating filter: %v", filter.ID)
}
l.Debug().Msgf("successfully updated filter: %v", filter.ID)
}
return nil
}

View file

@ -0,0 +1,82 @@
package list
import (
"context"
"encoding/json"
"net/http"
"strings"
"github.com/autobrr/autobrr/internal/domain"
"github.com/pkg/errors"
)
func (s *service) steam(ctx context.Context, list *domain.List) error {
l := s.log.With().Str("type", "steam").Str("list", list.Name).Logger()
if list.URL == "" {
return errors.New("no URL provided for steam")
}
l.Debug().Msgf("fetching titles from %s", list.URL)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, list.URL, nil)
if err != nil {
return errors.Wrapf(err, "could not make new request for URL: %s", list.URL)
}
list.SetRequestHeaders(req)
//setUserAgent(req)
resp, err := s.httpClient.Do(req)
if err != nil {
return errors.Wrapf(err, "failed to fetch titles from URL: %s", list.URL)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return errors.Errorf("failed to fetch titles, non-OK status recieved: %d", resp.StatusCode)
}
var data map[string]struct {
Name string `json:"name"`
}
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return errors.Wrapf(err, "failed to decode JSON data from URL: %s", list.URL)
}
var titles []string
for _, item := range data {
titles = append(titles, item.Name)
}
filterTitles := []string{}
for _, title := range titles {
filterTitles = append(filterTitles, processTitle(title, list.MatchRelease)...)
}
if len(filterTitles) == 0 {
l.Debug().Msgf("no titles found for list to update: %v", list.Name)
return nil
}
joinedTitles := strings.Join(filterTitles, ",")
l.Trace().Str("titles", joinedTitles).Msgf("found %d titles", len(joinedTitles))
filterUpdate := domain.FilterUpdate{MatchReleases: &joinedTitles}
for _, filter := range list.Filters {
filterUpdate.ID = filter.ID
if err := s.filterSvc.UpdatePartial(ctx, filterUpdate); err != nil {
return errors.Wrapf(err, "error updating filter: %v", filter.ID)
}
l.Debug().Msgf("successfully updated filter: %v", filter.ID)
}
return nil
}

View file

@ -0,0 +1,118 @@
package list
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/autobrr/autobrr/internal/domain"
"github.com/pkg/errors"
)
func (s *service) trakt(ctx context.Context, list *domain.List) error {
l := s.log.With().Str("type", "trakt").Str("list", list.Name).Logger()
if list.URL == "" {
errMsg := "no URL provided for steam"
l.Error().Msg(errMsg)
return fmt.Errorf(errMsg)
}
l.Debug().Msgf("fetching titles from %s", list.URL)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, list.URL, nil)
if err != nil {
l.Error().Err(err).Msg("could not make new request")
return err
}
req.Header.Set("trakt-api-version", "2")
if list.APIKey != "" {
req.Header.Set("trakt-api-key", list.APIKey)
}
list.SetRequestHeaders(req)
//setUserAgent(req)
resp, err := s.httpClient.Do(req)
if err != nil {
l.Error().Err(err).Msgf("failed to fetch titles from URL: %s", list.URL)
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
l.Error().Msgf("failed to fetch titles from URL: %s", list.URL)
return fmt.Errorf("failed to fetch titles from URL: %s", list.URL)
}
contentType := resp.Header.Get("Content-Type")
if !strings.HasPrefix(contentType, "application/json") {
errMsg := fmt.Sprintf("invalid content type for URL: %s, content type should be application/json", list.URL)
return fmt.Errorf(errMsg)
}
var data []struct {
Title string `json:"title"`
Movie struct {
Title string `json:"title"`
} `json:"movie"`
Show struct {
Title string `json:"title"`
} `json:"show"`
}
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
l.Error().Err(err).Msgf("failed to decode JSON data from URL: %s", list.URL)
return err
}
var titles []string
for _, item := range data {
titles = append(titles, item.Title)
if item.Movie.Title != "" {
titles = append(titles, item.Movie.Title)
}
if item.Show.Title != "" {
titles = append(titles, item.Show.Title)
}
}
filterTitles := []string{}
for _, title := range titles {
filterTitles = append(filterTitles, processTitle(title, list.MatchRelease)...)
}
if len(filterTitles) == 0 {
l.Debug().Msgf("no titles found to update for list: %v", list.Name)
return nil
}
joinedTitles := strings.Join(filterTitles, ",")
l.Trace().Str("titles", joinedTitles).Msgf("found %d titles", len(joinedTitles))
filterUpdate := domain.FilterUpdate{Shows: &joinedTitles}
if list.MatchRelease {
filterUpdate.Shows = &nullString
filterUpdate.MatchReleases = &joinedTitles
}
for _, filter := range list.Filters {
filterUpdate.ID = filter.ID
if err := s.filterSvc.UpdatePartial(ctx, filterUpdate); err != nil {
return errors.Wrapf(err, "error updating filter: %v", filter.ID)
}
l.Debug().Msgf("successfully updated filter: %v", filter.ID)
}
return nil
}

View file

@ -0,0 +1,68 @@
package list
import (
"context"
"math/rand"
"time"
"github.com/robfig/cron/v3"
"github.com/rs/zerolog"
)
type Job interface {
cron.Job
RunE(ctx context.Context) error
}
type RefreshListSvc interface {
RefreshAll(ctx context.Context) error
}
type RefreshListsJob struct {
log zerolog.Logger
listSvc RefreshListSvc
}
func NewRefreshListsJob(log zerolog.Logger, listSvc RefreshListSvc) Job {
return &RefreshListsJob{log: log, listSvc: listSvc}
}
func (job *RefreshListsJob) Run() {
ctx := context.Background()
if err := job.RunE(ctx); err != nil {
job.log.Error().Err(err).Msg("error refreshing lists")
}
}
func (job *RefreshListsJob) RunE(ctx context.Context) error {
if err := job.run(ctx); err != nil {
job.log.Error().Err(err).Msg("error refreshing lists")
return err
}
return nil
}
func (job *RefreshListsJob) run(ctx context.Context) error {
job.log.Debug().Msg("running refresh lists job")
// Seed the random number generator
rand.New(rand.NewSource(time.Now().UnixNano()))
// Generate a random duration between 1 and 35 seconds
delay := time.Duration(rand.Intn(35-1+1)+1) * time.Second // (35-1+1)+1 => range: 1 to 35
job.log.Debug().Msgf("delaying for %v...", delay)
// Sleep for the calculated duration
time.Sleep(delay)
if err := job.listSvc.RefreshAll(ctx); err != nil {
job.log.Error().Err(err).Msg("error refreshing lists")
return err
}
job.log.Debug().Msg("finished refresh lists job")
return nil
}

337
internal/list/service.go Normal file
View file

@ -0,0 +1,337 @@
package list
import (
"context"
stdErr "errors"
"net/http"
"time"
"github.com/autobrr/autobrr/internal/domain"
"github.com/autobrr/autobrr/internal/download_client"
"github.com/autobrr/autobrr/internal/filter"
"github.com/autobrr/autobrr/internal/logger"
"github.com/autobrr/autobrr/internal/scheduler"
"github.com/pkg/errors"
"github.com/rs/zerolog"
)
type Service interface {
List(ctx context.Context) ([]*domain.List, error)
FindByID(ctx context.Context, id int64) (*domain.List, error)
Store(ctx context.Context, list *domain.List) error
Update(ctx context.Context, list *domain.List) error
Delete(ctx context.Context, id int64) error
RefreshAll(ctx context.Context) error
RefreshList(ctx context.Context, listID int64) error
RefreshArrLists(ctx context.Context) error
RefreshOtherLists(ctx context.Context) error
Start()
}
type service struct {
log zerolog.Logger
repo domain.ListRepo
httpClient *http.Client
scheduler scheduler.Service
downloadClientSvc download_client.Service
filterSvc filter.Service
}
func NewService(log logger.Logger, repo domain.ListRepo, downloadClientSvc download_client.Service, filterSvc filter.Service, schedulerSvc scheduler.Service) Service {
return &service{
log: log.With().Str("module", "list").Logger(),
repo: repo,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
downloadClientSvc: downloadClientSvc,
filterSvc: filterSvc,
scheduler: schedulerSvc,
}
}
func (s *service) List(ctx context.Context) ([]*domain.List, error) {
data, err := s.repo.List(ctx)
if err != nil {
return nil, err
}
// attach filters
for _, list := range data {
filters, err := s.repo.GetListFilters(ctx, list.ID)
if err != nil {
return nil, err
}
list.Filters = filters
}
return data, nil
}
func (s *service) FindByID(ctx context.Context, id int64) (*domain.List, error) {
list, err := s.repo.FindByID(ctx, id)
if err != nil {
return nil, err
}
// attach filters
filters, err := s.repo.GetListFilters(ctx, list.ID)
if err != nil {
return nil, err
}
list.Filters = filters
return list, nil
}
func (s *service) Store(ctx context.Context, list *domain.List) error {
if err := list.Validate(); err != nil {
s.log.Error().Err(err).Msgf("could not validate list %s", list.Name)
return err
}
if err := s.repo.Store(ctx, list); err != nil {
s.log.Error().Err(err).Msgf("could not store list %s", list.Name)
return err
}
s.log.Debug().Msgf("successfully created list %s", list.Name)
if list.Enabled {
if err := s.refreshList(ctx, list); err != nil {
s.log.Error().Err(err).Msgf("could not refresh list %s", list.Name)
return err
}
}
return nil
}
func (s *service) Update(ctx context.Context, list *domain.List) error {
if err := list.Validate(); err != nil {
s.log.Error().Err(err).Msgf("could not validate list %s", list.Name)
return err
}
if err := s.repo.Update(ctx, list); err != nil {
s.log.Error().Err(err).Msgf("could not update list %s", list.Name)
return err
}
s.log.Debug().Msgf("successfully updated list %s", list.Name)
if list.Enabled {
if err := s.refreshList(ctx, list); err != nil {
s.log.Error().Err(err).Msgf("could not refresh list %s", list.Name)
return err
}
}
return nil
}
func (s *service) Delete(ctx context.Context, id int64) error {
err := s.repo.Delete(ctx, id)
if err != nil {
s.log.Error().Err(err).Msgf("could not delete list by id %d", id)
return err
}
s.log.Debug().Msgf("successfully deleted list %d", id)
return nil
}
func (s *service) RefreshAll(ctx context.Context) error {
lists, err := s.List(ctx)
if err != nil {
return err
}
s.log.Debug().Msgf("found %d lists to refresh", len(lists))
if err := s.refreshAll(ctx, lists); err != nil {
return err
}
s.log.Debug().Msgf("successfully refreshed all lists")
return nil
}
func (s *service) refreshAll(ctx context.Context, lists []*domain.List) error {
var processingErrors []error
for _, listItem := range lists {
if !listItem.Enabled {
s.log.Debug().Msgf("list %s is disabled, skipping...", listItem.Name)
continue
}
if err := s.refreshList(ctx, listItem); err != nil {
s.log.Error().Err(err).Str("type", string(listItem.Type)).Str("list", listItem.Name).Msgf("error while refreshing %s, continuing with other lists", listItem.Type)
processingErrors = append(processingErrors, errors.Wrapf(err, "error while refreshing %s", listItem.Name))
}
}
if len(processingErrors) > 0 {
err := stdErr.Join(processingErrors...)
s.log.Error().Err(err).Msg("Errors encountered during processing Arrs:")
return err
}
return nil
}
func (s *service) refreshList(ctx context.Context, listItem *domain.List) error {
s.log.Debug().Msgf("refresh list %s - %s", listItem.Type, listItem.Name)
var err error
switch listItem.Type {
case domain.ListTypeRadarr:
err = s.radarr(ctx, listItem)
case domain.ListTypeSonarr:
err = s.sonarr(ctx, listItem)
case domain.ListTypeWhisparr:
err = s.sonarr(ctx, listItem)
case domain.ListTypeReadarr:
err = s.readarr(ctx, listItem)
case domain.ListTypeLidarr:
err = s.lidarr(ctx, listItem)
case domain.ListTypeMDBList:
err = s.mdblist(ctx, listItem)
case domain.ListTypeMetacritic:
err = s.metacritic(ctx, listItem)
case domain.ListTypeSteam:
err = s.steam(ctx, listItem)
case domain.ListTypeTrakt:
err = s.trakt(ctx, listItem)
case domain.ListTypePlaintext:
err = s.plaintext(ctx, listItem)
default:
err = errors.Errorf("unsupported list type: %s", listItem.Type)
}
if err != nil {
s.log.Error().Err(err).Str("type", string(listItem.Type)).Str("list", listItem.Name).Msgf("error refreshing %s list", listItem.Name)
// update last run for list and set errs and status
listItem.LastRefreshStatus = domain.ListRefreshStatusError
listItem.LastRefreshData = err.Error()
listItem.LastRefreshTime = time.Now()
if updateErr := s.repo.UpdateLastRefresh(ctx, listItem); updateErr != nil {
s.log.Error().Err(updateErr).Str("type", string(listItem.Type)).Str("list", listItem.Name).Msgf("error updating last refresh for %s list", listItem.Name)
return updateErr
}
return err
}
listItem.LastRefreshStatus = domain.ListRefreshStatusSuccess
//listItem.LastRefreshData = err.Error()
listItem.LastRefreshTime = time.Now()
if updateErr := s.repo.UpdateLastRefresh(ctx, listItem); updateErr != nil {
s.log.Error().Err(updateErr).Str("type", string(listItem.Type)).Str("list", listItem.Name).Msgf("error updating last refresh for %s list", listItem.Name)
return updateErr
}
s.log.Debug().Msgf("successfully refreshed list %s", listItem.Name)
return nil
}
func (s *service) RefreshList(ctx context.Context, listID int64) error {
list, err := s.FindByID(ctx, listID)
if err != nil {
return err
}
if err := s.refreshList(ctx, list); err != nil {
return err
}
return nil
}
func (s *service) RefreshArrLists(ctx context.Context) error {
lists, err := s.List(ctx)
if err != nil {
return err
}
var selectedLists []*domain.List
for _, list := range lists {
if list.ListTypeArr() && list.Enabled {
selectedLists = append(selectedLists, list)
}
}
if err := s.refreshAll(ctx, selectedLists); err != nil {
return err
}
return nil
}
func (s *service) RefreshOtherLists(ctx context.Context) error {
lists, err := s.List(ctx)
if err != nil {
return err
}
var selectedLists []*domain.List
for _, list := range lists {
if list.ListTypeList() && list.Enabled {
selectedLists = append(selectedLists, list)
}
}
if err := s.refreshAll(ctx, selectedLists); err != nil {
return err
}
return nil
}
// scheduleJob start list updater in the background
func (s *service) scheduleJob() error {
identifierKey := "lists-updater"
job := NewRefreshListsJob(s.log.With().Str("job", identifierKey).Logger(), s)
// schedule job to run every 6th hour
id, err := s.scheduler.AddJob(job, "0 */6 * * *", identifierKey)
if err != nil {
return err
}
s.log.Debug().Msgf("scheduled job with id %d", id)
return nil
}
func (s *service) Start() {
if err := s.scheduleJob(); err != nil {
s.log.Error().Err(err).Msg("error while scheduling job")
}
}

28
internal/list/tags.go Normal file
View file

@ -0,0 +1,28 @@
package list
import "github.com/autobrr/autobrr/pkg/arr"
func containsTag(tags []*arr.Tag, titleTags []int, checkTags []string) bool {
tagLabels := []string{}
// match tag id's with labels
for _, movieTag := range titleTags {
for _, tag := range tags {
tag := tag
if movieTag == tag.ID {
tagLabels = append(tagLabels, tag.Label)
}
}
}
// check included tags and set ret to true if we have a match
for _, includeTag := range checkTags {
for _, label := range tagLabels {
if includeTag == label {
return true
}
}
}
return false
}

103
internal/list/title.go Normal file
View file

@ -0,0 +1,103 @@
package list
import (
"fmt"
"regexp"
"strings"
)
// Regex patterns
// https://www.regular-expressions.info/unicode.html#category
// https://www.ncbi.nlm.nih.gov/staff/beck/charents/hex.html
var (
replaceRegexp = regexp.MustCompile(`[\p{P}\p{Z}\x{00C0}-\x{017E}\x{00AE}]`)
questionmarkRegexp = regexp.MustCompile(`[?]{2,}`)
regionCodeRegexp = regexp.MustCompile(`\(.+\)$`)
parenthesesEndRegexp = regexp.MustCompile(`\)$`)
)
func processTitle(title string, matchRelease bool) []string {
// Checking if the title is empty.
if strings.TrimSpace(title) == "" {
return nil
}
// cleans year like (2020) from arr title
//var re = regexp.MustCompile(`(?m)\s(\(\d+\))`)
//title = re.ReplaceAllString(title, "")
t := NewTitleSlice()
if replaceRegexp.ReplaceAllString(title, "") == "" {
t.Add(title, matchRelease)
} else {
// title with all non-alphanumeric characters replaced by "?"
apostropheTitle := parenthesesEndRegexp.ReplaceAllString(title, "?")
apostropheTitle = replaceRegexp.ReplaceAllString(apostropheTitle, "?")
apostropheTitle = questionmarkRegexp.ReplaceAllString(apostropheTitle, "*")
t.Add(apostropheTitle, matchRelease)
t.Add(strings.TrimRight(apostropheTitle, "?* "), matchRelease)
// title with apostrophes removed and all non-alphanumeric characters replaced by "?"
noApostropheTitle := parenthesesEndRegexp.ReplaceAllString(title, "?")
noApostropheTitle = strings.ReplaceAll(noApostropheTitle, "'", "")
noApostropheTitle = replaceRegexp.ReplaceAllString(noApostropheTitle, "?")
noApostropheTitle = questionmarkRegexp.ReplaceAllString(noApostropheTitle, "*")
t.Add(noApostropheTitle, matchRelease)
t.Add(strings.TrimRight(noApostropheTitle, "?* "), matchRelease)
// title with regions in parentheses removed and all non-alphanumeric characters replaced by "?"
removedRegionCodeApostrophe := regionCodeRegexp.ReplaceAllString(title, "")
removedRegionCodeApostrophe = strings.TrimRight(removedRegionCodeApostrophe, " ")
removedRegionCodeApostrophe = replaceRegexp.ReplaceAllString(removedRegionCodeApostrophe, "?")
removedRegionCodeApostrophe = questionmarkRegexp.ReplaceAllString(removedRegionCodeApostrophe, "*")
t.Add(removedRegionCodeApostrophe, matchRelease)
t.Add(strings.TrimRight(removedRegionCodeApostrophe, "?* "), matchRelease)
// title with regions in parentheses and apostrophes removed and all non-alphanumeric characters replaced by "?"
removedRegionCodeNoApostrophe := regionCodeRegexp.ReplaceAllString(title, "")
removedRegionCodeNoApostrophe = strings.TrimRight(removedRegionCodeNoApostrophe, " ")
removedRegionCodeNoApostrophe = strings.ReplaceAll(removedRegionCodeNoApostrophe, "'", "")
removedRegionCodeNoApostrophe = replaceRegexp.ReplaceAllString(removedRegionCodeNoApostrophe, "?")
removedRegionCodeNoApostrophe = questionmarkRegexp.ReplaceAllString(removedRegionCodeNoApostrophe, "*")
t.Add(removedRegionCodeNoApostrophe, matchRelease)
t.Add(strings.TrimRight(removedRegionCodeNoApostrophe, "?* "), matchRelease)
}
return t.Titles()
}
type Titles struct {
tm map[string]struct{}
}
func NewTitleSlice() *Titles {
ts := Titles{
tm: map[string]struct{}{},
}
return &ts
}
func (ts *Titles) Add(title string, matchRelease bool) {
if matchRelease {
title = strings.Trim(title, "?")
title = fmt.Sprintf("*%v*", title)
}
_, ok := ts.tm[title]
if !ok {
ts.tm[title] = struct{}{}
}
}
func (ts *Titles) Titles() []string {
titles := []string{}
for key := range ts.tm {
titles = append(titles, key)
}
return titles
}