diff --git a/README.md b/README.md index cfe80f3d..36d39a19 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,7 @@ Filters can be configured per library and include: | Filter | Description | | ------------------------ | -------------------------------------------------------------------------------- | | `content_age_threshold` | Minimum age of the content in days | +| `movie_release_date_max` | Only accept movies released before this date (YYYY-MM-DD or RFC3339) | | `last_stream_threshold` | Minimum days since the content was last streamed | | `content_size_threshold` | Minimum size of the content in bytes (0 = no minimum) | | `tunarr_enabled` | Whether to protect items used by Tunarr channels (requires Tunarr configuration) | @@ -385,7 +386,7 @@ All configuration options can be set via environment variables with the `JELLYSW | `JELLYSWEEP_LEAVING_COLLECTIONS_TV_NAME` | `Leaving TV Shows` | Name of the leaving TV shows collection | | **Database Configuration** | | | | `JELLYSWEEP_DATABASE_TYPE` | `sqlite` | Database backend: `sqlite` or `postgres` | -| `JELLYSWEEP_DATABASE_PATH` | `./data/jellysweep.db` | Path to the database file (for SQLite) | +| `JELLYSWEEP_DATABASE_PATH` | `./data/jellysweep.db` | Path to the database file (for SQLite) | | `JELLYSWEEP_DATABASE_HOST` | *(required for postgres)* | PostgreSQL server host | | `JELLYSWEEP_DATABASE_PORT` | `5432` | PostgreSQL server port | | `JELLYSWEEP_DATABASE_NAME` | *(required for postgres)* | PostgreSQL database name | @@ -540,6 +541,7 @@ libraries: # Filter configuration filter: content_age_threshold: 120 # Content must be at least 120 days old + movie_release_date_max: "2024-01-01" # Movies must be released before this date last_stream_threshold: 90 # Last watched at least 90 days ago content_size_threshold: 1073741824 # 1GB minimum (0 = no minimum) tunarr_enabled: true # Protect items used by Tunarr channels (requires tunarr config) diff --git a/internal/config/config.go b/internal/config/config.go index 68fdc89e..9b8f9871 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -266,6 +266,9 @@ type CleanupConfig struct { type FilterConfig struct { // ContentAgeThreshold is the minimum age in days for content (since it was first imported) to be eligible for cleanup. ContentAgeThreshold int `yaml:"content_age_threshold" mapstructure:"content_age_threshold"` + // MovieReleaseDateMax is the latest movie release date accepted for cleanup. + // Supported formats: YYYY-MM-DD and RFC3339. + MovieReleaseDateMax string `yaml:"movie_release_date_max" mapstructure:"movie_release_date_max"` // LastStreamThreshold is the minimum time in days since the last stream for content to be eligible for cleanup. LastStreamThreshold int `yaml:"last_stream_threshold" mapstructure:"last_stream_threshold"` // ContentSizeThreshold is the minimum size in bytes for content to be eligible for cleanup. @@ -936,6 +939,21 @@ func (c *CleanupConfig) GetContentAgeThreshold() int { return 30 // Default to 30 days } +// GetMovieReleaseDateMax returns the maximum movie release date cutoff. +// An empty value disables movie release date filtering. +func (c *CleanupConfig) GetMovieReleaseDateMax() (*time.Time, error) { + if strings.TrimSpace(c.Filter.MovieReleaseDateMax) == "" { + return nil, nil + } + + releaseDateMax, err := parseConfigTime(c.Filter.MovieReleaseDateMax) + if err != nil { + return nil, fmt.Errorf("invalid movie_release_date_max %q: %w", c.Filter.MovieReleaseDateMax, err) + } + + return &releaseDateMax, nil +} + // GetLastStreamThreshold returns the last stream threshold with proper defaults. // It first checks the new Filter.LastStreamThreshold field, and falls back to the // deprecated LastStreamThreshold field if the new field is not set. @@ -999,3 +1017,20 @@ func (c *CleanupConfig) GetExcludeTags() []string { // Default value return []string{} // Default to empty list } + +func parseConfigTime(value string) (time.Time, error) { + value = strings.TrimSpace(value) + for _, layout := range []string{ + time.RFC3339, + "2006-01-02", + "2006-01-02 15:04:05", + "2006-01-02T15:04:05", + } { + parsed, err := time.Parse(layout, value) + if err == nil { + return parsed, nil + } + } + + return time.Time{}, fmt.Errorf("expected YYYY-MM-DD or RFC3339 timestamp") +} diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 61136ae8..5324909b 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -23,6 +23,7 @@ import ( "github.com/jon4hz/jellysweep/internal/filter" agefilter "github.com/jon4hz/jellysweep/internal/filter/age_filter" databasefilter "github.com/jon4hz/jellysweep/internal/filter/database_filter" + moviereleasefilter "github.com/jon4hz/jellysweep/internal/filter/movie_release_filter" seriesfilter "github.com/jon4hz/jellysweep/internal/filter/series_filter" sizefilter "github.com/jon4hz/jellysweep/internal/filter/size_filter" streamfilter "github.com/jon4hz/jellysweep/internal/filter/stream_filter" @@ -125,6 +126,7 @@ func New(cfg *config.Config, db database.DB, initialDBMigration bool) (*Engine, seriesfilter.New(cfg), tagsfilter.New(cfg), sizefilter.New(cfg), + moviereleasefilter.New(cfg), agefilter.New(cfg, db, sonarrClient, radarrClient), streamfilter.New(cfg, statsClient), } diff --git a/internal/filter/movie_release_filter/filter.go b/internal/filter/movie_release_filter/filter.go new file mode 100644 index 00000000..54926401 --- /dev/null +++ b/internal/filter/movie_release_filter/filter.go @@ -0,0 +1,105 @@ +package moviereleasefilter + +import ( + "context" + "time" + + "github.com/charmbracelet/log" + "github.com/devopsarr/radarr-go/radarr" + "github.com/jon4hz/jellysweep/internal/api/models" + "github.com/jon4hz/jellysweep/internal/config" + "github.com/jon4hz/jellysweep/internal/engine/arr" + "github.com/jon4hz/jellysweep/internal/filter" +) + +// Filter implements the filter.Filterer interface. +type Filter struct { + cfg *config.Config +} + +var _ filter.Filterer = (*Filter)(nil) + +// New creates a new movie release Filter instance. +func New(cfg *config.Config) *Filter { + return &Filter{ + cfg: cfg, + } +} + +// String returns the name of the filter. +func (f *Filter) String() string { return "Movie Release Filter" } + +// Apply filters movie items based on their release date. Non-movie items pass through unchanged. +func (f *Filter) Apply(ctx context.Context, mediaItems []arr.MediaItem) ([]arr.MediaItem, error) { + filteredItems := make([]arr.MediaItem, 0, len(mediaItems)) + + for _, item := range mediaItems { + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + + if item.MediaType != models.MediaTypeMovie { + filteredItems = append(filteredItems, item) + continue + } + + libraryConfig := f.cfg.GetLibraryConfig(item.LibraryName) + if libraryConfig == nil { + filteredItems = append(filteredItems, item) + log.Debug("no library config, including movie for deletion", "library", item.LibraryName, "title", item.Title) + continue + } + + releaseDateMax, err := libraryConfig.GetMovieReleaseDateMax() + if err != nil { + return nil, err + } + if releaseDateMax == nil { + filteredItems = append(filteredItems, item) + log.Debug("no movie release date maximum configured, including movie for deletion", "library", item.LibraryName, "title", item.Title) + continue + } + + releaseDate, ok := movieReleaseDate(item.MovieResource) + if !ok { + log.Debug("excluding movie without release date", "title", item.Title, "library", item.LibraryName) + continue + } + + if releaseDate.Before(*releaseDateMax) { + filteredItems = append(filteredItems, item) + log.Debug("including movie released before maximum date", "title", item.Title, "releaseDate", releaseDate.Format(time.RFC3339), "releaseDateMax", releaseDateMax.Format(time.RFC3339)) + continue + } + + log.Debug("excluding movie released on or after maximum date", "title", item.Title, "releaseDate", releaseDate.Format(time.RFC3339), "releaseDateMax", releaseDateMax.Format(time.RFC3339)) + } + + return filteredItems, nil +} + +func movieReleaseDate(movie radarr.MovieResource) (time.Time, bool) { + var releaseDate time.Time + + for _, candidate := range []time.Time{ + movie.GetReleaseDate(), + movie.GetInCinemas(), + movie.GetDigitalRelease(), + movie.GetPhysicalRelease(), + } { + if candidate.IsZero() { + continue + } + if releaseDate.IsZero() || candidate.Before(releaseDate) { + releaseDate = candidate + } + } + + if releaseDate.IsZero() { + return time.Time{}, false + } + + return releaseDate, true +} diff --git a/internal/filter/movie_release_filter/filter_test.go b/internal/filter/movie_release_filter/filter_test.go new file mode 100644 index 00000000..e8801e4d --- /dev/null +++ b/internal/filter/movie_release_filter/filter_test.go @@ -0,0 +1,110 @@ +package moviereleasefilter + +import ( + "context" + "testing" + "time" + + "github.com/devopsarr/radarr-go/radarr" + "github.com/devopsarr/sonarr-go/sonarr" + "github.com/jon4hz/jellysweep/internal/api/models" + "github.com/jon4hz/jellysweep/internal/config" + "github.com/jon4hz/jellysweep/internal/engine/arr" +) + +func TestApplyFiltersMoviesByReleaseDateMax(t *testing.T) { + cfg := &config.Config{ + Libraries: map[string]*config.CleanupConfig{ + "Movies": { + Filter: config.FilterConfig{ + MovieReleaseDateMax: "2024-01-01", + }, + }, + }, + } + + acceptedMovieDate := time.Date(2023, time.December, 31, 0, 0, 0, 0, time.UTC) + rejectedMovieDate := time.Date(2024, time.January, 1, 0, 0, 0, 0, time.UTC) + acceptedMovie := radarr.MovieResource{} + acceptedMovie.SetReleaseDate(acceptedMovieDate) + rejectedMovie := radarr.MovieResource{} + rejectedMovie.SetReleaseDate(rejectedMovieDate) + + items := []arr.MediaItem{ + { + Title: "Accepted Movie", + LibraryName: "Movies", + MediaType: models.MediaTypeMovie, + MovieResource: acceptedMovie, + }, + { + Title: "Rejected Movie", + LibraryName: "Movies", + MediaType: models.MediaTypeMovie, + MovieResource: rejectedMovie, + }, + { + Title: "TV Show", + LibraryName: "Movies", + MediaType: models.MediaTypeTV, + SeriesResource: sonarr.SeriesResource{}, + }, + } + + filtered, err := New(cfg).Apply(context.Background(), items) + if err != nil { + t.Fatalf("Apply returned error: %v", err) + } + + if len(filtered) != 2 { + t.Fatalf("expected 2 filtered items, got %d", len(filtered)) + } + if filtered[0].Title != "Accepted Movie" { + t.Fatalf("expected movie before maximum date to pass filter, got %q", filtered[0].Title) + } + if filtered[1].Title != "TV Show" { + t.Fatalf("expected TV item to pass filter, got %q", filtered[1].Title) + } +} + +func TestApplyIncludesMoviesWhenThresholdDisabled(t *testing.T) { + cfg := &config.Config{ + Libraries: map[string]*config.CleanupConfig{ + "Movies": {}, + }, + } + + item := arr.MediaItem{ + Title: "No Release Date", + LibraryName: "Movies", + MediaType: models.MediaTypeMovie, + } + + filtered, err := New(cfg).Apply(context.Background(), []arr.MediaItem{item}) + if err != nil { + t.Fatalf("Apply returned error: %v", err) + } + + if len(filtered) != 1 { + t.Fatalf("expected movie to pass when maximum release date is disabled, got %d items", len(filtered)) + } +} + +func TestMovieReleaseDateUsesEarliestKnownReleaseDate(t *testing.T) { + movie := radarr.MovieResource{} + releaseDate := time.Date(2025, time.January, 1, 0, 0, 0, 0, time.UTC) + inCinemas := time.Date(2024, time.January, 1, 0, 0, 0, 0, time.UTC) + digitalRelease := time.Date(2024, time.June, 1, 0, 0, 0, 0, time.UTC) + + movie.SetReleaseDate(releaseDate) + movie.SetInCinemas(inCinemas) + movie.SetDigitalRelease(digitalRelease) + + got, ok := movieReleaseDate(movie) + if !ok { + t.Fatal("expected release date to be found") + } + if !got.Equal(inCinemas) { + t.Fatalf("expected earliest release date %s, got %s", inCinemas, got) + } +}