Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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)
Expand Down
35 changes: 35 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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")
}
2 changes: 2 additions & 0 deletions internal/engine/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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),
}
Expand Down
105 changes: 105 additions & 0 deletions internal/filter/movie_release_filter/filter.go
Original file line number Diff line number Diff line change
@@ -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
}
110 changes: 110 additions & 0 deletions internal/filter/movie_release_filter/filter_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading