├── .github ├── funding.yml ├── release.yml ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ └── stale-questions.yml ├── images ├── logo.jpg ├── config_page_create.png ├── config_page_manage.png ├── config_page_status.png ├── config_page_settings.png ├── config_page_create_cropped.png ├── config_page_manage_cropped.png └── config_page_settings_cropped.png ├── docs ├── requirements.txt ├── overrides │ └── 404.html ├── content │ ├── extra.css │ ├── getting-started │ │ ├── quick-start.md │ │ └── installation.md │ ├── examples │ │ ├── advanced-examples.md │ │ └── common-use-cases.md │ ├── development │ │ ├── contributing.md │ │ ├── debugging.md │ │ └── building-locally.md │ ├── user-guide │ │ ├── advanced-configuration.md │ │ ├── media-types.md │ │ ├── sorting-and-limits.md │ │ ├── user-selection.md │ │ └── auto-refresh.md │ └── index.md └── mkdocs.yml ├── dev ├── meta-dev.json ├── logging.json ├── docker-compose.yml ├── build-local.sh └── build-local.ps1 ├── Jellyfin.Plugin.SmartLists ├── Core │ ├── Orders │ │ ├── NoOrder.cs │ │ ├── LastPlayedOrder.cs │ │ ├── DateCreatedOrder.cs │ │ ├── ProductionYearOrder.cs │ │ ├── CommunityRatingOrder.cs │ │ ├── RuntimeOrder.cs │ │ ├── Order.cs │ │ ├── AlbumNameOrder.cs │ │ ├── ComparableTuple4.cs │ │ ├── PropertyOrder.cs │ │ ├── NameIgnoreArticlesOrder.cs │ │ ├── RandomOrder.cs │ │ ├── SimilarityOrder.cs │ │ ├── ArtistOrder.cs │ │ ├── SeasonNumberOrder.cs │ │ ├── PlayCountOrder.cs │ │ ├── UserDataOrder.cs │ │ ├── NameOrder.cs │ │ ├── SeriesNameIgnoreArticlesOrder.cs │ │ ├── EpisodeNumberOrder.cs │ │ ├── SeriesNameOrder.cs │ │ ├── TrackNumberOrder.cs │ │ ├── ReleaseDateOrder.cs │ │ └── LastPlayedOrderBase.cs │ ├── Enums │ │ ├── RuleLogic.cs │ │ ├── SmartListType.cs │ │ ├── RefreshTriggerType.cs │ │ ├── AutoRefreshMode.cs │ │ └── ScheduleTrigger.cs │ ├── Models │ │ ├── SortOption.cs │ │ ├── ExpressionSet.cs │ │ ├── SmartCollectionDto.cs │ │ ├── OrderDto.cs │ │ ├── SmartPlaylistDto.cs │ │ ├── Schedule.cs │ │ ├── DayOfWeekAsIntegerConverter.cs │ │ └── SmartListDto.cs │ ├── QueryEngine │ │ ├── DateUtils.cs │ │ └── Expression.cs │ └── Constants │ │ └── ResolutionTypes.cs ├── Services │ ├── Abstractions │ │ ├── ISmartListStore.cs │ │ └── ISmartListService.cs │ └── Shared │ │ ├── BasicDirectoryService.cs │ │ └── AutoRefreshHostedService.cs ├── Configuration │ ├── config-multi-select.css │ ├── config-user-select.js │ └── PluginConfiguration.cs ├── Utilities │ ├── RuntimeCalculator.cs │ ├── LibraryManagerHelper.cs │ ├── MediaTypeConverter.cs │ ├── MediaTypesKey.cs │ └── NameFormatter.cs ├── ServiceRegistrator.cs ├── Jellyfin.Plugin.SmartLists.csproj └── .editorconfig ├── CONTRIBUTING.md ├── Jellyfin.Plugin.SmartLists.sln ├── 09aa0d74-f29e-468e-aeb0-1495d5c7602f.json └── .cursor └── rules └── html.mdc /.github/funding.yml: -------------------------------------------------------------------------------- 1 | buy_me_a_coffee: jyourstone -------------------------------------------------------------------------------- /images/logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jyourstone/jellyfin-smartlists-plugin/HEAD/images/logo.jpg -------------------------------------------------------------------------------- /images/config_page_create.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jyourstone/jellyfin-smartlists-plugin/HEAD/images/config_page_create.png -------------------------------------------------------------------------------- /images/config_page_manage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jyourstone/jellyfin-smartlists-plugin/HEAD/images/config_page_manage.png -------------------------------------------------------------------------------- /images/config_page_status.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jyourstone/jellyfin-smartlists-plugin/HEAD/images/config_page_status.png -------------------------------------------------------------------------------- /images/config_page_settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jyourstone/jellyfin-smartlists-plugin/HEAD/images/config_page_settings.png -------------------------------------------------------------------------------- /images/config_page_create_cropped.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jyourstone/jellyfin-smartlists-plugin/HEAD/images/config_page_create_cropped.png -------------------------------------------------------------------------------- /images/config_page_manage_cropped.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jyourstone/jellyfin-smartlists-plugin/HEAD/images/config_page_manage_cropped.png -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | mkdocs==1.* 2 | mkdocs-material==9.* 3 | mkdocs-git-revision-date-localized-plugin==1.* 4 | mkdocs-literate-nav==0.6.* 5 | 6 | -------------------------------------------------------------------------------- /images/config_page_settings_cropped.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jyourstone/jellyfin-smartlists-plugin/HEAD/images/config_page_settings_cropped.png -------------------------------------------------------------------------------- /dev/meta-dev.json: -------------------------------------------------------------------------------- 1 | { 2 | "guid": "A0A2A7B2-747A-4113-8B39-757A9D267C79", 3 | "version": "10.11.0.0", 4 | "targetAbi": "10.11.0", 5 | "imagePath": "logo.jpg" 6 | } -------------------------------------------------------------------------------- /dev/logging.json: -------------------------------------------------------------------------------- 1 | { 2 | "Serilog": { 3 | "MinimumLevel": { 4 | "Override": { 5 | "Jellyfin.Plugin.SmartLists": "Debug" 6 | } 7 | } 8 | } 9 | } -------------------------------------------------------------------------------- /Jellyfin.Plugin.SmartLists/Core/Orders/NoOrder.cs: -------------------------------------------------------------------------------- 1 | namespace Jellyfin.Plugin.SmartLists.Core.Orders 2 | { 3 | public class NoOrder : Order 4 | { 5 | public override string Name => "NoOrder"; 6 | } 7 | } 8 | 9 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.SmartLists/Core/Enums/RuleLogic.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace Jellyfin.Plugin.SmartLists.Core.Enums 4 | { 5 | [JsonConverter(typeof(JsonStringEnumConverter))] 6 | public enum RuleLogic 7 | { 8 | And, 9 | Or, 10 | } 11 | } 12 | 13 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.SmartLists/Core/Enums/SmartListType.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace Jellyfin.Plugin.SmartLists.Core.Enums 4 | { 5 | /// 6 | /// Type discriminator for smart lists (Playlist vs Collection) 7 | /// 8 | [JsonConverter(typeof(JsonStringEnumConverter))] 9 | public enum SmartListType 10 | { 11 | Playlist, 12 | Collection, 13 | } 14 | } 15 | 16 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.SmartLists/Core/Enums/RefreshTriggerType.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace Jellyfin.Plugin.SmartLists.Core.Enums 4 | { 5 | /// 6 | /// Type of trigger that initiated a refresh operation 7 | /// 8 | [JsonConverter(typeof(JsonStringEnumConverter))] 9 | public enum RefreshTriggerType 10 | { 11 | Manual, 12 | Auto, 13 | Scheduled 14 | } 15 | } 16 | 17 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.SmartLists/Core/Enums/AutoRefreshMode.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace Jellyfin.Plugin.SmartLists.Core.Enums 4 | { 5 | [JsonConverter(typeof(JsonStringEnumConverter))] 6 | public enum AutoRefreshMode 7 | { 8 | Never = 0, // Manual only (current behavior) 9 | OnLibraryChanges = 1, // Only when items added 10 | OnAllChanges = 2 // Any metadata updates (including playback status), 11 | } 12 | } 13 | 14 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | labels: 4 | - skip-changelog 5 | authors: 6 | - github-actions 7 | - sourcery-ai 8 | - coderabbitai 9 | categories: 10 | - title: Breaking Changes 11 | labels: 12 | - breaking-change 13 | - title: New Features 14 | labels: 15 | - feature 16 | - enhancement 17 | - title: Bug Fixes 18 | labels: 19 | - bug 20 | - fix 21 | - title: Other Changes 22 | labels: 23 | - "*" -------------------------------------------------------------------------------- /Jellyfin.Plugin.SmartLists/Core/Models/SortOption.cs: -------------------------------------------------------------------------------- 1 | using Jellyfin.Database.Implementations.Enums; 2 | 3 | namespace Jellyfin.Plugin.SmartLists.Core.Models 4 | { 5 | /// 6 | /// Represents a single sorting option with field and direction 7 | /// 8 | public class SortOption 9 | { 10 | public required string SortBy { get; set; } // e.g., "Name", "ProductionYear", "SeasonNumber" 11 | public required SortOrder SortOrder { get; set; } // Ascending or Descending 12 | } 13 | } 14 | 15 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.SmartLists/Core/Orders/LastPlayedOrder.cs: -------------------------------------------------------------------------------- 1 | namespace Jellyfin.Plugin.SmartLists.Core.Orders 2 | { 3 | public class LastPlayedOrder : LastPlayedOrderBase 4 | { 5 | public override string Name => "LastPlayed (owner) Ascending"; 6 | protected override bool IsDescending => false; 7 | } 8 | 9 | public class LastPlayedOrderDesc : LastPlayedOrderBase 10 | { 11 | public override string Name => "LastPlayed (owner) Descending"; 12 | protected override bool IsDescending => true; 13 | } 14 | } 15 | 16 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.SmartLists/Core/Models/ExpressionSet.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using Jellyfin.Plugin.SmartLists.Core.QueryEngine; 3 | 4 | namespace Jellyfin.Plugin.SmartLists.Core.Models 5 | { 6 | /// 7 | /// Represents a set of expressions that are evaluated together as a group. 8 | /// 9 | public class ExpressionSet 10 | { 11 | /// 12 | /// Gets the list of expressions in this set. 13 | /// May be null during JSON deserialization of legacy data. 14 | /// 15 | public List? Expressions { get; init; } = []; 16 | } 17 | } 18 | 19 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.SmartLists/Core/Enums/ScheduleTrigger.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace Jellyfin.Plugin.SmartLists.Core.Enums 4 | { 5 | [JsonConverter(typeof(JsonStringEnumConverter))] 6 | public enum ScheduleTrigger 7 | { 8 | None = 0, // Explicitly no schedule (different from null which means legacy tasks) 9 | Daily = 1, // Once per day at specified time 10 | Weekly = 2, // Once per week on specified day/time 11 | Monthly = 3, // Once per month on specified day and time 12 | Interval = 4, // Every X hours/minutes 13 | Yearly = 5 // Once per year on specified month, day, and time, 14 | } 15 | } 16 | 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.SmartLists/Core/Models/SmartCollectionDto.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Jellyfin.Plugin.SmartLists.Core.Models 4 | { 5 | /// 6 | /// DTO for server-wide smart collections 7 | /// Collections are server-wide (visible to all users) but have an owner for rule context 8 | /// 9 | [Serializable] 10 | public class SmartCollectionDto : SmartListDto 11 | { 12 | public SmartCollectionDto() 13 | { 14 | Type = Core.Enums.SmartListType.Collection; 15 | } 16 | 17 | // Collection-specific properties 18 | public string? JellyfinCollectionId { get; set; } // Jellyfin collection (BoxSet) ID for reliable lookup 19 | } 20 | } 21 | 22 | -------------------------------------------------------------------------------- /docs/overrides/404.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |
5 |
6 |
7 |

😢

8 |

404 - Page Not Found

9 |

Sorry, the page you're looking for doesn't exist.

10 |

11 | 13 | Go to Homepage 14 | 15 |

16 |
17 |
18 |
19 | {% endblock %} -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Version** 27 | Which versions of Jellyfin and SmartLists you are using. 28 | 29 | **Additional context** 30 | Add any other context about the problem here. 31 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.SmartLists/Core/Models/OrderDto.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Text.Json.Serialization; 3 | 4 | namespace Jellyfin.Plugin.SmartLists.Core.Models 5 | { 6 | /// 7 | /// Represents the sorting configuration for a smart list 8 | /// Supports both legacy single Order format and new multiple SortOptions format 9 | /// 10 | public class OrderDto 11 | { 12 | // Legacy single order format (for backward compatibility) 13 | // Name is optional when SortOptions is used 14 | [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] 15 | public string? Name { get; set; } 16 | 17 | // New multiple sort options format 18 | [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] 19 | public List? SortOptions { get; set; } 20 | } 21 | } 22 | 23 | -------------------------------------------------------------------------------- /docs/content/extra.css: -------------------------------------------------------------------------------- 1 | /* Custom primary color to match logo */ 2 | :root, 3 | [data-md-color-scheme="default"] { 4 | --md-primary-fg-color: #322D42; 5 | --md-primary-fg-color--light: #4a3f5a; 6 | --md-primary-fg-color--dark: #1f1a2a; 7 | } 8 | 9 | [data-md-color-scheme="slate"] { 10 | --md-primary-fg-color: #322D42; 11 | --md-primary-fg-color--light: #4a3f5a; 12 | --md-primary-fg-color--dark: #1f1a2a; 13 | } 14 | 15 | /* Custom accent/hover color */ 16 | :root, 17 | [data-md-color-scheme="default"], 18 | [data-md-color-scheme="slate"] { 19 | --md-accent-fg-color: #53999B; 20 | --md-accent-fg-color--transparent: rgba(83, 153, 155, 0.1); 21 | } 22 | 23 | /* Link colors - use accent color for better visibility */ 24 | [data-md-color-scheme="default"] { 25 | --md-typeset-a-color: #53999B; 26 | } 27 | 28 | [data-md-color-scheme="slate"] { 29 | --md-typeset-a-color: #53999B; 30 | } -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Jellyfin SmartLists Plugin 2 | 3 | Thank you for wanting to contribute to the Jellyfin SmartLists Plugin! This document provides guidelines for contributing to this project. 4 | 5 | ## How to Contribute 6 | 7 | ### For Minor Changes 8 | 9 | 1. **Fork the repository** to your GitHub account 10 | 2. **Create a feature branch** from `main`: 11 | ```bash 12 | git checkout -b feature/your-feature-name 13 | ``` 14 | 3. **Make your changes** following the coding standards below 15 | 4. **Test your changes** - see the [documentation](https://jellyfin-smartlists-plugin.dinsten.se/development/building-locally/) 16 | 5. **Commit your changes** with clear, descriptive commit messages 17 | 6. **Push to your fork** and create a Pull Request to the `main` branch 18 | 7. **Wait for review** and address any feedback 19 | 20 | ### For Major Changes 21 | 22 | For significant features or architectural changes, **open an issue first** to discuss the proposed changes. -------------------------------------------------------------------------------- /Jellyfin.Plugin.SmartLists/Services/Abstractions/ISmartListStore.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Jellyfin.Plugin.SmartLists.Core.Models; 4 | 5 | namespace Jellyfin.Plugin.SmartLists.Services.Abstractions 6 | { 7 | /// 8 | /// Generic store interface for smart list persistence (Playlists and Collections) 9 | /// 10 | /// The DTO type (SmartPlaylistDto or SmartCollectionDto) 11 | public interface ISmartListStore where TDto : SmartListDto 12 | { 13 | /// 14 | /// Gets a smart list by ID 15 | /// 16 | Task GetByIdAsync(Guid id); 17 | 18 | /// 19 | /// Gets all smart lists of this type 20 | /// 21 | Task GetAllAsync(); 22 | 23 | /// 24 | /// Saves a smart list (creates or updates) 25 | /// 26 | Task SaveAsync(TDto dto); 27 | 28 | /// 29 | /// Deletes a smart list by ID 30 | /// 31 | Task DeleteAsync(Guid id); 32 | } 33 | } 34 | 35 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.SmartLists/Services/Shared/BasicDirectoryService.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using MediaBrowser.Controller.Providers; 3 | using MediaBrowser.Model.IO; 4 | 5 | namespace Jellyfin.Plugin.SmartLists.Services.Shared 6 | { 7 | /// 8 | /// Basic DirectoryService implementation for metadata refresh operations. 9 | /// Used by both playlist and collection services. 10 | /// 11 | public class BasicDirectoryService : IDirectoryService 12 | { 13 | public List GetDirectories(string path) => []; 14 | public List GetFiles(string path) => []; 15 | public FileSystemMetadata[] GetFileSystemEntries(string path) => []; 16 | public FileSystemMetadata? GetFile(string path) => null; 17 | public FileSystemMetadata? GetDirectory(string path) => null; 18 | public FileSystemMetadata? GetFileSystemEntry(string path) => null; 19 | public IReadOnlyList GetFilePaths(string path) => []; 20 | public IReadOnlyList GetFilePaths(string path, bool clearCache, bool sort) => []; 21 | public bool IsAccessible(string path) => false; 22 | } 23 | } -------------------------------------------------------------------------------- /dev/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | jellyfin: 3 | image: jellyfin/jellyfin:10.11.5 4 | container_name: jellyfin 5 | user: 1000:1000 6 | environment: 7 | TZ: "Europe/Stockholm" 8 | ports: 9 | - "8096:8096" # Map port 8096 on your Mac to port 8096 in the container. 10 | - "8920:8920" # HTTPS Web UI (optional) 11 | - "7359:7359/udp" # Client discovery (optional) 12 | - "1900:1900/udp" # DLNA (optional) 13 | volumes: 14 | - ./jellyfin-data/config:/config 15 | - ./jellyfin-data/cache:/cache 16 | - ./media/movies:/movies # You can place some movie files here for testing. 17 | - ./media/shows:/shows # You can place some tv/episode files here for testing. 18 | - ./media/musicvideos:/musicvideos # You can place some music video files here for testing. 19 | - ./media/music:/music # You can place some music files here for testing. 20 | - ./media/books:/books # You can place some books here for testing. 21 | - ./media/homevideosandphotos:/homevideosandphotos # You can place some home videos and photos here for testing. 22 | - ../build_output:/config/plugins/SmartLists # Mount the build output directly into the plugins directory. 23 | restart: "unless-stopped" -------------------------------------------------------------------------------- /Jellyfin.Plugin.SmartLists.sln: -------------------------------------------------------------------------------- 1 | Microsoft Visual Studio Solution File, Format Version 12.00 2 | # Visual Studio Version 16 3 | VisualStudioVersion = 16.0.29920.165 4 | MinimumVisualStudioVersion = 10.0.40219.1 5 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Plugin.SmartLists", "Jellyfin.Plugin.SmartLists\Jellyfin.Plugin.SmartLists.csproj", "{EFE3257F-C3D6-4D49-AF40-486CBABAF49C}" 6 | EndProject 7 | Global 8 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 9 | Debug|Any CPU = Debug|Any CPU 10 | Release|Any CPU = Release|Any CPU 11 | EndGlobalSection 12 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 13 | {EFE3257F-C3D6-4D49-AF40-486CBABAF49C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 14 | {EFE3257F-C3D6-4D49-AF40-486CBABAF49C}.Debug|Any CPU.Build.0 = Debug|Any CPU 15 | {EFE3257F-C3D6-4D49-AF40-486CBABAF49C}.Release|Any CPU.ActiveCfg = Release|Any CPU 16 | {EFE3257F-C3D6-4D49-AF40-486CBABAF49C}.Release|Any CPU.Build.0 = Release|Any CPU 17 | EndGlobalSection 18 | GlobalSection(SolutionProperties) = preSolution 19 | HideSolutionNode = FALSE 20 | EndGlobalSection 21 | GlobalSection(ExtensibilityGlobals) = postSolution 22 | SolutionGuid = {AB0CAC20-A449-45F3-B2A5-6B2E5FC02771} 23 | EndGlobalSection 24 | EndGlobal -------------------------------------------------------------------------------- /Jellyfin.Plugin.SmartLists/Core/Orders/DateCreatedOrder.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Jellyfin.Database.Implementations.Entities; 3 | using Jellyfin.Plugin.SmartLists.Services.Shared; 4 | using MediaBrowser.Controller.Entities; 5 | using MediaBrowser.Controller.Library; 6 | using Microsoft.Extensions.Logging; 7 | 8 | namespace Jellyfin.Plugin.SmartLists.Core.Orders 9 | { 10 | public class DateCreatedOrder : PropertyOrder 11 | { 12 | public override string Name => "DateCreated Ascending"; 13 | protected override bool IsDescending => false; 14 | protected override DateTime GetSortValue(BaseItem item, User? user = null, IUserDataManager? userDataManager = null, ILogger? logger = null, RefreshQueueService.RefreshCache? refreshCache = null) 15 | { 16 | ArgumentNullException.ThrowIfNull(item); 17 | return item.DateCreated; 18 | } 19 | } 20 | 21 | public class DateCreatedOrderDesc : PropertyOrder 22 | { 23 | public override string Name => "DateCreated Descending"; 24 | protected override bool IsDescending => true; 25 | protected override DateTime GetSortValue(BaseItem item, User? user = null, IUserDataManager? userDataManager = null, ILogger? logger = null, RefreshQueueService.RefreshCache? refreshCache = null) 26 | { 27 | ArgumentNullException.ThrowIfNull(item); 28 | return item.DateCreated; 29 | } 30 | } 31 | } 32 | 33 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.SmartLists/Core/Orders/ProductionYearOrder.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Jellyfin.Database.Implementations.Entities; 3 | using Jellyfin.Plugin.SmartLists.Services.Shared; 4 | using MediaBrowser.Controller.Entities; 5 | using MediaBrowser.Controller.Library; 6 | using Microsoft.Extensions.Logging; 7 | 8 | namespace Jellyfin.Plugin.SmartLists.Core.Orders 9 | { 10 | public class ProductionYearOrder : PropertyOrder 11 | { 12 | public override string Name => "ProductionYear Ascending"; 13 | protected override bool IsDescending => false; 14 | protected override int GetSortValue(BaseItem item, User? user = null, IUserDataManager? userDataManager = null, ILogger? logger = null, RefreshQueueService.RefreshCache? refreshCache = null) 15 | { 16 | ArgumentNullException.ThrowIfNull(item); 17 | return item.ProductionYear ?? 0; 18 | } 19 | } 20 | 21 | public class ProductionYearOrderDesc : PropertyOrder 22 | { 23 | public override string Name => "ProductionYear Descending"; 24 | protected override bool IsDescending => true; 25 | protected override int GetSortValue(BaseItem item, User? user = null, IUserDataManager? userDataManager = null, ILogger? logger = null, RefreshQueueService.RefreshCache? refreshCache = null) 26 | { 27 | ArgumentNullException.ThrowIfNull(item); 28 | return item.ProductionYear ?? 0; 29 | } 30 | } 31 | } 32 | 33 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.SmartLists/Core/Orders/CommunityRatingOrder.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Jellyfin.Database.Implementations.Entities; 3 | using Jellyfin.Plugin.SmartLists.Services.Shared; 4 | using MediaBrowser.Controller.Entities; 5 | using MediaBrowser.Controller.Library; 6 | using Microsoft.Extensions.Logging; 7 | 8 | namespace Jellyfin.Plugin.SmartLists.Core.Orders 9 | { 10 | public class CommunityRatingOrder : PropertyOrder 11 | { 12 | public override string Name => "CommunityRating Ascending"; 13 | protected override bool IsDescending => false; 14 | protected override float GetSortValue(BaseItem item, User? user = null, IUserDataManager? userDataManager = null, ILogger? logger = null, RefreshQueueService.RefreshCache? refreshCache = null) 15 | { 16 | ArgumentNullException.ThrowIfNull(item); 17 | return item.CommunityRating ?? 0; 18 | } 19 | } 20 | 21 | public class CommunityRatingOrderDesc : PropertyOrder 22 | { 23 | public override string Name => "CommunityRating Descending"; 24 | protected override bool IsDescending => true; 25 | protected override float GetSortValue(BaseItem item, User? user = null, IUserDataManager? userDataManager = null, ILogger? logger = null, RefreshQueueService.RefreshCache? refreshCache = null) 26 | { 27 | ArgumentNullException.ThrowIfNull(item); 28 | return item.CommunityRating ?? 0; 29 | } 30 | } 31 | } 32 | 33 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.SmartLists/Core/Orders/RuntimeOrder.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Jellyfin.Database.Implementations.Entities; 3 | using Jellyfin.Plugin.SmartLists.Services.Shared; 4 | using MediaBrowser.Controller.Entities; 5 | using MediaBrowser.Controller.Library; 6 | using Microsoft.Extensions.Logging; 7 | 8 | namespace Jellyfin.Plugin.SmartLists.Core.Orders 9 | { 10 | public class RuntimeOrder : PropertyOrder 11 | { 12 | public override string Name => "Runtime Ascending"; 13 | protected override bool IsDescending => false; 14 | 15 | protected override long GetSortValue(BaseItem item, User? user = null, IUserDataManager? userDataManager = null, ILogger? logger = null, RefreshQueueService.RefreshCache? refreshCache = null) 16 | { 17 | ArgumentNullException.ThrowIfNull(item); 18 | // Runtime is in ticks 19 | return item.RunTimeTicks ?? 0L; 20 | } 21 | } 22 | 23 | public class RuntimeOrderDesc : PropertyOrder 24 | { 25 | public override string Name => "Runtime Descending"; 26 | protected override bool IsDescending => true; 27 | 28 | protected override long GetSortValue(BaseItem item, User? user = null, IUserDataManager? userDataManager = null, ILogger? logger = null, RefreshQueueService.RefreshCache? refreshCache = null) 29 | { 30 | ArgumentNullException.ThrowIfNull(item); 31 | // Runtime is in ticks 32 | return item.RunTimeTicks ?? 0L; 33 | } 34 | } 35 | } 36 | 37 | -------------------------------------------------------------------------------- /09aa0d74-f29e-468e-aeb0-1495d5c7602f.json: -------------------------------------------------------------------------------- 1 | { 2 | "Public": true, 3 | "UserPlaylists": [ 4 | { 5 | "UserId": "3ea7c3e4af89436c93fa40b573743e2a", 6 | "JellyfinPlaylistId": "75027a4028c8ef6cc45fbd8a24a3c5d5" 7 | } 8 | ], 9 | "Type": "Playlist", 10 | "Id": "09aa0d74-f29e-468e-aeb0-1495d5c7602f", 11 | "Name": "Regex T Lunes", 12 | "FileName": "09aa0d74-f29e-468e-aeb0-1495d5c7602f.json", 13 | "ExpressionSets": [ 14 | { 15 | "Expressions": [ 16 | { 17 | "MemberName": "Name", 18 | "Operator": "MatchRegex", 19 | "TargetValue": "^T\\w*" 20 | }, 21 | { 22 | "MemberName": "IsPlayed", 23 | "Operator": "Equal", 24 | "TargetValue": "false", 25 | "UserId": "3ea7c3e4af89436c93fa40b573743e2a", 26 | "IsUserSpecific": true 27 | } 28 | ] 29 | } 30 | ], 31 | "Order": { 32 | "SortOptions": [ 33 | { 34 | "SortBy": "Random", 35 | "SortOrder": "Ascending" 36 | } 37 | ] 38 | }, 39 | "MediaTypes": [ 40 | "Audio" 41 | ], 42 | "Enabled": true, 43 | "MaxItems": 25, 44 | "MaxPlayTimeMinutes": 0, 45 | "AutoRefresh": "Never", 46 | "Schedules": [ 47 | { 48 | "Trigger": "Weekly", 49 | "Time": "03:30:00", 50 | "DayOfWeek": 1 51 | } 52 | ], 53 | "LastRefreshed": "2025-12-06T22:06:35.0472885Z", 54 | "DateCreated": "2025-10-04T19:30:11.3189234Z", 55 | "ItemCount": 25, 56 | "TotalRuntimeMinutes": 100.38360919666668, 57 | "SimilarityComparisonFields": [] 58 | } -------------------------------------------------------------------------------- /docs/content/getting-started/quick-start.md: -------------------------------------------------------------------------------- 1 | # Quick Start 2 | 3 | ## Creating Your First List 4 | 5 | 1. **Access Plugin Settings**: Go to Dashboard → My Plugins → SmartLists 6 | 2. **Navigate to Create List Tab**: Click on the "Create List" tab 7 | 3. **Configure Your List**: 8 | - Enter a name for your list 9 | - Choose whether to create a Playlist or Collection 10 | - Select the media type(s) you want to include 11 | - Add rules to filter your content 12 | - Choose sorting options 13 | - Set the list owner (for playlists) or reference user (for collections) 14 | - Configure other settings as needed 15 | 16 | !!! tip "Playlists vs Collections" 17 | For a detailed explanation of the differences between Playlists and Collections, see the [Configuration Guide](../user-guide/configuration.md#playlists-vs-collections). 18 | 19 | ## Example: Unwatched Action Movies 20 | 21 | Here's a simple example to get you started: 22 | 23 | **List Name**: "Unwatched Action Movies" 24 | 25 | **List Type**: Playlist 26 | 27 | **Media Type**: Movie 28 | 29 | **Rules**: 30 | - Genre contains "Action" 31 | - Playback Status = Unplayed 32 | 33 | **Sort Order**: Production Year (Descending) 34 | 35 | **Max Items**: 100 36 | 37 | This will create a playlist of up to 100 unwatched action movies, sorted by production year with the newest first. 38 | 39 | ## Next Steps 40 | 41 | - Learn about [Configuration](../user-guide/configuration.md) options 42 | - Explore [Fields and Operators](../user-guide/fields-and-operators.md) for more complex rules 43 | - Check out [Common Use Cases](../examples/common-use-cases.md) for inspiration 44 | 45 | -------------------------------------------------------------------------------- /dev/build-local.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # This script builds the SmartLists plugin and prepares it for local Docker-based testing. 4 | # It will also restart the Jellyfin Docker container to apply the changes. 5 | 6 | set -e # Exit immediately if a command exits with a non-zero status. 7 | 8 | # Set the version for the build. For local testing, this can be a static string. 9 | VERSION="10.11.0.0" 10 | OUTPUT_DIR="../build_output" 11 | 12 | echo "Building SmartLists plugin version for local development..." 13 | 14 | # Clean the previous build output 15 | rm -rf $OUTPUT_DIR 16 | mkdir -p $OUTPUT_DIR 17 | 18 | # Build the project 19 | dotnet build ../Jellyfin.Plugin.SmartLists/Jellyfin.Plugin.SmartLists.csproj --configuration Release -o $OUTPUT_DIR /p:Version=$VERSION /p:AssemblyVersion=$VERSION 20 | 21 | # Copy the dev meta.json file, as it's required by Jellyfin to load the plugin 22 | cp meta-dev.json $OUTPUT_DIR/meta.json 23 | 24 | # Copy the logo image for local plugin display 25 | cp ../images/logo.jpg $OUTPUT_DIR/logo.jpg 26 | 27 | # Create the Configuration directory and copy the logging file for debug logs 28 | mkdir -p $OUTPUT_DIR/Configuration 29 | mkdir -p jellyfin-data/config/config 30 | cp logging.json jellyfin-data/config/config/logging.json 31 | 32 | echo "" 33 | echo "Build complete." 34 | echo "Restarting Jellyfin container to apply changes..." 35 | 36 | # Stop the existing container (if any) and start a new one with the updated plugin files. 37 | docker compose down 38 | docker container prune -f 39 | docker compose up --detach 40 | 41 | echo "" 42 | echo "Jellyfin container is up and running." 43 | echo "You can access it at: http://localhost:8096" -------------------------------------------------------------------------------- /Jellyfin.Plugin.SmartLists/Core/Models/SmartPlaylistDto.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text.Json.Serialization; 4 | 5 | namespace Jellyfin.Plugin.SmartLists.Core.Models 6 | { 7 | /// 8 | /// DTO for user-specific smart playlists 9 | /// 10 | [Serializable] 11 | public class SmartPlaylistDto : SmartListDto 12 | { 13 | public SmartPlaylistDto() 14 | { 15 | Type = Core.Enums.SmartListType.Playlist; 16 | } 17 | 18 | // Playlist-specific properties 19 | [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] 20 | public string? JellyfinPlaylistId { get; set; } // Jellyfin playlist ID for reliable lookup (backwards compatibility - first user's playlist) 21 | public bool Public { get; set; } = false; // Default to private 22 | 23 | /// 24 | /// Multi-user playlist support: Array of user-playlist mappings. 25 | /// When multiple users are selected, one Jellyfin playlist is created per user. 26 | /// 27 | [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] 28 | public List? UserPlaylists { get; set; } 29 | 30 | /// 31 | /// Mapping between a user ID and their associated Jellyfin playlist ID 32 | /// 33 | [Serializable] 34 | public class UserPlaylistMapping 35 | { 36 | public required string UserId { get; set; } 37 | [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] 38 | public string? JellyfinPlaylistId { get; set; } 39 | } 40 | } 41 | } 42 | 43 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.SmartLists/Core/Orders/Order.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using Jellyfin.Database.Implementations.Entities; 4 | using Jellyfin.Plugin.SmartLists.Services.Shared; 5 | using MediaBrowser.Controller.Entities; 6 | using MediaBrowser.Controller.Library; 7 | using Microsoft.Extensions.Logging; 8 | 9 | namespace Jellyfin.Plugin.SmartLists.Core.Orders 10 | { 11 | public abstract class Order 12 | { 13 | public abstract string Name { get; } 14 | 15 | public virtual IEnumerable OrderBy(IEnumerable items) 16 | { 17 | return items ?? []; 18 | } 19 | 20 | public virtual IEnumerable OrderBy(IEnumerable items, User user, IUserDataManager? userDataManager, ILogger? logger, RefreshQueueService.RefreshCache? refreshCache = null) 21 | { 22 | // Default implementation falls back to the simple OrderBy method 23 | return OrderBy(items); 24 | } 25 | 26 | /// 27 | /// Creates a comparable sort key for an item. This method is used for multi-sort scenarios 28 | /// and should return the same value that would be used for sorting in single-sort scenarios. 29 | /// 30 | public virtual IComparable GetSortKey( 31 | BaseItem item, 32 | User user, 33 | IUserDataManager? userDataManager, 34 | ILogger? logger, 35 | Dictionary? itemRandomKeys = null, 36 | RefreshQueueService.RefreshCache? refreshCache = null) 37 | { 38 | // Default implementation returns name as fallback 39 | return item.Name ?? ""; 40 | } 41 | } 42 | } 43 | 44 | -------------------------------------------------------------------------------- /.github/workflows/stale-questions.yml: -------------------------------------------------------------------------------- 1 | # .github/workflows/stale-questions.yml 2 | name: Close inactive questions 3 | 4 | on: 5 | schedule: 6 | - cron: "0 03 * * *" # runs daily at 03:00 UTC 7 | workflow_dispatch: {} # let maintainers run it on demand 8 | 9 | permissions: 10 | issues: write 11 | pull-requests: write 12 | 13 | jobs: 14 | stale: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/stale@v9 18 | with: 19 | # Consider only issues labeled "insufficient data" 20 | only-labels: insufficient data 21 | 22 | # Timing 23 | days-before-issue-stale: 14 # mark as stale after 14 days of no activity 24 | days-before-issue-close: 7 # close 7 days after being marked stale 25 | 26 | # What to do when marking as stale 27 | stale-issue-label: stale 28 | stale-issue-message: > 29 | This issue has been unanswered for 14 days. We mark issues like this 30 | as stale to help keep the tracker tidy. If this is still relevant, 31 | please add a comment and it will be kept open. 32 | 33 | # What to do when closing 34 | close-issue-message: > 35 | Closing due to inactivity. If you’re still seeing this problem, 36 | comment with an update. 37 | 38 | # Nice-to-haves / safeguards 39 | exempt-issue-labels: pinned,security,keep-open 40 | exempt-milestones: true # don't stale anything in an active milestone 41 | exempt-assignees: true # don't stale if someone is actively assigned 42 | remove-stale-when-updated: true # remove "stale" label on any activity 43 | enable-statistics: true -------------------------------------------------------------------------------- /Jellyfin.Plugin.SmartLists/Configuration/config-multi-select.css: -------------------------------------------------------------------------------- 1 | /* Multi-select component styling - centralized for reuse */ 2 | 3 | .multi-select-container { 4 | position: relative; 5 | } 6 | 7 | .multi-select-display { 8 | padding: 0.5em; 9 | background-color: #2A2A2A; 10 | min-height: 1.7em; 11 | display: flex; 12 | align-items: center; 13 | border-radius: 3px; 14 | cursor: default; 15 | } 16 | 17 | .multi-select-placeholder { 18 | color: #999; 19 | font-size: 110%; 20 | cursor: default; 21 | } 22 | 23 | .multi-select-selected-items { 24 | color: #e0e0e0; 25 | font-size: 110%; 26 | cursor: default; 27 | } 28 | 29 | .multi-select-dropdown { 30 | position: absolute; 31 | left: 0; 32 | right: 0; 33 | max-height: 400px; 34 | overflow-y: auto; 35 | background-color: rgba(42, 42, 42, 0.95); 36 | border: 1px solid #7a7a7a; 37 | z-index: 1000; 38 | border-radius: 1em; 39 | backdrop-filter: blur(2px); 40 | } 41 | 42 | .multi-select-options { 43 | padding: 0.5em; 44 | } 45 | 46 | .multi-select-option { 47 | font-size: 110%; 48 | padding-left: 0.1em; 49 | } 50 | 51 | .multi-select-option:hover { 52 | background-color: #2A56B9; 53 | border-radius: 0.5em; 54 | } 55 | 56 | .multi-select-option .emby-checkbox-label { 57 | width: 100%; 58 | padding: 0.3em 1em; 59 | cursor: pointer; 60 | height: auto; 61 | } 62 | 63 | .multi-select-option .checkboxLabel { 64 | margin-left: 1.2em; 65 | } 66 | 67 | .multi-select-option .checkboxOutline { 68 | transform: scale(0.80); 69 | margin-top: -2px; 70 | } 71 | 72 | .multi-select-arrow { 73 | margin-left: auto; 74 | margin-right: 1.1em; 75 | font-size: 0.5em; 76 | color: #E0E0E0; 77 | } -------------------------------------------------------------------------------- /docs/content/getting-started/installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | ## From Repository 4 | 5 | 1. **Add the Repository**: 6 | - Go to **Dashboard** → **Plugins** → **Repositories** (or **Plugins** → **Manage Repositories** → **New Repository**) 7 | - Click **New Repository** (or the **+** button) 8 | - Enter the repository URL: 9 | ``` 10 | https://raw.githubusercontent.com/jyourstone/jellyfin-plugin-manifest/main/manifest.json 11 | ``` 12 | - Click **Save** 13 | 14 | 2. **Install the Plugin**: 15 | - Go to **Dashboard** → **Plugins** → **All/Available** 16 | - Click **SmartLists** in the list of available plugins 17 | - Click **Install** 18 | - Restart Jellyfin 19 | 20 | ## Manual Installation 21 | 22 | Download the latest release from the [Releases page](https://github.com/jyourstone/jellyfin-smartlists-plugin/releases) and extract it to a subfolder in your Jellyfin plugins directory (for example `/config/plugins/SmartLists`) and restart Jellyfin. 23 | 24 | ## Try RC Releases (Unstable) 25 | 26 | Want to test the latest features before they're officially released? You can try release candidate (RC) versions using the unstable manifest: 27 | 28 | ``` 29 | https://raw.githubusercontent.com/jyourstone/jellyfin-plugin-manifest/unstable/manifest.json 30 | ``` 31 | 32 | !!! warning "RC Releases" 33 | RC releases are pre-release versions that may contain bugs or incomplete features. Use at your own risk and consider backing up your smart list configurations before upgrading. 34 | 35 | !!! tip "Dashboard Theme Recommendation" 36 | This plugin is best used with the **Dark** dashboard theme in Jellyfin. The plugin's custom styling is designed to match the dark theme, providing the best visual experience and consistency with the Jellyfin interface. -------------------------------------------------------------------------------- /Jellyfin.Plugin.SmartLists/Core/Orders/AlbumNameOrder.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using Jellyfin.Database.Implementations.Entities; 4 | using Jellyfin.Plugin.SmartLists.Core; 5 | using Jellyfin.Plugin.SmartLists.Services.Shared; 6 | using MediaBrowser.Controller.Entities; 7 | using MediaBrowser.Controller.Library; 8 | using Microsoft.Extensions.Logging; 9 | 10 | namespace Jellyfin.Plugin.SmartLists.Core.Orders 11 | { 12 | public class AlbumNameOrder : PropertyOrder 13 | { 14 | public override string Name => "AlbumName Ascending"; 15 | protected override bool IsDescending => false; 16 | protected override IComparer Comparer => OrderUtilities.SharedNaturalComparer; 17 | 18 | protected override string GetSortValue(BaseItem item, User? user = null, IUserDataManager? userDataManager = null, ILogger? logger = null, RefreshQueueService.RefreshCache? refreshCache = null) 19 | { 20 | ArgumentNullException.ThrowIfNull(item); 21 | // Album is already a property on BaseItem 22 | return item.Album ?? ""; 23 | } 24 | } 25 | 26 | public class AlbumNameOrderDesc : PropertyOrder 27 | { 28 | public override string Name => "AlbumName Descending"; 29 | protected override bool IsDescending => true; 30 | protected override IComparer Comparer => OrderUtilities.SharedNaturalComparer; 31 | 32 | protected override string GetSortValue(BaseItem item, User? user = null, IUserDataManager? userDataManager = null, ILogger? logger = null, RefreshQueueService.RefreshCache? refreshCache = null) 33 | { 34 | ArgumentNullException.ThrowIfNull(item); 35 | // Album is already a property on BaseItem 36 | return item.Album ?? ""; 37 | } 38 | } 39 | } 40 | 41 | -------------------------------------------------------------------------------- /dev/build-local.ps1: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env pwsh 2 | 3 | # This script builds the SmartLists plugin and prepares it for local Docker-based testing. 4 | # It will also restart the Jellyfin Docker container to apply the changes. 5 | 6 | $ErrorActionPreference = "Stop" # Exit immediately if a command fails 7 | 8 | # Set the version for the build. For local testing, this can be a static string. 9 | $VERSION = "10.11.0.0" 10 | $OUTPUT_DIR = "..\build_output" 11 | 12 | Write-Host "Building SmartLists plugin version for local development..." 13 | 14 | # Clean the previous build output 15 | if (Test-Path $OUTPUT_DIR) { 16 | Remove-Item -Path $OUTPUT_DIR -Recurse -Force 17 | } 18 | New-Item -ItemType Directory -Path $OUTPUT_DIR -Force | Out-Null 19 | 20 | # Build the project 21 | dotnet build ..\Jellyfin.Plugin.SmartLists\Jellyfin.Plugin.SmartLists.csproj --configuration Release -o $OUTPUT_DIR /p:Version=$VERSION /p:AssemblyVersion=$VERSION 22 | 23 | # Copy the dev meta.json file, as it's required by Jellyfin to load the plugin 24 | Copy-Item -Path "meta-dev.json" -Destination "$OUTPUT_DIR\meta.json" 25 | 26 | # Copy the logo image for local plugin display 27 | Copy-Item -Path "..\images\logo.jpg" -Destination "$OUTPUT_DIR\logo.jpg" 28 | 29 | # Create the Configuration directory and copy the logging file for debug logs 30 | New-Item -ItemType Directory -Path "$OUTPUT_DIR\Configuration" -Force | Out-Null 31 | Copy-Item -Path "logging.json" -Destination "jellyfin-data\config\config\logging.json" 32 | 33 | Write-Host "" 34 | Write-Host "Build complete." 35 | Write-Host "Restarting Jellyfin container to apply changes..." 36 | 37 | # Stop the existing container (if any) and start a new one with the updated plugin files. 38 | docker compose down 39 | docker container prune -f 40 | docker compose up --detach 41 | 42 | Write-Host "" 43 | Write-Host "Jellyfin container is up and running." 44 | Write-Host "You can access it at: http://localhost:8096" -------------------------------------------------------------------------------- /Jellyfin.Plugin.SmartLists/Core/QueryEngine/DateUtils.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using MediaBrowser.Controller.Entities; 3 | 4 | namespace Jellyfin.Plugin.SmartLists.Core.QueryEngine 5 | { 6 | public static class DateUtils 7 | { 8 | /// 9 | /// Extracts the PremiereDate property from a BaseItem and returns its Unix timestamp, or 0 on error. 10 | /// Treats the PremiereDate as UTC to ensure consistency with user-input date handling. 11 | /// 12 | /// The BaseItem to extract the release date from. Must not be null. 13 | /// Unix timestamp of the release date, or 0 if the date is not available or invalid. 14 | /// Thrown when item is null. 15 | public static double GetReleaseDateUnixTimestamp(BaseItem item) 16 | { 17 | ArgumentNullException.ThrowIfNull(item); 18 | 19 | try 20 | { 21 | var premiereDateProperty = item.GetType().GetProperty("PremiereDate"); 22 | if (premiereDateProperty != null) 23 | { 24 | var premiereDate = premiereDateProperty.GetValue(item); 25 | if (premiereDate is DateTime premiereDateTime && premiereDateTime != DateTime.MinValue) 26 | { 27 | // Treat the PremiereDate as UTC to ensure consistency with user-input date handling 28 | // This assumes Jellyfin stores dates in UTC, which is the typical behavior 29 | return new DateTimeOffset(premiereDateTime, TimeSpan.Zero).ToUnixTimeSeconds(); 30 | } 31 | } 32 | } 33 | catch 34 | { 35 | // Ignore errors and fall back to 0 36 | } 37 | return 0; 38 | } 39 | } 40 | } -------------------------------------------------------------------------------- /Jellyfin.Plugin.SmartLists/Core/Models/Schedule.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text.Json.Serialization; 3 | using Jellyfin.Plugin.SmartLists.Core.Enums; 4 | 5 | namespace Jellyfin.Plugin.SmartLists.Core.Models 6 | { 7 | /// 8 | /// Represents a single schedule configuration for a smart list. 9 | /// Supports multiple schedules per list for flexible scheduling. 10 | /// 11 | [Serializable] 12 | public class Schedule 13 | { 14 | /// 15 | /// The type of schedule trigger (Daily, Weekly, Monthly, Yearly, Interval) 16 | /// 17 | public ScheduleTrigger Trigger { get; set; } 18 | 19 | /// 20 | /// Time of day for Daily/Weekly/Monthly/Yearly schedules (e.g., 15:00) 21 | /// 22 | [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] 23 | public TimeSpan? Time { get; set; } 24 | 25 | /// 26 | /// Day of week for Weekly schedules (0 = Sunday, 6 = Saturday) 27 | /// Serialized as integer for consistency with legacy format and UI expectations 28 | /// 29 | [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] 30 | [JsonConverter(typeof(DayOfWeekAsIntegerConverter))] 31 | public DayOfWeek? DayOfWeek { get; set; } 32 | 33 | /// 34 | /// Day of month for Monthly/Yearly schedules (1-31) 35 | /// 36 | [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] 37 | public int? DayOfMonth { get; set; } 38 | 39 | /// 40 | /// Month for Yearly schedules (1 = January, 12 = December) 41 | /// 42 | [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] 43 | public int? Month { get; set; } 44 | 45 | /// 46 | /// Interval for Interval-based schedules (e.g., 2 hours) 47 | /// 48 | [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] 49 | public TimeSpan? Interval { get; set; } 50 | } 51 | } 52 | 53 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.SmartLists/Utilities/RuntimeCalculator.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using Jellyfin.Plugin.SmartLists.Core.Models; 5 | using MediaBrowser.Controller.Entities; 6 | using Microsoft.Extensions.Logging; 7 | 8 | namespace Jellyfin.Plugin.SmartLists.Utilities 9 | { 10 | /// 11 | /// Utility class for calculating runtime statistics for smart lists. 12 | /// 13 | public static class RuntimeCalculator 14 | { 15 | /// 16 | /// Calculates the total runtime in minutes for all items in a list. 17 | /// 18 | /// Array of item GUIDs 19 | /// Dictionary mapping item GUIDs to BaseItem objects 20 | /// Logger for diagnostics 21 | /// Total runtime in minutes, or null if no items have runtime information 22 | public static double? CalculateTotalRuntimeMinutes(Guid[] itemIds, Dictionary mediaLookup, ILogger? logger = null) 23 | { 24 | ArgumentNullException.ThrowIfNull(itemIds); 25 | ArgumentNullException.ThrowIfNull(mediaLookup); 26 | 27 | double totalMinutes = 0.0; 28 | int itemsWithRuntime = 0; 29 | 30 | foreach (var itemId in itemIds) 31 | { 32 | if (mediaLookup.TryGetValue(itemId, out var item)) 33 | { 34 | if (item.RunTimeTicks.HasValue) 35 | { 36 | var itemMinutes = TimeSpan.FromTicks(item.RunTimeTicks.Value).TotalMinutes; 37 | totalMinutes += itemMinutes; 38 | itemsWithRuntime++; 39 | } 40 | } 41 | } 42 | 43 | // Only return runtime if at least one item has runtime information 44 | if (itemsWithRuntime > 0) 45 | { 46 | return totalMinutes; 47 | } 48 | 49 | return null; 50 | } 51 | } 52 | } 53 | 54 | -------------------------------------------------------------------------------- /docs/content/examples/advanced-examples.md: -------------------------------------------------------------------------------- 1 | # Advanced Examples 2 | 3 | Here are some more complex playlist and collection examples: 4 | 5 | ## Weekend Binge Queue 6 | - **Next Unwatched** = True (excluding unwatched series) for started shows only 7 | 8 | ## Kids' Shows Progress 9 | - **Next Unwatched** = True AND **Tags** contain "Kids" (with parent series tags enabled) 10 | 11 | ## Foreign Language Practice 12 | - **Audio Languages** match `(?i)(ger|fra|spa)` AND **Playback Status** = Unplayed 13 | 14 | ## Tagged Series Marathon 15 | - **Tags** is in "Drama;Thriller" (with parent series tags enabled) AND **Runtime** < 50 minutes 16 | 17 | ## High-Quality FLAC Music 18 | - **Audio Codec** = "FLAC" AND **Audio Bit Depth** >= 24 AND **Audio Sample Rate** >= 96000 19 | 20 | ## Lossless Audio Collection 21 | - **Audio Codec** is in "FLAC;ALAC" (lossless formats) 22 | 23 | ## High Bitrate Music 24 | - **Audio Bitrate** >= 320 (high-quality MP3 or lossless) 25 | 26 | ## Surround Sound Movies 27 | - **Audio Channels** >= 6 (5.1 or higher) 28 | 29 | ## Dynamic Playlist from User Favorites 30 | - **Playlists** contains "favorites" (match any user-created favorites playlists) 31 | - **Playback Status** = Unplayed (combine with unplayed filter) 32 | - Creates a dynamic playlist that pulls unplayed items from any existing favorites playlists 33 | - Automatically updates as playlists are added/removed or items are marked as played 34 | 35 | ## Genre-Based Playlist Mixer 36 | - **Rule Group 1**: **Playlists** contains "Rock" AND **Community Rating** >= 8 37 | - **Rule Group 2**: **Playlists** contains "Electronic" AND **Play Count** = 0 38 | - Combines highly-rated rock tracks with unplayed electronic music 39 | - Uses OR logic between rule groups to pull from multiple playlist categories 40 | 41 | ## Smart Collection of Music Playlists 42 | - **Playlists** matches regex `(?i)(workout|running|gym)` with "Include playlist only" enabled 43 | - **List Type**: Collection 44 | - Creates a collection that contains your exercise-related playlist objects 45 | - Great for organizing themed playlists without duplicating content 46 | - Use regex for flexible pattern matching (e.g., case-insensitive matching of multiple keywords) -------------------------------------------------------------------------------- /Jellyfin.Plugin.SmartLists/Utilities/LibraryManagerHelper.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Reflection; 3 | using MediaBrowser.Controller.Library; 4 | using Microsoft.Extensions.Logging; 5 | 6 | namespace Jellyfin.Plugin.SmartLists.Utilities 7 | { 8 | /// 9 | /// Utility class for common ILibraryManager operations using reflection. 10 | /// 11 | public static class LibraryManagerHelper 12 | { 13 | /// 14 | /// Triggers a library scan using reflection to call QueueLibraryScan if available. 15 | /// 16 | /// The library manager instance 17 | /// Optional logger for diagnostics 18 | /// True if the scan was successfully queued, false otherwise 19 | public static bool QueueLibraryScan(ILibraryManager libraryManager, ILogger? logger = null) 20 | { 21 | ArgumentNullException.ThrowIfNull(libraryManager); 22 | 23 | try 24 | { 25 | logger?.LogDebug("Triggering library scan"); 26 | var queueScanMethod = libraryManager.GetType().GetMethod("QueueLibraryScan"); 27 | if (queueScanMethod != null) 28 | { 29 | queueScanMethod.Invoke(libraryManager, null); 30 | logger?.LogDebug("Queued library scan"); 31 | return true; 32 | } 33 | else 34 | { 35 | logger?.LogWarning("QueueLibraryScan method not found on ILibraryManager"); 36 | return false; 37 | } 38 | } 39 | catch (TargetInvocationException ex) 40 | { 41 | // Unwrap TargetInvocationException to get the actual inner exception 42 | logger?.LogWarning(ex.InnerException ?? ex, "Failed to trigger library scan"); 43 | return false; 44 | } 45 | catch (Exception ex) 46 | { 47 | logger?.LogWarning(ex, "Failed to trigger library scan"); 48 | return false; 49 | } 50 | } 51 | } 52 | } 53 | 54 | -------------------------------------------------------------------------------- /docs/mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Jellyfin SmartLists Plugin 2 | site_description: Create smart, rule-based playlists and collections in Jellyfin. 3 | site_url: https://github.com/jyourstone/jellyfin-smartlists-plugin 4 | 5 | repo_name: jyourstone/jellyfin-smartlists-plugin 6 | repo_url: https://github.com/jyourstone/jellyfin-smartlists-plugin 7 | edit_uri: edit/main/docs/content/ 8 | 9 | docs_dir: content 10 | 11 | theme: 12 | name: material 13 | custom_dir: overrides 14 | palette: 15 | - scheme: slate 16 | primary: teal 17 | toggle: 18 | icon: material/brightness-4 19 | name: Switch to light mode 20 | - scheme: default 21 | primary: teal 22 | toggle: 23 | icon: material/brightness-7 24 | name: Switch to dark mode 25 | features: 26 | - navigation.tabs 27 | - navigation.sections 28 | - navigation.expand 29 | - navigation.top 30 | - search.suggest 31 | - search.highlight 32 | 33 | nav: 34 | - Home: index.md 35 | - Getting Started: 36 | - Installation: getting-started/installation.md 37 | - Quick Start: getting-started/quick-start.md 38 | - User Guide: 39 | - Configuration: user-guide/configuration.md 40 | - Media Types: user-guide/media-types.md 41 | - User Selection: user-guide/user-selection.md 42 | - Fields and Operators: user-guide/fields-and-operators.md 43 | - Sorting and Limits: user-guide/sorting-and-limits.md 44 | - Auto-Refresh: user-guide/auto-refresh.md 45 | - Advanced Configuration: user-guide/advanced-configuration.md 46 | - Examples: 47 | - Common Use Cases: examples/common-use-cases.md 48 | - Advanced Examples: examples/advanced-examples.md 49 | - Development: 50 | - Building Locally: development/building-locally.md 51 | - Debugging: development/debugging.md 52 | - Contributing: development/contributing.md 53 | 54 | markdown_extensions: 55 | - pymdownx.highlight: 56 | anchor_linenums: true 57 | - pymdownx.inlinehilite 58 | - pymdownx.snippets 59 | - pymdownx.superfences: 60 | custom_fences: 61 | - name: mermaid 62 | class: mermaid 63 | format: !!python/name:pymdownx.superfences.fence_code_format 64 | - admonition 65 | - pymdownx.details 66 | - pymdownx.tasklist: 67 | custom_checkbox: true 68 | - attr_list 69 | - md_in_html 70 | 71 | extra_css: 72 | - extra.css -------------------------------------------------------------------------------- /docs/content/development/contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thank you for your interest in contributing to the SmartLists plugin! This guide will help you get started. 4 | 5 | ## Prerequisites 6 | 7 | Before contributing, make sure you: 8 | 9 | - Have set up the [local development environment](building-locally.md) 10 | - Are familiar with Git and GitHub 11 | - Have tested your changes locally 12 | 13 | ## How to Contribute 14 | 15 | ### 1. Fork the Repository 16 | 17 | 1. Go to the [repository page](https://github.com/jyourstone/jellyfin-smartlists-plugin) 18 | 2. Click the **Fork** button in the top right 19 | 3. This creates a copy of the repository in your GitHub account 20 | 21 | ### 2. Clone Your Fork 22 | 23 | ```bash 24 | git clone https://github.com/YOUR_USERNAME/jellyfin-smartlists-plugin.git 25 | cd jellyfin-smartlists-plugin 26 | ``` 27 | 28 | ### 3. Make Your Changes 29 | 30 | 1. Create a new branch for your changes: 31 | ```bash 32 | git checkout -b your-feature-name 33 | ``` 34 | 35 | 2. Make your changes to the codebase 36 | 3. Test your changes using the local development environment (see [Building Locally](building-locally.md)) 37 | 4. Commit your changes: 38 | ```bash 39 | git add . 40 | git commit -m "Description of your changes" 41 | ``` 42 | 43 | ### 4. Push and Create a Pull Request 44 | 45 | 1. Push your branch to your fork: 46 | ```bash 47 | git push origin your-feature-name 48 | ``` 49 | 50 | 2. Go to your fork on GitHub 51 | 3. Click **Contribute** → **Open Pull Request** 52 | 4. Select your branch and create a pull request to the `main` branch of the original repository 53 | 5. Fill out the pull request description explaining your changes 54 | 55 | Your pull request will be reviewed, and once approved, it will be merged into the main branch. 56 | 57 | ## What to Contribute 58 | 59 | All contributions are welcome: 60 | 61 | - **Bug fixes** - Report and fix issues you encounter 62 | - **New features** - Add functionality that would benefit users 63 | - **Documentation** - Improve or expand the documentation 64 | - **Code improvements** - Refactor, optimize, or improve existing code 65 | - **Testing** - Add tests or improve test coverage 66 | 67 | ## Code Guidelines 68 | 69 | - Follow existing code style and patterns 70 | - Write clear, descriptive commit messages 71 | - Test your changes thoroughly before submitting 72 | - Update documentation if you add new features or change behavior -------------------------------------------------------------------------------- /Jellyfin.Plugin.SmartLists/Core/Orders/ComparableTuple4.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | 4 | namespace Jellyfin.Plugin.SmartLists.Core.Orders 5 | { 6 | /// 7 | /// A comparable tuple for composite sort keys with 4 elements. 8 | /// Used for complex multi-level sorting. 9 | /// 10 | internal sealed class ComparableTuple4 : IComparable 11 | where T1 : IComparable 12 | where T2 : IComparable 13 | where T3 : IComparable 14 | where T4 : IComparable 15 | { 16 | private readonly T1 _item1; 17 | private readonly T2 _item2; 18 | private readonly T3 _item3; 19 | private readonly T4 _item4; 20 | private readonly IComparer _comparer1; 21 | private readonly IComparer _comparer2; 22 | private readonly IComparer _comparer3; 23 | private readonly IComparer _comparer4; 24 | 25 | public ComparableTuple4(T1 item1, T2 item2, T3 item3, T4 item4, 26 | IComparer? comparer1 = null, 27 | IComparer? comparer2 = null, 28 | IComparer? comparer3 = null, 29 | IComparer? comparer4 = null) 30 | { 31 | _item1 = item1; 32 | _item2 = item2; 33 | _item3 = item3; 34 | _item4 = item4; 35 | _comparer1 = comparer1 ?? Comparer.Default; 36 | _comparer2 = comparer2 ?? Comparer.Default; 37 | _comparer3 = comparer3 ?? Comparer.Default; 38 | _comparer4 = comparer4 ?? Comparer.Default; 39 | } 40 | 41 | public int CompareTo(object? obj) 42 | { 43 | if (obj is null) return 1; 44 | if (obj is not ComparableTuple4 other) 45 | throw new ArgumentException($"Object must be of type {typeof(ComparableTuple4).Name}", nameof(obj)); 46 | 47 | // Compare each level in order, returning if there's a difference 48 | var cmp1 = _comparer1.Compare(_item1, other._item1); 49 | if (cmp1 != 0) return cmp1; 50 | 51 | var cmp2 = _comparer2.Compare(_item2, other._item2); 52 | if (cmp2 != 0) return cmp2; 53 | 54 | var cmp3 = _comparer3.Compare(_item3, other._item3); 55 | if (cmp3 != 0) return cmp3; 56 | 57 | return _comparer4.Compare(_item4, other._item4); 58 | } 59 | } 60 | } 61 | 62 | -------------------------------------------------------------------------------- /.cursor/rules/html.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: *.html 4 | alwaysApply: false 5 | --- 6 | ### 1. JavaScript Environment Limitations 7 | 8 | The Jellyfin plugin configuration page has a specific JavaScript environment that must be respected. 9 | 10 | - **No Template Literals:** Do not use ES6 template literals (backticks `` ` ``) for string formatting. This environment does not support them. All dynamic strings **must** be constructed using traditional string concatenation with the `+` operator. 11 | 12 | - **Use Inline Notifications:** Jellyfin's `Dashboard.alert()` function is unreliable and fails silently. Standard browser `alert()` is also discouraged as it is intrusive. All user-facing messages (both success and error) **must** be displayed using the project's custom `showNotification(message, type)` function. 13 | 14 | ### 2. API Error Handling 15 | 16 | When handling API errors, remember that the server returns error messages in a specific format. 17 | 18 | - A `400 Bad Request` from the backend API will contain a **JSON-encoded string** in the response body. This is *not* a JSON object. You must call `.text()` on the `Response` object and then use `JSON.parse()` on the resulting string to get the clean error message. Forgetting to parse the JSON string will result in a message wrapped in quotes being displayed. 19 | 20 | ### 3. UI Styling and CSS 21 | 22 | To maintain a consistent and professional appearance, all UI elements must use Jellyfin's standard CSS classes. 23 | 24 | - Avoid using custom inline styles for layout and component styling. Instead, use Jellyfin's predefined classes like `inputContainer`, `inputLabel` `sectionTitle`, `checkboxLabel`, `fieldDescription`, `emby-button`, and `raised` to ensure the plugin's UI matches the native Jellyfin look and feel. 25 | 26 | ### 4. Jellyfin Custom Element System - CRITICAL 27 | 28 | **NEVER use `is="emby-input"` on input elements.** This is a critical issue that causes `Cannot set properties of undefined (setting 'htmlFor')` errors and breaks the entire page initialization. 29 | 30 | #### The Problem 31 | 32 | Jellyfin's custom element system has timing issues when `is="emby-input"` is used on input elements during page initialization. This causes: 33 | - Console errors: `Cannot set properties of undefined (setting 'htmlFor')` 34 | - Checkboxes failing to display on initial page load 35 | - Custom element upgrade failures 36 | - Complete page functionality breakdown 37 | 38 | #### The Solution 39 | 40 | **Do NOT use `is="emby-input"` for styling input elements:** 41 | 42 | ```html 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | ``` 51 | -------------------------------------------------------------------------------- /docs/content/development/debugging.md: -------------------------------------------------------------------------------- 1 | # Debugging 2 | 3 | Enable debug logging for the SmartLists plugin to troubleshoot issues or get detailed information about playlist operations. 4 | 5 | ## Enable Debug Logging 6 | 7 | To enable debug logging specifically for the SmartLists plugin: 8 | 9 | 1. **Create a logging configuration file** in your Jellyfin config directory: 10 | ``` 11 | {JellyfinConfigPath}/config/logging.json 12 | ``` 13 | 14 | Where `{JellyfinConfigPath}` is typically: 15 | - **Linux**: `/config/config/` (Docker) or `/var/lib/jellyfin/config/` (system install) 16 | - **Windows**: `C:\ProgramData\Jellyfin\Server\config\` 17 | - **macOS**: `~/Library/Application Support/Jellyfin/Server/config/` 18 | 19 | 2. **Add the following content** to `logging.json`: 20 | ```json 21 | { 22 | "Serilog": { 23 | "MinimumLevel": { 24 | "Override": { 25 | "Jellyfin.Plugin.SmartLists": "Debug" 26 | } 27 | } 28 | } 29 | } 30 | ``` 31 | 32 | 3. **Restart Jellyfin** for the changes to take effect 33 | 34 | ## Viewing Logs 35 | 36 | After enabling debug logging, you can view the detailed logs: 37 | 38 | - **Log location**: `{JellyfinConfigPath}/log/` 39 | - **Log files**: Look for files named `log_YYYYMMDD.log` (e.g., `log_20251109.log`) 40 | 41 | The debug logs will include detailed information about: 42 | - Playlist refresh operations 43 | - Rule evaluation 44 | - Item filtering and matching 45 | - Performance metrics 46 | - Error details and stack traces 47 | 48 | ## Sharing Logs for Troubleshooting 49 | 50 | When seeking help with issues, you may need to share log files. The easiest way to access logs is through the Jellyfin admin dashboard: 51 | 52 | 1. **Access Logs via Admin Dashboard**: 53 | - Go to **Dashboard** → **Logs** 54 | - Select the log file you want to view (e.g., `log_20251109.log`) 55 | - Copy the relevant log entries or download the whole log 56 | 57 | 2. **Upload to Pastebin**: 58 | - Go to [bin.dinsten.se](https://bin.dinsten.se/) 59 | - Paste the log content (or attach the entire log file if needed) 60 | - Share the paste URL when reporting issues 61 | 62 | 3. **For very large logs**: 63 | - Focus on the time period when the issue occurred 64 | - Copy only the relevant sections containing SmartLists entries 65 | 66 | !!! tip "Privacy Note" 67 | Logs may contain sensitive information. Review logs before sharing and consider redacting any personal information if needed. 68 | 69 | ## Disable Debug Logging 70 | 71 | To disable debug logging: 72 | 73 | 1. **Delete or rename** the `logging.json` file 74 | 2. **Restart Jellyfin** 75 | 76 | Or modify the file to remove the SmartLists override section. 77 | 78 | !!! tip "Performance Note" 79 | Debug logging generates significantly more log output and may impact performance. Only enable it when troubleshooting issues or during development. 80 | 81 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.SmartLists/Core/Orders/PropertyOrder.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using Jellyfin.Database.Implementations.Entities; 5 | using Jellyfin.Plugin.SmartLists.Services.Shared; 6 | using MediaBrowser.Controller.Entities; 7 | using MediaBrowser.Controller.Library; 8 | using Microsoft.Extensions.Logging; 9 | 10 | namespace Jellyfin.Plugin.SmartLists.Core.Orders 11 | { 12 | /// 13 | /// Generic base class for simple property-based sorting to eliminate code duplication 14 | /// 15 | public abstract class PropertyOrder : Order where T : IComparable, IComparable 16 | { 17 | /// 18 | /// Gets the sort key for an item. This is the unified method used for both single-sort and multi-sort. 19 | /// Subclasses implement this method, and both OrderBy and GetSortKey use it. 20 | /// 21 | protected abstract T GetSortValue( 22 | BaseItem item, 23 | User? user = null, 24 | IUserDataManager? userDataManager = null, 25 | ILogger? logger = null, 26 | RefreshQueueService.RefreshCache? refreshCache = null); 27 | protected abstract bool IsDescending { get; } 28 | protected virtual IComparer Comparer => Comparer.Default; 29 | 30 | public override IEnumerable OrderBy(IEnumerable items) 31 | { 32 | if (items == null) return []; 33 | 34 | // Use GetSortValue - the unified sorting logic 35 | return IsDescending 36 | ? items.OrderByDescending(item => GetSortValue(item), Comparer) 37 | : items.OrderBy(item => GetSortValue(item), Comparer); 38 | } 39 | 40 | public override IEnumerable OrderBy( 41 | IEnumerable items, 42 | User user, 43 | IUserDataManager? userDataManager, 44 | ILogger? logger, 45 | RefreshQueueService.RefreshCache? refreshCache = null) 46 | { 47 | if (items == null) return []; 48 | 49 | // Use unified GetSortValue with cache 50 | return IsDescending 51 | ? items.OrderByDescending(item => GetSortValue(item, user, userDataManager, logger, refreshCache), Comparer) 52 | : items.OrderBy(item => GetSortValue(item, user, userDataManager, logger, refreshCache), Comparer); 53 | } 54 | 55 | public override IComparable GetSortKey( 56 | BaseItem item, 57 | User user, 58 | IUserDataManager? userDataManager, 59 | ILogger? logger, 60 | Dictionary? itemRandomKeys = null, 61 | RefreshQueueService.RefreshCache? refreshCache = null) 62 | { 63 | // Delegate to unified GetSortValue 64 | return GetSortValue(item, user, userDataManager, logger, refreshCache); 65 | } 66 | } 67 | } 68 | 69 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.SmartLists/Core/Orders/NameIgnoreArticlesOrder.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text.RegularExpressions; 4 | using Jellyfin.Database.Implementations.Entities; 5 | using Jellyfin.Plugin.SmartLists.Core; 6 | using Jellyfin.Plugin.SmartLists.Services.Shared; 7 | using MediaBrowser.Controller.Entities; 8 | using MediaBrowser.Controller.Entities.TV; 9 | using MediaBrowser.Controller.Library; 10 | using Microsoft.Extensions.Logging; 11 | 12 | namespace Jellyfin.Plugin.SmartLists.Core.Orders 13 | { 14 | public class NameIgnoreArticlesOrder : PropertyOrder 15 | { 16 | private static readonly Regex AutoGeneratedSortNamePattern = 17 | new(@"^\d{3,} - \d{4,} - ", RegexOptions.Compiled); 18 | 19 | public override string Name => "Name (Ignore Articles) Ascending"; 20 | protected override bool IsDescending => false; 21 | protected override IComparer Comparer => OrderUtilities.SharedNaturalComparer; 22 | 23 | protected override string GetSortValue(BaseItem item, User? user = null, IUserDataManager? userDataManager = null, ILogger? logger = null, RefreshQueueService.RefreshCache? refreshCache = null) 24 | { 25 | return ComputeNameIgnoreArticlesSortValue(item); 26 | } 27 | 28 | /// 29 | /// Shared logic for computing name with articles stripped 30 | /// 31 | public static string ComputeNameIgnoreArticlesSortValue(BaseItem item) 32 | { 33 | ArgumentNullException.ThrowIfNull(item); 34 | 35 | // For Episodes, SortName is auto-generated as "001 - 0001 - Title". 36 | // We want to sort by Title (ignoring articles) UNLESS manual SortName is set. 37 | if (item is Episode && !string.IsNullOrEmpty(item.SortName) && 38 | AutoGeneratedSortNamePattern.IsMatch(item.SortName)) 39 | { 40 | return OrderUtilities.StripLeadingArticles(item.Name ?? ""); 41 | } 42 | 43 | // Use SortName as-is (no article stripping) when present, else strip articles from Name 44 | return !string.IsNullOrEmpty(item.SortName) 45 | ? item.SortName 46 | : OrderUtilities.StripLeadingArticles(item.Name ?? ""); 47 | } 48 | } 49 | 50 | public class NameIgnoreArticlesOrderDesc : PropertyOrder 51 | { 52 | public override string Name => "Name (Ignore Articles) Descending"; 53 | protected override bool IsDescending => true; 54 | protected override IComparer Comparer => OrderUtilities.SharedNaturalComparer; 55 | 56 | protected override string GetSortValue(BaseItem item, User? user = null, IUserDataManager? userDataManager = null, ILogger? logger = null, RefreshQueueService.RefreshCache? refreshCache = null) 57 | { 58 | return NameIgnoreArticlesOrder.ComputeNameIgnoreArticlesSortValue(item); 59 | } 60 | } 61 | } 62 | 63 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.SmartLists/Core/Orders/RandomOrder.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using Jellyfin.Database.Implementations.Entities; 5 | using Jellyfin.Plugin.SmartLists.Services.Shared; 6 | using MediaBrowser.Controller.Entities; 7 | using MediaBrowser.Controller.Library; 8 | using Microsoft.Extensions.Logging; 9 | 10 | namespace Jellyfin.Plugin.SmartLists.Core.Orders 11 | { 12 | public class RandomOrder : Order 13 | { 14 | public override string Name => "Random"; 15 | 16 | public override IEnumerable OrderBy(IEnumerable items) 17 | { 18 | if (items == null) return []; 19 | 20 | // Convert to list to ensure stable enumeration 21 | var itemsList = items.ToList(); 22 | if (itemsList.Count == 0) return []; 23 | 24 | // Use current ticks as seed for different results each refresh 25 | // Suppress CA5394: Random is acceptable here - we're not using it for security purposes, just for shuffling playlist items 26 | #pragma warning disable CA5394 27 | var random = new Random((int)(DateTime.Now.Ticks & 0x7FFFFFFF)); 28 | #pragma warning restore CA5394 29 | 30 | // Create a list of items with their random keys to ensure consistent random values 31 | // Suppress CA5394: Random.Next() is acceptable here - we're not using it for security purposes, just for shuffling playlist items 32 | #pragma warning disable CA5394 33 | var itemsWithKeys = itemsList.Select(item => new { Item = item, Key = random.Next() }).ToList(); 34 | #pragma warning restore CA5394 35 | 36 | // Sort by the pre-generated random keys 37 | return itemsWithKeys.OrderBy(x => x.Key).Select(x => x.Item); 38 | } 39 | 40 | public override IEnumerable OrderBy( 41 | IEnumerable items, 42 | User user, 43 | IUserDataManager? userDataManager, 44 | ILogger? logger, 45 | RefreshQueueService.RefreshCache? refreshCache = null) 46 | { 47 | // refreshCache not used for random ordering 48 | return OrderBy(items); 49 | } 50 | 51 | public override IComparable GetSortKey( 52 | BaseItem item, 53 | User user, 54 | IUserDataManager? userDataManager, 55 | ILogger? logger, 56 | Dictionary? itemRandomKeys = null, 57 | RefreshQueueService.RefreshCache? refreshCache = null) 58 | { 59 | // For random order, use pre-generated random key that's different each refresh 60 | // but stable within this sort operation 61 | if (itemRandomKeys != null && itemRandomKeys.TryGetValue(item.Id, out var randomKey)) 62 | { 63 | return randomKey; 64 | } 65 | // Fallback to hash if not pre-generated (shouldn't happen in multi-sort, but needed for single-sort) 66 | return item.Id.GetHashCode(); 67 | } 68 | } 69 | } 70 | 71 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.SmartLists/Core/Orders/SimilarityOrder.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Concurrent; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using Jellyfin.Database.Implementations.Entities; 6 | using Jellyfin.Plugin.SmartLists.Core; 7 | using Jellyfin.Plugin.SmartLists.Services.Shared; 8 | using MediaBrowser.Controller.Entities; 9 | using MediaBrowser.Controller.Library; 10 | using Microsoft.Extensions.Logging; 11 | 12 | namespace Jellyfin.Plugin.SmartLists.Core.Orders 13 | { 14 | /// 15 | /// Base class for similarity ordering to eliminate duplication 16 | /// 17 | public abstract class SimilarityOrderBase : Order 18 | { 19 | protected abstract bool IsDescending { get; } 20 | 21 | // Initialize to empty dictionary instead of null-suppression 22 | public ConcurrentDictionary Scores { get; set; } = new(); 23 | 24 | public override IEnumerable OrderBy(IEnumerable items) 25 | { 26 | if (items == null) return []; 27 | if (Scores.Count == 0) 28 | { 29 | // No scores available, return items unsorted 30 | return items; 31 | } 32 | 33 | // Sort by similarity score, then by name for deterministic ordering when scores are equal 34 | var orderedItems = IsDescending 35 | ? items.OrderByDescending(item => Scores.TryGetValue(item.Id, out var score) ? score : 0) 36 | : items.OrderBy(item => Scores.TryGetValue(item.Id, out var score) ? score : 0); 37 | 38 | return orderedItems.ThenBy(item => item.Name ?? "", OrderUtilities.SharedNaturalComparer); 39 | } 40 | 41 | public override IEnumerable OrderBy( 42 | IEnumerable items, 43 | User user, 44 | IUserDataManager? userDataManager, 45 | ILogger? logger, 46 | RefreshQueueService.RefreshCache? refreshCache = null) 47 | { 48 | // Similarity ordering only depends on pre-computed scores, so user context and cache are not needed 49 | return OrderBy(items); 50 | } 51 | 52 | public override IComparable GetSortKey( 53 | BaseItem item, 54 | User user, 55 | IUserDataManager? userDataManager, 56 | ILogger? logger, 57 | Dictionary? itemRandomKeys = null, 58 | RefreshQueueService.RefreshCache? refreshCache = null) 59 | { 60 | if (Scores.TryGetValue(item.Id, out var score)) 61 | { 62 | return score; 63 | } 64 | return 0f; 65 | } 66 | } 67 | 68 | public class SimilarityOrder : SimilarityOrderBase 69 | { 70 | public override string Name => "Similarity Descending"; 71 | protected override bool IsDescending => true; 72 | } 73 | 74 | public class SimilarityOrderAsc : SimilarityOrderBase 75 | { 76 | public override string Name => "Similarity Ascending"; 77 | protected override bool IsDescending => false; 78 | } 79 | } 80 | 81 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.SmartLists/Core/Constants/ResolutionTypes.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | 3 | namespace Jellyfin.Plugin.SmartLists.Core.Constants 4 | { 5 | /// 6 | /// Represents a resolution with its display name and numeric value for comparisons. 7 | /// 8 | public record ResolutionInfo(string Value, string DisplayName, int Height); 9 | 10 | /// 11 | /// Centralized resolution definitions for the resolution field. 12 | /// 13 | public static class ResolutionTypes 14 | { 15 | /// 16 | /// All available resolution options for the UI dropdown. 17 | /// 18 | public static readonly ResolutionInfo[] AllResolutions = 19 | [ 20 | new ResolutionInfo("480p", "480p (854x480)", 480), 21 | new ResolutionInfo("720p", "720p (1280x720)", 720), 22 | new ResolutionInfo("1080p", "1080p (1920x1080)", 1080), 23 | new ResolutionInfo("1440p", "1440p (2560x1440)", 1440), 24 | new ResolutionInfo("4K", "4K (3840x2160)", 2160), 25 | new ResolutionInfo("8K", "8K (7680x4320)", 4320) 26 | ]; 27 | 28 | /// 29 | /// Gets a resolution info by its value. 30 | /// 31 | /// The resolution value (e.g., "1080p") 32 | /// The resolution info or null if not found 33 | public static ResolutionInfo? GetByValue(string value) 34 | { 35 | return AllResolutions.FirstOrDefault(r => r.Value == value); 36 | } 37 | 38 | /// 39 | /// Gets a resolution info by its height. 40 | /// 41 | /// The resolution height in pixels 42 | /// The resolution info or null if not found 43 | public static ResolutionInfo? GetByHeight(int height) 44 | { 45 | return AllResolutions.FirstOrDefault(r => r.Height == height); 46 | } 47 | 48 | /// 49 | /// Gets all resolution values for API responses. 50 | /// 51 | /// Array of resolution values 52 | public static string[] GetAllValues() 53 | { 54 | return [.. AllResolutions.Select(r => r.Value)]; 55 | } 56 | 57 | /// 58 | /// Gets all resolution display names for UI dropdowns. 59 | /// 60 | /// Array of resolution display names 61 | public static string[] GetAllDisplayNames() 62 | { 63 | return [.. AllResolutions.Select(r => r.DisplayName)]; 64 | } 65 | 66 | /// 67 | /// Gets the numeric height value for a resolution string. 68 | /// 69 | /// The resolution value (e.g., "1080p") 70 | /// The height in pixels, or -1 if not found 71 | public static int GetHeightForResolution(string resolutionValue) 72 | { 73 | var resolution = GetByValue(resolutionValue); 74 | return resolution?.Height ?? -1; 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.SmartLists/ServiceRegistrator.cs: -------------------------------------------------------------------------------- 1 | using MediaBrowser.Controller; 2 | using MediaBrowser.Controller.Plugins; 3 | using Microsoft.Extensions.DependencyInjection; 4 | using MediaBrowser.Common; 5 | using Jellyfin.Plugin.SmartLists.Services.Shared; 6 | 7 | namespace Jellyfin.Plugin.SmartLists 8 | { 9 | /// 10 | /// Service registrator for SmartLists plugin services. 11 | /// 12 | public sealed class ServiceRegistrator : IPluginServiceRegistrator 13 | { 14 | /// 15 | /// Registers services for the SmartLists plugin. 16 | /// 17 | /// The service collection. 18 | /// The application host. 19 | public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost) 20 | { 21 | // Register RefreshStatusService first 22 | serviceCollection.AddSingleton(); 23 | 24 | // Register RefreshQueueService as singleton 25 | serviceCollection.AddSingleton(sp => 26 | { 27 | var logger = sp.GetRequiredService>(); 28 | var userManager = sp.GetRequiredService(); 29 | var libraryManager = sp.GetRequiredService(); 30 | var playlistManager = sp.GetRequiredService(); 31 | var collectionManager = sp.GetRequiredService(); 32 | var userDataManager = sp.GetRequiredService(); 33 | var providerManager = sp.GetRequiredService(); 34 | var applicationPaths = sp.GetRequiredService(); 35 | var refreshStatusService = sp.GetRequiredService(); 36 | var loggerFactory = sp.GetRequiredService(); 37 | 38 | var queueService = new RefreshQueueService( 39 | logger, 40 | userManager, 41 | libraryManager, 42 | playlistManager, 43 | collectionManager, 44 | userDataManager, 45 | providerManager, 46 | applicationPaths, 47 | refreshStatusService, 48 | loggerFactory); 49 | 50 | // Set the reference in RefreshStatusService 51 | refreshStatusService.SetRefreshQueueService(queueService); 52 | 53 | return queueService; 54 | }); 55 | 56 | serviceCollection.AddHostedService(); 57 | serviceCollection.AddScoped(); 58 | } 59 | } 60 | } 61 | 62 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.SmartLists/Core/Orders/ArtistOrder.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using Jellyfin.Database.Implementations.Entities; 5 | using Jellyfin.Plugin.SmartLists.Core; 6 | using Jellyfin.Plugin.SmartLists.Services.Shared; 7 | using MediaBrowser.Controller.Entities; 8 | using MediaBrowser.Controller.Library; 9 | using Microsoft.Extensions.Logging; 10 | 11 | namespace Jellyfin.Plugin.SmartLists.Core.Orders 12 | { 13 | public class ArtistOrder : PropertyOrder 14 | { 15 | public override string Name => "Artist Ascending"; 16 | protected override bool IsDescending => false; 17 | protected override IComparer Comparer => OrderUtilities.SharedNaturalComparer; 18 | 19 | protected override string GetSortValue(BaseItem item, User? user = null, IUserDataManager? userDataManager = null, ILogger? logger = null, RefreshQueueService.RefreshCache? refreshCache = null) 20 | { 21 | ArgumentNullException.ThrowIfNull(item); 22 | try 23 | { 24 | // Try to get Artists property (it's a list, so we'll use the first one for sorting) 25 | var artistsProperty = item.GetType().GetProperty("Artists"); 26 | if (artistsProperty != null) 27 | { 28 | var value = artistsProperty.GetValue(item); 29 | if (value is IEnumerable artists) 30 | { 31 | var firstArtist = artists.FirstOrDefault(); 32 | if (firstArtist != null) 33 | return firstArtist; 34 | } 35 | } 36 | } 37 | catch 38 | { 39 | // Ignore errors and return empty string 40 | } 41 | return ""; 42 | } 43 | } 44 | 45 | public class ArtistOrderDesc : PropertyOrder 46 | { 47 | public override string Name => "Artist Descending"; 48 | protected override bool IsDescending => true; 49 | protected override IComparer Comparer => OrderUtilities.SharedNaturalComparer; 50 | 51 | protected override string GetSortValue(BaseItem item, User? user = null, IUserDataManager? userDataManager = null, ILogger? logger = null, RefreshQueueService.RefreshCache? refreshCache = null) 52 | { 53 | ArgumentNullException.ThrowIfNull(item); 54 | try 55 | { 56 | // Try to get Artists property (it's a list, so we'll use the first one for sorting) 57 | var artistsProperty = item.GetType().GetProperty("Artists"); 58 | if (artistsProperty != null) 59 | { 60 | var value = artistsProperty.GetValue(item); 61 | if (value is IEnumerable artists) 62 | { 63 | var firstArtist = artists.FirstOrDefault(); 64 | if (firstArtist != null) 65 | return firstArtist; 66 | } 67 | } 68 | } 69 | catch 70 | { 71 | // Ignore errors and return empty string 72 | } 73 | return ""; 74 | } 75 | } 76 | } 77 | 78 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.SmartLists/Core/Models/DayOfWeekAsIntegerConverter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text.Json; 3 | using System.Text.Json.Serialization; 4 | 5 | namespace Jellyfin.Plugin.SmartLists.Core.Models 6 | { 7 | /// 8 | /// JSON converter that serializes DayOfWeek enum as integer instead of string 9 | /// 10 | public class DayOfWeekAsIntegerConverter : JsonConverter 11 | { 12 | public override DayOfWeek? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) 13 | { 14 | if (reader.TokenType == JsonTokenType.Null) 15 | { 16 | return null; 17 | } 18 | 19 | if (reader.TokenType == JsonTokenType.Number) 20 | { 21 | var intValue = reader.GetInt32(); 22 | 23 | // Validate range: DayOfWeek enum is 0 (Sunday) through 6 (Saturday) 24 | if (intValue < 0 || intValue > 6) 25 | { 26 | throw new JsonException($"Invalid DayOfWeek value '{intValue}'. Must be between 0 (Sunday) and 6 (Saturday)."); 27 | } 28 | 29 | return (DayOfWeek)intValue; 30 | } 31 | 32 | // Handle string input for backward compatibility (e.g., "Friday" -> 5) 33 | if (reader.TokenType == JsonTokenType.String) 34 | { 35 | var value = reader.GetString(); 36 | if (!string.IsNullOrEmpty(value) && Enum.TryParse(value, true, out var dayOfWeek)) 37 | { 38 | return dayOfWeek; 39 | } 40 | 41 | throw new JsonException($"Unable to convert string '{value}' to DayOfWeek. Expected a day name (e.g., 'Monday') or numeric value (0-6)."); 42 | } 43 | 44 | // For unexpected token types, build error message without accessing ValueSpan for structural tokens 45 | var tokenType = reader.TokenType; 46 | 47 | // Only access ValueSpan for token types that have values (avoid InvalidOperationException for structural tokens) 48 | var rawText = ""; 49 | if (tokenType != JsonTokenType.StartObject && 50 | tokenType != JsonTokenType.EndObject && 51 | tokenType != JsonTokenType.StartArray && 52 | tokenType != JsonTokenType.EndArray && 53 | tokenType != JsonTokenType.True && 54 | tokenType != JsonTokenType.False && 55 | !reader.HasValueSequence && 56 | reader.ValueSpan.Length > 0) 57 | { 58 | rawText = System.Text.Encoding.UTF8.GetString(reader.ValueSpan); 59 | } 60 | 61 | throw new JsonException($"Unable to convert token type '{tokenType}' (value: '{rawText}') to DayOfWeek. Expected a number (0-6) or string day name."); 62 | } 63 | 64 | public override void Write(Utf8JsonWriter writer, DayOfWeek? value, JsonSerializerOptions options) 65 | { 66 | ArgumentNullException.ThrowIfNull(writer); 67 | 68 | if (value.HasValue) 69 | { 70 | writer.WriteNumberValue((int)value.Value); 71 | } 72 | else 73 | { 74 | writer.WriteNullValue(); 75 | } 76 | } 77 | } 78 | } 79 | 80 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.SmartLists/Services/Abstractions/ISmartListService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | using Jellyfin.Database.Implementations.Entities; 6 | using Jellyfin.Plugin.SmartLists.Core.Models; 7 | using Jellyfin.Plugin.SmartLists.Services.Shared; 8 | using MediaBrowser.Controller.Entities; 9 | 10 | namespace Jellyfin.Plugin.SmartLists.Services.Abstractions 11 | { 12 | /// 13 | /// Generic service interface for smart list operations (Playlists and Collections) 14 | /// 15 | /// The DTO type (SmartPlaylistDto or SmartCollectionDto) 16 | public interface ISmartListService where TDto : SmartListDto 17 | { 18 | /// 19 | /// Refreshes a single smart list 20 | /// This method is called by the queue processor and assumes no locking is needed. 21 | /// 22 | Task<(bool Success, string Message, string Id)> RefreshAsync( 23 | TDto dto, Action? progressCallback = null, CancellationToken cancellationToken = default); 24 | 25 | /// 26 | /// Deletes a smart list 27 | /// 28 | Task DeleteAsync(TDto dto, CancellationToken cancellationToken = default); 29 | 30 | /// 31 | /// Disables a smart list (deletes the underlying Jellyfin entity) 32 | /// 33 | Task DisableAsync(TDto dto, CancellationToken cancellationToken = default); 34 | 35 | /// 36 | /// Gets all user media for a playlist, filtered by media types. 37 | /// 38 | /// The user 39 | /// List of media types to include 40 | /// The playlist DTO (optional, for validation) 41 | /// Enumerable of BaseItem matching the media types 42 | IEnumerable GetAllUserMediaForPlaylist(User user, List mediaTypes, TDto? dto = null); 43 | 44 | /// 45 | /// Processes a playlist refresh with pre-cached media for efficient batch processing. 46 | /// 47 | /// The playlist DTO to process 48 | /// The user for this playlist 49 | /// All media items for the user (cached) 50 | /// RefreshCache instance for caching expensive operations 51 | /// Optional callback to save the DTO when JellyfinPlaylistId is updated 52 | /// Optional callback to report progress 53 | /// Cancellation token 54 | /// Tuple of (success, message, jellyfinPlaylistId) 55 | Task<(bool Success, string Message, string JellyfinPlaylistId)> ProcessPlaylistRefreshWithCachedMediaAsync( 56 | TDto dto, 57 | User user, 58 | BaseItem[] allUserMedia, 59 | RefreshQueueService.RefreshCache refreshCache, 60 | Func? saveCallback = null, 61 | Action? progressCallback = null, 62 | CancellationToken cancellationToken = default); 63 | } 64 | } 65 | 66 | -------------------------------------------------------------------------------- /docs/content/development/building-locally.md: -------------------------------------------------------------------------------- 1 | # Building Locally 2 | 3 | This guide explains how to set up a local development environment for the SmartLists plugin. 4 | 5 | For the source code and repository, see: [jellyfin-smartlists-plugin](https://github.com/jyourstone/jellyfin-smartlists-plugin) 6 | 7 | ## Prerequisites 8 | 9 | Make sure you have the following installed on your system: 10 | 11 | - [Docker](https://www.docker.com/) 12 | - [.NET SDK](https://dotnet.microsoft.com/) 13 | 14 | ## Development Environment 15 | 16 | The development environment is contained in the [`/dev` directory](https://github.com/jyourstone/jellyfin-smartlists-plugin/tree/main/dev). This folder contains all the files needed for local development and testing of the SmartLists plugin. 17 | 18 | ### Files in the Dev Folder 19 | 20 | - **`build-local.sh`** – Bash script to build the plugin and restart the Docker container (Linux/macOS/WSL) 21 | - **`build-local.ps1`** – PowerShell script to build the plugin and restart the Docker container (Windows) 22 | - **`docker-compose.yml`** – Docker Compose configuration for local Jellyfin testing 23 | - **`meta-dev.json`** – Development version of the plugin metadata 24 | - **`jellyfin-data/`** – Jellyfin data, gets created when built (persistent across restarts) 25 | - **`media/`** – Media directories for testing (place your test media files here) 26 | 27 | !!! important "Important" 28 | For local testing, don't modify files outside `/dev` to prevent accidental changes. However, if you want to contribute improvements, you can edit any files and create a pull request. See the [Contributing](contributing.md) guide for details. 29 | 30 | ## How to Use 31 | 32 | ### 1. Build and Run the Plugin Locally 33 | 34 | **Linux/macOS/WSL:** 35 | ```bash 36 | cd dev 37 | ./build-local.sh 38 | ``` 39 | 40 | **Windows PowerShell:** 41 | ```powershell 42 | cd dev 43 | .\build-local.ps1 44 | ``` 45 | 46 | The build scripts automatically: 47 | - Build the plugin 48 | - Restart the Jellyfin Docker container 49 | - Make the plugin available in your local Jellyfin instance 50 | 51 | ### 2. Access Jellyfin 52 | 53 | - Open [http://localhost:8096](http://localhost:8096) in your browser 54 | - Complete the initial setup if it's your first time 55 | - The plugin will appear as **"SmartLists"** in the plugin list 56 | 57 | ### 3. Add Test Media 58 | 59 | Place your test media files in the appropriate directories: 60 | 61 | - **Movies**: `dev/media/movies/` 62 | - **TV Shows**: `dev/media/shows/` 63 | - **Music**: `dev/media/music/` 64 | - **Music Videos**: `dev/media/musicvideos/` 65 | 66 | After adding media, you may need to trigger a library scan in Jellyfin. 67 | 68 | ## Important Notes 69 | 70 | ### Jellyfin Data Directory 71 | 72 | The **`jellyfin-data`** directory stores Jellyfin's configuration and data, including: 73 | - Logs 74 | - Playlists 75 | - User information 76 | - Plugin configurations 77 | 78 | This directory is mounted into the container so your data persists across restarts. The `smartlists` folder inside `jellyfin-data/config/data` is where your created playlists are stored. 79 | 80 | ### Logs 81 | 82 | Logs can be accessed in: 83 | ``` 84 | dev/jellyfin-data/config/log/ 85 | ``` 86 | 87 | ### Development Metadata 88 | 89 | **`meta-dev.json`** is a development-specific plugin manifest. It overrides the main `meta.json` during local builds to ensure the plugin works correctly in the development environment. 90 | -------------------------------------------------------------------------------- /docs/content/user-guide/advanced-configuration.md: -------------------------------------------------------------------------------- 1 | # Advanced Configuration 2 | 3 | For advanced users who prefer direct file editing or need to perform bulk operations, SmartLists stores all list configurations as JSON files. 4 | 5 | ## File Location 6 | 7 | Smart list files are stored in the Jellyfin data directory: 8 | 9 | ``` 10 | {DataPath}/smartlists/ 11 | ``` 12 | 13 | Where `{DataPath}` is your Jellyfin data path (typically `/config/data` on Linux, `C:\ProgramData\Jellyfin\Server\data` on Windows, or `~/Library/Application Support/Jellyfin/Server/data` on macOS). 14 | 15 | Each list is stored as a separate JSON file named `{listId}.json`, where `{listId}` is a unique GUID identifier for the list. 16 | 17 | ## File Format 18 | 19 | List files use JSON format with the following structure: 20 | 21 | - **Indented JSON** - Files are formatted with indentation for readability 22 | - **UTF-8 encoding** - All files use UTF-8 character encoding 23 | - **GUID-based filenames** - Each file is named using the list's unique identifier 24 | 25 | ## Manual Editing 26 | 27 | You can manually edit these JSON files if needed, but please be aware: 28 | 29 | !!! warning "Edit at Your Own Risk" 30 | - **No validation safeguards**: The plugin may not have safeguards in place for misconfigured JSON files 31 | - **Backup first**: Always backup your list files before editing 32 | - **Syntax errors**: Invalid JSON syntax will prevent the list from loading 33 | - **Data corruption**: Incorrect field values or types may cause unexpected behavior or errors 34 | 35 | ### Best Practices 36 | 37 | 1. **Always backup** your `smartlists` directory before making changes 38 | 2. **Validate JSON syntax** using a JSON validator before saving 39 | 3. **Test thoroughly** after making changes to ensure lists still work correctly 40 | 4. **Use the web interface** when possible - it's safer and includes validation 41 | 42 | ## Example Use Cases 43 | 44 | Manual editing can be useful for: 45 | 46 | - **Bulk operations**: Making the same change to multiple lists 47 | - **Advanced configurations**: Settings not available in the web interface 48 | - **Migration**: Copying lists between Jellyfin instances 49 | - **Backup/restore**: Manual backup and restoration of list configurations 50 | 51 | ## File Structure Reference 52 | 53 | For a reference of the JSON file structure, you can: 54 | 55 | 1. **Export a list** using the web interface (Settings → Export All Lists) to see the format 56 | 2. **Examine existing files** in your `smartlists` directory 57 | 3. **Check the repository** for example files (if available) 58 | 59 | The JSON structure follows the `SmartListDto` format, which includes fields for: 60 | - List metadata (name, ID, owner, list type, etc.) 61 | - Rules and logic groups 62 | - Sort options 63 | - Refresh settings 64 | - Limits (max items, max playtime) 65 | - And more 66 | 67 | ## Troubleshooting 68 | 69 | If a list file becomes corrupted or invalid: 70 | 71 | 1. **Check JSON syntax** - Use a JSON validator to find syntax errors 72 | 2. **Restore from backup** - If you have a backup, restore the file 73 | 3. **Recreate via UI** - Delete the corrupted file and recreate the list using the web interface 74 | 4. **Check logs** - Review Jellyfin logs for specific error messages about the list 75 | 76 | !!! tip "Prefer the Web Interface" 77 | While manual editing is possible, the web interface is the recommended method for creating and editing lists. It includes validation, error checking, and is much safer than manual file editing. 78 | 79 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.SmartLists/Core/Orders/SeasonNumberOrder.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using Jellyfin.Database.Implementations.Entities; 5 | using Jellyfin.Plugin.SmartLists.Core; 6 | using Jellyfin.Plugin.SmartLists.Services.Shared; 7 | using MediaBrowser.Controller.Entities; 8 | using MediaBrowser.Controller.Library; 9 | using Microsoft.Extensions.Logging; 10 | 11 | namespace Jellyfin.Plugin.SmartLists.Core.Orders 12 | { 13 | public class SeasonNumberOrder : Order 14 | { 15 | public override string Name => "SeasonNumber Ascending"; 16 | 17 | public override IEnumerable OrderBy(IEnumerable items) 18 | { 19 | if (items == null) return []; 20 | 21 | // Sort by Season Number -> Episode Number -> Name 22 | return items 23 | .OrderBy(item => OrderUtilities.GetSeasonNumber(item)) 24 | .ThenBy(item => OrderUtilities.GetEpisodeNumber(item)) 25 | .ThenBy(item => item.Name ?? "", OrderUtilities.SharedNaturalComparer); 26 | } 27 | 28 | public override IEnumerable OrderBy( 29 | IEnumerable items, 30 | User user, 31 | IUserDataManager? userDataManager, 32 | ILogger? logger, 33 | RefreshQueueService.RefreshCache? refreshCache = null) 34 | { 35 | // refreshCache not used for season number ordering 36 | return OrderBy(items); 37 | } 38 | 39 | public override IComparable GetSortKey( 40 | BaseItem item, 41 | User user, 42 | IUserDataManager? userDataManager, 43 | ILogger? logger, 44 | Dictionary? itemRandomKeys = null, 45 | RefreshQueueService.RefreshCache? refreshCache = null) 46 | { 47 | return OrderUtilities.GetSeasonNumber(item); 48 | } 49 | } 50 | 51 | public class SeasonNumberOrderDesc : Order 52 | { 53 | public override string Name => "SeasonNumber Descending"; 54 | 55 | public override IEnumerable OrderBy(IEnumerable items) 56 | { 57 | if (items == null) return []; 58 | 59 | // Sort by Season Number (descending) -> Episode Number (descending) -> Name (descending) 60 | return items 61 | .OrderByDescending(item => OrderUtilities.GetSeasonNumber(item)) 62 | .ThenByDescending(item => OrderUtilities.GetEpisodeNumber(item)) 63 | .ThenByDescending(item => item.Name ?? "", OrderUtilities.SharedNaturalComparer); 64 | } 65 | 66 | public override IEnumerable OrderBy( 67 | IEnumerable items, 68 | User user, 69 | IUserDataManager? userDataManager, 70 | ILogger? logger, 71 | RefreshQueueService.RefreshCache? refreshCache = null) 72 | { 73 | // refreshCache not used for season number ordering 74 | return OrderBy(items); 75 | } 76 | 77 | public override IComparable GetSortKey( 78 | BaseItem item, 79 | User user, 80 | IUserDataManager? userDataManager, 81 | ILogger? logger, 82 | Dictionary? itemRandomKeys = null, 83 | RefreshQueueService.RefreshCache? refreshCache = null) 84 | { 85 | return OrderUtilities.GetSeasonNumber(item); 86 | } 87 | } 88 | } 89 | 90 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.SmartLists/Core/Orders/PlayCountOrder.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using Jellyfin.Database.Implementations.Entities; 4 | using Jellyfin.Plugin.SmartLists.Services.Shared; 5 | using MediaBrowser.Controller.Entities; 6 | using MediaBrowser.Controller.Library; 7 | using Microsoft.Extensions.Logging; 8 | 9 | namespace Jellyfin.Plugin.SmartLists.Core.Orders 10 | { 11 | public class PlayCountOrder : UserDataOrder 12 | { 13 | public override string Name => "PlayCount (owner) Ascending"; 14 | protected override bool IsDescending => false; 15 | 16 | protected override int GetUserDataValue( 17 | BaseItem item, 18 | User user, 19 | IUserDataManager? userDataManager, 20 | ILogger? logger, 21 | RefreshQueueService.RefreshCache? refreshCache = null) 22 | { 23 | return GetPlayCountFromUserData(item, user, userDataManager, logger, refreshCache); 24 | } 25 | 26 | /// 27 | /// Shared logic for extracting PlayCount from user data 28 | /// 29 | public static int GetPlayCountFromUserData( 30 | BaseItem item, 31 | User user, 32 | IUserDataManager? userDataManager, 33 | ILogger? logger, 34 | RefreshQueueService.RefreshCache? refreshCache = null) 35 | { 36 | ArgumentNullException.ThrowIfNull(item); 37 | ArgumentNullException.ThrowIfNull(user); 38 | try 39 | { 40 | object? userData = null; 41 | 42 | // Try to get user data from cache if available 43 | if (refreshCache != null && refreshCache.UserDataCache.TryGetValue((item.Id, user.Id), out var cachedUserData)) 44 | { 45 | userData = cachedUserData; 46 | } 47 | else if (userDataManager != null) 48 | { 49 | // Fallback to fetching from userDataManager 50 | userData = userDataManager.GetUserData(user, item); 51 | } 52 | 53 | // Use reflection to safely extract PlayCount from userData 54 | var playCountProp = userData?.GetType().GetProperty("PlayCount"); 55 | if (playCountProp != null) 56 | { 57 | var playCountValue = playCountProp.GetValue(userData); 58 | if (playCountValue is int pc) 59 | return pc; 60 | if (playCountValue != null) 61 | return Convert.ToInt32(playCountValue, System.Globalization.CultureInfo.InvariantCulture); 62 | } 63 | return 0; 64 | } 65 | catch (Exception ex) 66 | { 67 | logger?.LogDebug(ex, "Error extracting PlayCount from userData for item {ItemName}", item.Name); 68 | return 0; 69 | } 70 | } 71 | } 72 | 73 | public class PlayCountOrderDesc : UserDataOrder 74 | { 75 | public override string Name => "PlayCount (owner) Descending"; 76 | protected override bool IsDescending => true; 77 | 78 | protected override int GetUserDataValue( 79 | BaseItem item, 80 | User user, 81 | IUserDataManager? userDataManager, 82 | ILogger? logger, 83 | RefreshQueueService.RefreshCache? refreshCache = null) 84 | { 85 | return PlayCountOrder.GetPlayCountFromUserData(item, user, userDataManager, logger, refreshCache); 86 | } 87 | } 88 | } 89 | 90 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.SmartLists/Utilities/MediaTypeConverter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using Jellyfin.Data.Enums; 5 | using Jellyfin.Plugin.SmartLists.Core.Constants; 6 | using Jellyfin.Plugin.SmartLists.Core.Models; 7 | using Microsoft.Extensions.Logging; 8 | 9 | namespace Jellyfin.Plugin.SmartLists.Utilities 10 | { 11 | /// 12 | /// Utility class for converting media types to BaseItemKind enums. 13 | /// 14 | public static class MediaTypeConverter 15 | { 16 | /// 17 | /// Maps string media types to BaseItemKind enums for API-level filtering. 18 | /// 19 | /// List of media type strings 20 | /// Optional DTO for smart query expansion (Collections episode expansion) 21 | /// Optional logger for diagnostics 22 | /// Array of BaseItemKind enums 23 | public static BaseItemKind[] GetBaseItemKindsFromMediaTypes( 24 | List? mediaTypes, 25 | SmartListDto? dto = null, 26 | ILogger? logger = null) 27 | { 28 | // This method should only be called after validation, so empty media types should not happen 29 | if (mediaTypes == null || mediaTypes.Count == 0) 30 | { 31 | logger?.LogError("GetBaseItemKindsFromMediaTypes called with empty media types - this should have been caught by validation"); 32 | throw new InvalidOperationException("No media types specified - this should have been caught by validation"); 33 | } 34 | 35 | var baseItemKinds = new List(); 36 | 37 | foreach (var mediaType in mediaTypes) 38 | { 39 | if (MediaTypes.MediaTypeToBaseItemKind.TryGetValue(mediaType, out var baseItemKind)) 40 | { 41 | baseItemKinds.Add(baseItemKind); 42 | } 43 | else 44 | { 45 | logger?.LogWarning("Unknown media type '{MediaType}' - skipping", mediaType); 46 | } 47 | } 48 | 49 | // Smart Query Expansion: If Episodes media type is selected AND Collections episode expansion is enabled, 50 | // also include Series in the query so we can find series in collections and expand them to episodes 51 | if (dto != null && baseItemKinds.Contains(BaseItemKind.Episode) && !baseItemKinds.Contains(BaseItemKind.Series)) 52 | { 53 | var hasCollectionsEpisodeExpansion = dto.ExpressionSets?.Any(set => 54 | set.Expressions?.Any(expr => 55 | expr.MemberName == "Collections" && expr.IncludeEpisodesWithinSeries == true) == true) == true; 56 | 57 | if (hasCollectionsEpisodeExpansion) 58 | { 59 | baseItemKinds.Add(BaseItemKind.Series); 60 | logger?.LogDebug("Auto-including Series in query for Episodes media type due to Collections episode expansion"); 61 | } 62 | } 63 | 64 | // This should not happen if validation is working correctly 65 | if (baseItemKinds.Count == 0) 66 | { 67 | logger?.LogError("No valid media types found after processing - this should have been caught by validation"); 68 | throw new InvalidOperationException("No valid media types found - this should have been caught by validation"); 69 | } 70 | 71 | return [.. baseItemKinds]; 72 | } 73 | } 74 | } 75 | 76 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.SmartLists/Utilities/MediaTypesKey.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using Jellyfin.Plugin.SmartLists.Core.Constants; 5 | using Jellyfin.Plugin.SmartLists.Core.Models; 6 | 7 | namespace Jellyfin.Plugin.SmartLists.Utilities 8 | { 9 | /// 10 | /// Cache key for media types to avoid string collision issues 11 | /// 12 | internal readonly record struct MediaTypesKey : IEquatable 13 | { 14 | private readonly string[] _sortedTypes; 15 | private readonly bool _hasCollectionsExpansion; 16 | 17 | private MediaTypesKey(string[] sortedTypes, bool hasCollectionsExpansion = false) 18 | { 19 | _sortedTypes = sortedTypes; 20 | _hasCollectionsExpansion = hasCollectionsExpansion; 21 | } 22 | 23 | public static MediaTypesKey Create(List mediaTypes) 24 | { 25 | return Create(mediaTypes, null); 26 | } 27 | 28 | public static MediaTypesKey Create(List mediaTypes, SmartListDto? dto) 29 | { 30 | if (mediaTypes == null || mediaTypes.Count == 0) 31 | { 32 | return new MediaTypesKey([], false); 33 | } 34 | 35 | // Deduplicate to ensure identical cache keys for equivalent content (e.g., ["Movie", "Movie"] = ["Movie"]) 36 | var sortedTypes = mediaTypes.Distinct(StringComparer.Ordinal).OrderBy(x => x, StringComparer.Ordinal).ToArray(); 37 | 38 | // Determine Collections expansion flag 39 | bool collectionsExpansionFlag = false; 40 | if (dto != null) 41 | { 42 | // Include Collections episode expansion in cache key to avoid incorrect caching 43 | // when same media types have different expansion settings 44 | var hasCollectionsExpansion = dto.ExpressionSets?.Any(set => 45 | set.Expressions?.Any(expr => 46 | expr.MemberName == "Collections" && expr.IncludeEpisodesWithinSeries == true) == true) == true; 47 | 48 | // Use boolean flag instead of string marker to distinguish caches with Collections expansion 49 | collectionsExpansionFlag = hasCollectionsExpansion && sortedTypes.Contains(MediaTypes.Episode) && !sortedTypes.Contains(MediaTypes.Series); 50 | } 51 | 52 | return new MediaTypesKey(sortedTypes, collectionsExpansionFlag); 53 | } 54 | 55 | public bool Equals(MediaTypesKey other) 56 | { 57 | // Handle null arrays (default struct case) and use SequenceEqual for cleaner comparison 58 | var thisArray = _sortedTypes ?? []; 59 | var otherArray = other._sortedTypes ?? []; 60 | 61 | return thisArray.AsSpan().SequenceEqual(otherArray.AsSpan()) && 62 | _hasCollectionsExpansion == other._hasCollectionsExpansion; 63 | } 64 | 65 | public override int GetHashCode() 66 | { 67 | // Handle null array (default struct case) 68 | var array = _sortedTypes ?? []; 69 | 70 | // Use HashCode.Combine for better distribution 71 | var hashCode = new HashCode(); 72 | foreach (var item in array) 73 | { 74 | hashCode.Add(item, StringComparer.Ordinal); 75 | } 76 | hashCode.Add(_hasCollectionsExpansion); 77 | 78 | return hashCode.ToHashCode(); 79 | } 80 | 81 | public override string ToString() 82 | { 83 | var array = _sortedTypes ?? []; 84 | var typesString = string.Join(",", array); 85 | return _hasCollectionsExpansion ? $"{typesString}[CollectionsExpansion]" : typesString; 86 | } 87 | } 88 | } 89 | 90 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.SmartLists/Core/Orders/UserDataOrder.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using Jellyfin.Database.Implementations.Entities; 5 | using Jellyfin.Plugin.SmartLists.Services.Shared; 6 | using MediaBrowser.Controller.Entities; 7 | using MediaBrowser.Controller.Library; 8 | using Microsoft.Extensions.Logging; 9 | 10 | namespace Jellyfin.Plugin.SmartLists.Core.Orders 11 | { 12 | /// 13 | /// Base class for user-data-based sorting with safe caching and error handling 14 | /// 15 | public abstract class UserDataOrder : Order 16 | { 17 | protected abstract bool IsDescending { get; } 18 | 19 | // Unified method that can use cache 20 | protected abstract int GetUserDataValue( 21 | BaseItem item, 22 | User user, 23 | IUserDataManager? userDataManager, 24 | ILogger? logger, 25 | RefreshQueueService.RefreshCache? refreshCache = null); 26 | 27 | public override IEnumerable OrderBy( 28 | IEnumerable items, 29 | User user, 30 | IUserDataManager? userDataManager, 31 | ILogger? logger, 32 | RefreshQueueService.RefreshCache? refreshCache = null) 33 | { 34 | if (items == null) return []; 35 | if (userDataManager == null || user == null) 36 | { 37 | logger?.LogWarning("UserDataManager or User is null for {OrderType} sorting, returning unsorted items", GetType().Name); 38 | return items; 39 | } 40 | 41 | try 42 | { 43 | var list = items as IList ?? items.ToList(); 44 | 45 | // Pre-cache user data for performance 46 | var sortValueCache = new Dictionary(list.Count); 47 | foreach (var item in list) 48 | { 49 | try 50 | { 51 | sortValueCache[item] = GetUserDataValue(item, user, userDataManager, logger, refreshCache); 52 | } 53 | catch (Exception ex) 54 | { 55 | logger?.LogWarning(ex, "Error getting user data for item {ItemName}", item.Name); 56 | sortValueCache[item] = 0; 57 | } 58 | } 59 | 60 | // Sort using cached values with DateCreated as tie-breaker 61 | return IsDescending 62 | ? list.OrderByDescending(item => sortValueCache[item]).ThenByDescending(item => item.DateCreated) 63 | : list.OrderBy(item => sortValueCache[item]).ThenBy(item => item.DateCreated); 64 | } 65 | catch (Exception ex) 66 | { 67 | logger?.LogError(ex, "Error in {OrderType} sorting", GetType().Name); 68 | return items; 69 | } 70 | } 71 | 72 | public override IComparable GetSortKey( 73 | BaseItem item, 74 | User user, 75 | IUserDataManager? userDataManager, 76 | ILogger? logger, 77 | Dictionary? itemRandomKeys = null, 78 | RefreshQueueService.RefreshCache? refreshCache = null) 79 | { 80 | try 81 | { 82 | // Delegate to unified method 83 | return GetUserDataValue(item, user, userDataManager, logger, refreshCache); 84 | } 85 | catch (Exception ex) 86 | { 87 | logger?.LogWarning(ex, "Error getting user data value for item {ItemName} in {OrderType}, returning default value 0", item?.Name ?? "Unknown", GetType().Name); 88 | return 0; 89 | } 90 | } 91 | } 92 | } 93 | 94 | -------------------------------------------------------------------------------- /docs/content/user-guide/media-types.md: -------------------------------------------------------------------------------- 1 | # Media Types 2 | 3 | When creating a smart list, you must select at least one **Media Type** to specify what kind of content should be included. Media types in SmartLists correspond directly to the **Content type** options you see when adding a new Media Library in Jellyfin. 4 | 5 | ## Available Media Types 6 | 7 | SmartLists supports the following media types: 8 | 9 | ### Movies 10 | - **Jellyfin Library Type**: Movies 11 | - **Description**: Feature films and movie content 12 | - **Example Use Cases**: 13 | - Action movies from the 90s 14 | - Unwatched movies rated above 8.0 15 | - Recently added movies 16 | 17 | ### Episodes (TV Shows) 18 | - **Jellyfin Library Type**: Shows 19 | - **Description**: Individual TV show episodes 20 | - **Example Use Cases**: 21 | - Next unwatched episodes from favorite series 22 | - Recently aired episodes 23 | - Episodes from specific genres 24 | 25 | ### Series (TV Shows) 26 | - **Jellyfin Library Type**: Shows 27 | - **Description**: Entire TV series (not individual episodes, works only for collections) 28 | - **Example Use Cases**: 29 | - TV series by genre 30 | - Ongoing series 31 | - Series with high ratings 32 | 33 | ### Audio (Music) 34 | - **Jellyfin Library Type**: Music 35 | - **Description**: Music tracks and songs 36 | - **Example Use Cases**: 37 | - Songs from specific artists 38 | - Recently played music 39 | - Favorite tracks 40 | 41 | ### Music Videos 42 | - **Jellyfin Library Type**: Music Videos 43 | - **Description**: Music video content 44 | - **Example Use Cases**: 45 | - Music videos from specific artists 46 | - Recently added music videos 47 | 48 | ### Video (Home Videos) 49 | - **Jellyfin Library Type**: Home Videos and Photos 50 | - **Description**: Personal video content 51 | - **Example Use Cases**: 52 | - Home videos from specific years 53 | - Recently added home videos 54 | 55 | ### Photo (Home Photos) 56 | - **Jellyfin Library Type**: Home Videos and Photos 57 | - **Description**: Photo content 58 | - **Example Use Cases**: 59 | - Photos from specific dates 60 | - Recently added photos 61 | 62 | ### Books 63 | - **Jellyfin Library Type**: Books 64 | - **Description**: E-book content 65 | - **Example Use Cases**: 66 | - Books by specific authors 67 | - Unread books 68 | 69 | ### AudioBooks 70 | - **Jellyfin Library Type**: Books 71 | - **Description**: Audiobook content 72 | - **Example Use Cases**: 73 | - Audiobooks by narrator 74 | - Unfinished audiobooks 75 | 76 | ## Important Notes 77 | 78 | !!! warning "Library Content Type Matters" 79 | The media type you select must match the content type of your Jellyfin libraries. For example: 80 | 81 | - If you select **Movies**, the list will only include items from libraries configured with the "Movies" content type 82 | - If you select **Episodes**, the list will only include items from libraries configured with the "Shows" content type 83 | - If you select **Audio**, the list will only include items from libraries configured with the "Music" content type 84 | 85 | !!! tip "Multiple Media Types" 86 | You can select multiple media types for a single list. For example, you could create a list that includes both **Movies** and **Episodes** to create a mixed content list. 87 | 88 | ## Selecting Media Types 89 | 90 | In the SmartLists configuration interface, media types are presented as a multi-select dropdown: 91 | 92 | 1. Click on the **Media Types** field 93 | 2. Check the boxes for the media types you want to include 94 | 3. At least one media type must be selected 95 | 4. The selected types will be displayed in the field 96 | 97 | The available fields and operators for filtering will vary depending on which media types you select. See the [Fields and Operators](fields-and-operators.md) guide for details on what filtering options are available for each media type. 98 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.SmartLists/Jellyfin.Plugin.SmartLists.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net9.0 5 | Jellyfin.Plugin.SmartLists 6 | true 7 | true 8 | enable 9 | Recommended 10 | 11 | 0.0.0 12 | $(Version).0 13 | logo.jpg 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | true 60 | true 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /docs/content/user-guide/sorting-and-limits.md: -------------------------------------------------------------------------------- 1 | # Sorting and Limits 2 | 3 | ## Multiple Sorting Levels 4 | 5 | You can add up to **3 sorting options** for both playlists and collections to create cascading sorts. Items are first sorted by the first option, then items with equal values are sorted by the second option, and so on. 6 | 7 | ### Example Use Cases 8 | 9 | - **Best Movies by Year**: Sort by "Production Year" descending, then "Community Rating" descending - Groups movies by year, with highest-rated movies first within each year 10 | - **Least Played Mix**: Sort by "Play Count (owner)" ascending, then "Random" - Prioritizes less-played items, while shuffling tracks with the same play count to prevent album grouping 11 | 12 | ## Available Sort Fields 13 | 14 | - **No Order** - Items appear in library order 15 | - **Name** - Sort by title 16 | - **Name (Ignore 'The')** - Sort by name while ignoring leading article 'The' 17 | - **Release Date** - Sort by release date 18 | - **Production Year** - Sort by production year 19 | - **Season Number** - Sort by TV season number 20 | - **Episode Number** - Sort by TV episode number 21 | - **Series Name** - Sort by series name (for TV episodes) 22 | - **Community Rating** - Sort by user ratings 23 | - **Date Created** - Sort by when added to library 24 | - **Play Count (owner)** - Sort by how many times the list owner has played each item 25 | - **Last Played (owner)** - Sort by when the list owner last played each item 26 | - **Runtime** - Sort by duration/runtime in minutes 27 | - **Album Name** - Sort by album name (for music and music videos) 28 | - **Artist** - Sort by artist name (for music and music videos) 29 | - **Track Number** - Sort by album name, disc number, then track number (designed for music) 30 | - **Similarity** - Sort by similarity score (highest first) - only available when using the "Similar To" field 31 | - **Random** - Randomize the order of items 32 | 33 | !!! tip "Sort Title Metadata Support" 34 | All **Name** and **Series Name** sort options (including "Ignore Articles" variants) automatically respect Jellyfin's **Sort Title** metadata field. When you set a custom Sort Title for a media item in Jellyfin's metadata editor: 35 | 36 | - The plugin will use the Sort Title **as-is** for sorting (without any modifications) 37 | - This applies to both regular and "Ignore Articles" sorting options 38 | - If Sort Title is not set, the plugin falls back to the regular title (and strips "The" for "Ignore Articles" options) 39 | 40 | This allows you to control the exact sort order without changing the displayed title. 41 | 42 | ## Max Items 43 | 44 | You can optionally set a maximum number of items for your smart list. This is useful for: 45 | 46 | - Limiting large lists to a manageable size 47 | - Creating "Top 10" or "Best of" style playlists or collections 48 | - Improving performance for very large libraries 49 | 50 | !!! note "Collections and Sorting" 51 | For both playlists and collections, the max items limit applies after sorting is applied. 52 | 53 | !!! warning "Performance" 54 | Setting this to unlimited (0) might cause performance issues or even crashes for very large lists. 55 | 56 | ## Max Playtime 57 | 58 | You can optionally set a maximum playtime in minutes for your smart playlist (this option is only available for playlists, not collections). This is useful for: 59 | 60 | - Creating workout playlists that match your exercise duration 61 | - Setting up Pomodoro-style work sessions with music 62 | - Ensuring playlists fit within specific time constraints 63 | 64 | **How it works**: The plugin calculates the total runtime of items in the playlist and stops adding items when the time limit is reached. The last item that would exceed the limit is not included, ensuring the playlist stays within your specified duration. 65 | 66 | This feature works with all media types (movies, TV shows, music) and uses the actual runtime of each item. -------------------------------------------------------------------------------- /Jellyfin.Plugin.SmartLists/Configuration/config-user-select.js: -------------------------------------------------------------------------------- 1 | (function (SmartLists) { 2 | 'use strict'; 3 | 4 | // Initialize namespace if it doesn't exist 5 | if (!window.SmartLists) { 6 | window.SmartLists = {}; 7 | SmartLists = window.SmartLists; 8 | } 9 | 10 | // ===== MULTI-SELECT USER COMPONENT ===== 11 | 12 | /** 13 | * Initialize the multi-select user component for playlists 14 | */ 15 | SmartLists.initializeUserMultiSelect = function (page) { 16 | // Use generic multi-select component 17 | SmartLists.initializeMultiSelect(page, { 18 | containerId: 'playlistUserMultiSelect', 19 | displayId: 'userMultiSelectDisplay', 20 | dropdownId: 'userMultiSelectDropdown', 21 | optionsId: 'userMultiSelectOptions', 22 | placeholderText: 'Select users...', 23 | checkboxClass: 'user-multi-select-checkbox', 24 | onChange: function (selectedValues) { 25 | SmartLists.updatePublicCheckboxVisibility(page); 26 | } 27 | }); 28 | }; 29 | 30 | /** 31 | * Load users into the multi-select component 32 | */ 33 | SmartLists.loadUsersIntoMultiSelect = function (page, users) { 34 | SmartLists.loadItemsIntoMultiSelect( 35 | page, 36 | 'playlistUserMultiSelect', 37 | users, 38 | 'user-multi-select-checkbox', 39 | function (user) { return user.Name || user.Username || user.Id; }, 40 | function (user) { return user.Id; } 41 | ); 42 | }; 43 | 44 | /** 45 | * Get array of selected user IDs 46 | */ 47 | SmartLists.getSelectedUserIds = function (page) { 48 | return SmartLists.getSelectedItems(page, 'playlistUserMultiSelect', 'user-multi-select-checkbox'); 49 | }; 50 | 51 | /** 52 | * Set selected users by user ID array 53 | */ 54 | SmartLists.setSelectedUserIds = function (page, userIds) { 55 | SmartLists.setSelectedItems(page, 'playlistUserMultiSelect', userIds, 'user-multi-select-checkbox', 'Select users...'); 56 | SmartLists.updatePublicCheckboxVisibility(page); 57 | }; 58 | 59 | /** 60 | * Update the display text showing selected users 61 | */ 62 | SmartLists.updateUserMultiSelectDisplay = function (page) { 63 | SmartLists.updateMultiSelectDisplay(page, 'playlistUserMultiSelect', 'Select users...', 'user-multi-select-checkbox'); 64 | }; 65 | 66 | /** 67 | * Update public checkbox visibility based on selected user count 68 | */ 69 | SmartLists.updatePublicCheckboxVisibility = function (page) { 70 | const listType = SmartLists.getElementValue(page, '#listType', 'Playlist'); 71 | const isCollection = listType === 'Collection'; 72 | if (isCollection) { 73 | // Collections don't have public checkbox 74 | return; 75 | } 76 | 77 | const userIds = SmartLists.getSelectedUserIds(page); 78 | const publicCheckboxContainer = page.querySelector('#publicCheckboxContainer'); 79 | 80 | if (publicCheckboxContainer) { 81 | if (userIds.length > 1) { 82 | // Hide public checkbox for multi-user playlists 83 | publicCheckboxContainer.style.display = 'none'; 84 | } else { 85 | // Show public checkbox for single-user playlists 86 | publicCheckboxContainer.style.display = ''; 87 | } 88 | } 89 | }; 90 | 91 | /** 92 | * Cleanup function to be called on page navigation to prevent memory leaks 93 | */ 94 | SmartLists.cleanupUserMultiSelect = function (page) { 95 | SmartLists.cleanupMultiSelect(page, 'playlistUserMultiSelect'); 96 | }; 97 | 98 | })(window.SmartLists); 99 | 100 | -------------------------------------------------------------------------------- /docs/content/index.md: -------------------------------------------------------------------------------- 1 | # Jellyfin SmartLists Plugin 2 | 3 |
4 |

5 | Logo
6 | Total GitHub Downloads 7 | GitHub Issues or Pull Requests 8 | Build and Release 9 | Jellyfin Version 10 |

11 |
12 | 13 | Create smart, rule-based playlists and collections in Jellyfin. 14 | 15 | This plugin allows you to create dynamic playlists and collections based on a set of rules, which will automatically update as your library changes. 16 | 17 | **Requires Jellyfin version `10.11.0` and newer.** 18 | 19 | ## Features 20 | 21 | - 🚀 **Modern Jellyfin Support** - Built for newer Jellyfin versions with improved compatibility 22 | - 🎨 **Modern Web Interface** - A full-featured UI to create, edit, view and delete smart playlists and collections 23 | - ✏️ **Edit Lists** - Modify existing smart playlists and collections directly from the UI 24 | - 👥 **Multi-User Playlists** - Create playlists for multiple users, with each user getting their own personalized version based on their playback data 25 | - 🎯 **Flexible Rules** - Build simple or complex rules with an intuitive builder 26 | - 🔄 **Automatic Updates** - Playlists and collections refresh automatically on library updates or via scheduled tasks 27 | - 📦 **Export/Import** - Export all lists to a ZIP file for backup or transfer between Jellyfin instances 28 | - 🎵 **Media Types** - Works with all Jellyfin media types 29 | 30 | ## Supported Media Types 31 | 32 | SmartLists works with all media types supported by Jellyfin: 33 | 34 | - **🎬 Movie** - Individual movie files 35 | - **📺 Series** - TV shows as a whole (can only be used when creating a Collection) 36 | - **📺 Episode** - Individual TV show episodes 37 | - **🎵 Audio (Music)** - Music tracks and albums 38 | - **🎬 Music Video** - Music video files 39 | - **📹 Video (Home Video)** - Personal home videos and recordings 40 | - **📸 Photo (Home Photo)** - Personal photos and images 41 | - **📚 Book** - eBooks, comics, and other readable content 42 | - **🎧 Audiobook** - Spoken word audio books 43 | 44 | ## Quick Start 45 | 46 | 1. **Install the Plugin**: See [Installation Guide](getting-started/installation.md) 47 | 2. **Access Plugin Settings**: Go to Dashboard → My Plugins → SmartLists 48 | 3. **Create Your First List**: Use the "Create List" tab 49 | 4. **Example**: Create a playlist or collection for "Unwatched Action Movies" with: 50 | - Media type: "Movie" 51 | - Genre contains "Action" 52 | - Playback Status = Unplayed 53 | 54 | For more detailed instructions, see the [Quick Start Guide](getting-started/quick-start.md). 55 | 56 | ## Overview 57 | 58 | This plugin creates smart playlists and collections that automatically update based on rules you define, such as: 59 | 60 | - **Unplayed movies** from specific genres 61 | - **Recently added** series or episodes 62 | - **Next unwatched episodes** for "Continue Watching" playlists 63 | - **High-rated** content from certain years 64 | - **Music** from specific artists or albums 65 | - **Tagged content** like "Christmas", "Kids", or "Documentaries" 66 | - And much more! 67 | 68 | The plugin features a modern web-based interface for easy list management - no technical knowledge required. -------------------------------------------------------------------------------- /Jellyfin.Plugin.SmartLists/Core/Orders/NameOrder.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text.RegularExpressions; 4 | using Jellyfin.Database.Implementations.Entities; 5 | using Jellyfin.Plugin.SmartLists.Core; 6 | using Jellyfin.Plugin.SmartLists.Services.Shared; 7 | using MediaBrowser.Controller.Entities; 8 | using MediaBrowser.Controller.Entities.Audio; 9 | using MediaBrowser.Controller.Entities.TV; 10 | using MediaBrowser.Controller.Library; 11 | using Microsoft.Extensions.Logging; 12 | 13 | namespace Jellyfin.Plugin.SmartLists.Core.Orders 14 | { 15 | /// 16 | /// Helper class for shared name sorting logic. 17 | /// 18 | internal static class NameSortHelper 19 | { 20 | // Compiled regex patterns for performance - avoids regex compilation overhead on every sort operation 21 | private static readonly Regex AudioAutoSortPattern = new(@"^\d+\s+-\s+", RegexOptions.Compiled); 22 | private static readonly Regex EpisodeAutoSortPattern = new(@"^\d{3,}\s+-\s+\d{4,}\s+-\s+", RegexOptions.Compiled); 23 | 24 | /// 25 | /// Gets the sort value for name-based sorting, handling auto-generated SortName patterns. 26 | /// 27 | internal static string GetNameSortValue(BaseItem item) 28 | { 29 | ArgumentNullException.ThrowIfNull(item); 30 | 31 | // For Audio items, SortName is often auto-generated as "0001 - Track Title" (track number - title). 32 | // If the user selected "Name", they likely want alphabetical by Title. 33 | // However, if the user MANUALLY set a SortName, we should respect it. 34 | // We detect auto-generated SortName by pattern: digits, space, hyphen, space. 35 | if (item is Audio && !string.IsNullOrEmpty(item.SortName) && 36 | AudioAutoSortPattern.IsMatch(item.SortName)) 37 | { 38 | return item.Name ?? ""; 39 | } 40 | 41 | // For Episodes, SortName is auto-generated as "001 - 0001 - Title", which forces chronological sort. 42 | // If the user selected "Name", they likely want alphabetical by Title. 43 | // However, if the user MANUALLY set a SortName (e.g. "A"), we should respect it. 44 | // We detect auto-generated SortName by pattern: 3+ digits, hyphen, 4+ digits, hyphen. 45 | if (item is Episode && !string.IsNullOrEmpty(item.SortName) && 46 | EpisodeAutoSortPattern.IsMatch(item.SortName)) 47 | { 48 | return item.Name ?? ""; 49 | } 50 | 51 | // Use SortName if set, otherwise fall back to Name 52 | return !string.IsNullOrEmpty(item.SortName) ? item.SortName : (item.Name ?? ""); 53 | } 54 | } 55 | 56 | public class NameOrder : PropertyOrder 57 | { 58 | public override string Name => "Name Ascending"; 59 | protected override bool IsDescending => false; 60 | protected override IComparer Comparer => OrderUtilities.SharedNaturalComparer; 61 | 62 | protected override string GetSortValue(BaseItem item, User? user = null, IUserDataManager? userDataManager = null, ILogger? logger = null, RefreshQueueService.RefreshCache? refreshCache = null) 63 | { 64 | return NameSortHelper.GetNameSortValue(item); 65 | } 66 | } 67 | 68 | public class NameOrderDesc : PropertyOrder 69 | { 70 | public override string Name => "Name Descending"; 71 | protected override bool IsDescending => true; 72 | protected override IComparer Comparer => OrderUtilities.SharedNaturalComparer; 73 | 74 | protected override string GetSortValue(BaseItem item, User? user = null, IUserDataManager? userDataManager = null, ILogger? logger = null, RefreshQueueService.RefreshCache? refreshCache = null) 75 | { 76 | return NameSortHelper.GetNameSortValue(item); 77 | } 78 | } 79 | } 80 | 81 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.SmartLists/Core/Orders/SeriesNameIgnoreArticlesOrder.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using Jellyfin.Database.Implementations.Entities; 4 | using Jellyfin.Plugin.SmartLists.Core; 5 | using Jellyfin.Plugin.SmartLists.Services.Shared; 6 | using MediaBrowser.Controller.Entities; 7 | using MediaBrowser.Controller.Entities.TV; 8 | using MediaBrowser.Controller.Library; 9 | using Microsoft.Extensions.Logging; 10 | 11 | namespace Jellyfin.Plugin.SmartLists.Core.Orders 12 | { 13 | public class SeriesNameIgnoreArticlesOrder : PropertyOrder 14 | { 15 | public override string Name => "SeriesName (Ignore Articles) Ascending"; 16 | protected override bool IsDescending => false; 17 | protected override IComparer Comparer => OrderUtilities.SharedNaturalComparer; 18 | 19 | protected override string GetSortValue( 20 | BaseItem item, 21 | User? user = null, 22 | IUserDataManager? userDataManager = null, 23 | ILogger? logger = null, 24 | RefreshQueueService.RefreshCache? refreshCache = null) 25 | { 26 | return ComputeSeriesNameIgnoreArticlesSortValue(item, refreshCache, logger); 27 | } 28 | 29 | /// 30 | /// Shared logic for computing series name with articles stripped 31 | /// 32 | public static string ComputeSeriesNameIgnoreArticlesSortValue( 33 | BaseItem item, 34 | RefreshQueueService.RefreshCache? refreshCache = null, 35 | ILogger? logger = null) 36 | { 37 | ArgumentNullException.ThrowIfNull(item); 38 | // Try to get Series SortName from cache first (for episodes) 39 | if (refreshCache != null && item is Episode episode && episode.SeriesId != Guid.Empty) 40 | { 41 | if (refreshCache.SeriesSortNameById.TryGetValue(episode.SeriesId, out var cachedSortName) && !string.IsNullOrEmpty(cachedSortName)) 42 | { 43 | return cachedSortName; 44 | } 45 | if (refreshCache.SeriesNameById.TryGetValue(episode.SeriesId, out var cachedName)) 46 | { 47 | return OrderUtilities.StripLeadingArticles(cachedName); 48 | } 49 | } 50 | 51 | // Fallback to item properties 52 | // Use SortName if set (as-is, without article stripping), otherwise strip articles from SeriesName 53 | if (!string.IsNullOrEmpty(item.SortName)) 54 | return item.SortName; 55 | 56 | try 57 | { 58 | // SeriesName property for episodes 59 | var seriesNameProperty = item.GetType().GetProperty("SeriesName"); 60 | if (seriesNameProperty != null) 61 | { 62 | var value = seriesNameProperty.GetValue(item); 63 | if (value is string seriesName) 64 | return OrderUtilities.StripLeadingArticles(seriesName ?? ""); 65 | } 66 | } 67 | catch (Exception ex) 68 | { 69 | logger?.LogWarning(ex, "Failed to retrieve SeriesName property via reflection for item {ItemId}", item.Id); 70 | } 71 | return ""; 72 | } 73 | } 74 | 75 | public class SeriesNameIgnoreArticlesOrderDesc : PropertyOrder 76 | { 77 | public override string Name => "SeriesName (Ignore Articles) Descending"; 78 | protected override bool IsDescending => true; 79 | protected override IComparer Comparer => OrderUtilities.SharedNaturalComparer; 80 | 81 | protected override string GetSortValue( 82 | BaseItem item, 83 | User? user = null, 84 | IUserDataManager? userDataManager = null, 85 | ILogger? logger = null, 86 | RefreshQueueService.RefreshCache? refreshCache = null) 87 | { 88 | return SeriesNameIgnoreArticlesOrder.ComputeSeriesNameIgnoreArticlesSortValue(item, refreshCache, logger); 89 | } 90 | } 91 | } 92 | 93 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.SmartLists/Core/QueryEngine/Expression.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace Jellyfin.Plugin.SmartLists.Core.QueryEngine 4 | { 5 | public class Expression(string memberName, string @operator, string targetValue) 6 | { 7 | public string MemberName { get; set; } = memberName; 8 | public string Operator { get; set; } = @operator; 9 | public string TargetValue { get; set; } = targetValue; 10 | 11 | // User-specific query support - only serialize when meaningful 12 | [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] 13 | public string? UserId { get; set; } = null; 14 | 15 | // NextUnwatched-specific option - only serialize when meaningful 16 | [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] 17 | public bool? IncludeUnwatchedSeries { get; set; } = null; 18 | 19 | // Collections-specific option - only serialize when meaningful 20 | [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] 21 | public bool? IncludeEpisodesWithinSeries { get; set; } = null; 22 | 23 | // Collections-specific option - only serialize when meaningful 24 | [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] 25 | public bool? IncludeCollectionOnly { get; set; } = null; 26 | 27 | // Playlists-specific option - only serialize when meaningful 28 | [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] 29 | public bool? IncludePlaylistOnly { get; set; } = null; 30 | 31 | // Tags-specific option - only serialize when meaningful 32 | [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] 33 | public bool? IncludeParentSeriesTags { get; set; } = null; 34 | 35 | // Studios-specific option - only serialize when meaningful 36 | [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] 37 | public bool? IncludeParentSeriesStudios { get; set; } = null; 38 | 39 | // Genres-specific option - only serialize when meaningful 40 | [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] 41 | public bool? IncludeParentSeriesGenres { get; set; } = null; 42 | 43 | // AudioLanguages-specific option - only serialize when meaningful 44 | [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] 45 | public bool? OnlyDefaultAudioLanguage { get; set; } = null; 46 | 47 | // Helper property to check if this is a user-specific expression 48 | // Only serialize when UserId is not null 49 | [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] 50 | public bool IsUserSpecific => !string.IsNullOrEmpty(UserId); 51 | 52 | // Helper property to get the user-specific field name for reflection 53 | // Only serialize when it's actually a user-specific field (different from MemberName) 54 | [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] 55 | public string? UserSpecificField => IsUserSpecific && IsUserSpecificField(MemberName) ? GetUserSpecificFieldName() : null; 56 | 57 | private string GetUserSpecificFieldName() 58 | { 59 | return MemberName switch 60 | { 61 | "PlaybackStatus" => "GetPlaybackStatusByUser", 62 | "IsPlayed" => "GetPlaybackStatusByUser", // Legacy field - treat as PlaybackStatus 63 | "PlayCount" => "GetPlayCountByUser", 64 | "IsFavorite" => "GetIsFavoriteByUser", 65 | "NextUnwatched" => "GetNextUnwatchedByUser", 66 | "LastPlayedDate" => "GetLastPlayedDateByUser", 67 | _ => MemberName, 68 | }; 69 | } 70 | 71 | public static bool IsUserSpecificField(string memberName) 72 | { 73 | return memberName switch 74 | { 75 | "PlaybackStatus" => true, 76 | "IsPlayed" => true, // Legacy field - treat as user-specific 77 | "PlayCount" => true, 78 | "IsFavorite" => true, 79 | "NextUnwatched" => true, 80 | "LastPlayedDate" => true, 81 | _ => false, 82 | }; 83 | } 84 | } 85 | } -------------------------------------------------------------------------------- /Jellyfin.Plugin.SmartLists/Configuration/PluginConfiguration.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Jellyfin.Plugin.SmartLists.Core.Enums; 3 | using MediaBrowser.Model.Plugins; 4 | 5 | namespace Jellyfin.Plugin.SmartLists.Configuration 6 | { 7 | public class PluginConfiguration : BasePluginConfiguration 8 | { 9 | /// 10 | /// Gets or sets the default sort order for new playlists. 11 | /// 12 | public string DefaultSortBy { get; set; } = "Name"; 13 | 14 | /// 15 | /// Gets or sets the default sort direction for new playlists. 16 | /// 17 | public string DefaultSortOrder { get; set; } = "Ascending"; 18 | 19 | /// 20 | /// Gets or sets the default list type for new lists (Playlist or Collection). 21 | /// 22 | public SmartListType DefaultListType { get; set; } = SmartListType.Playlist; 23 | 24 | /// 25 | /// Gets or sets whether new playlists should be public by default. 26 | /// 27 | public bool DefaultMakePublic { get; set; } = false; 28 | 29 | /// 30 | /// Gets or sets the default maximum number of items for new playlists. 31 | /// 32 | public int DefaultMaxItems { get; set; } = 500; 33 | 34 | /// 35 | /// Gets or sets the default maximum playtime in minutes for new playlists. 36 | /// 37 | public int DefaultMaxPlayTimeMinutes { get; set; } = 0; 38 | 39 | /// 40 | /// Gets or sets the prefix text to add to playlist names. 41 | /// Leave empty to not add a prefix. 42 | /// 43 | public string PlaylistNamePrefix { get; set; } = string.Empty; 44 | 45 | /// 46 | /// Gets or sets the suffix text to add to playlist names. 47 | /// Leave empty to not add a suffix. 48 | /// 49 | public string PlaylistNameSuffix { get; set; } = "[Smart]"; 50 | 51 | /// 52 | /// Gets or sets the default auto-refresh mode for new playlists. 53 | /// 54 | public AutoRefreshMode DefaultAutoRefresh { get; set; } = AutoRefreshMode.OnLibraryChanges; 55 | 56 | /// 57 | /// Gets or sets the default schedule trigger for new playlists. 58 | /// 59 | public ScheduleTrigger? DefaultScheduleTrigger { get; set; } = null; // No schedule by default 60 | 61 | /// 62 | /// Gets or sets the default schedule time for Daily/Weekly triggers. 63 | /// 64 | public TimeSpan DefaultScheduleTime { get; set; } = TimeSpan.FromHours(0); // Midnight (00:00) default 65 | 66 | /// 67 | /// Gets or sets the default day of week for Weekly triggers. 68 | /// 69 | public DayOfWeek DefaultScheduleDayOfWeek { get; set; } = DayOfWeek.Sunday; // Sunday default 70 | 71 | /// 72 | /// Gets or sets the default day of month for Monthly/Yearly triggers. 73 | /// 74 | public int DefaultScheduleDayOfMonth { get; set; } = 1; // 1st of month default 75 | 76 | /// 77 | /// Gets or sets the default month for Yearly triggers. 78 | /// 79 | public int DefaultScheduleMonth { get; set; } = 1; // January default 80 | 81 | /// 82 | /// Gets or sets the default interval for Interval triggers. 83 | /// 84 | public TimeSpan DefaultScheduleInterval { get; set; } = TimeSpan.FromMinutes(15); // 15 minutes default 85 | 86 | 87 | private int _processingBatchSize = 300; 88 | 89 | /// 90 | /// Gets or sets the processing batch size for list refreshes. 91 | /// Items are processed in batches of this size for memory management and progress reporting. 92 | /// Minimum value: 1 93 | /// Default: 300 94 | /// 95 | public int ProcessingBatchSize 96 | { 97 | get => _processingBatchSize; 98 | set => _processingBatchSize = value < 1 ? 300 : value; 99 | } 100 | } 101 | } -------------------------------------------------------------------------------- /Jellyfin.Plugin.SmartLists/.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig for SmartLists Plugin 2 | # Suppressions for Recommended analysis mode 3 | # Note: Some suppressions are necessary for plugin compatibility and code style preferences 4 | 5 | root = true 6 | 7 | [*.cs] 8 | 9 | # Suppress XML documentation warnings - not critical for internal plugin code 10 | dotnet_diagnostic.CS1591.severity = none 11 | dotnet_diagnostic.CS1573.severity = none 12 | 13 | # StyleCop: File header (not required for plugin code) 14 | dotnet_diagnostic.SA1633.severity = none 15 | 16 | # StyleCop: Using directives placement (preference to keep outside namespace) 17 | dotnet_diagnostic.SA1200.severity = none 18 | 19 | # StyleCop: Using directive ordering (non-critical formatting) 20 | dotnet_diagnostic.SA1208.severity = none 21 | dotnet_diagnostic.SA1209.severity = none 22 | dotnet_diagnostic.SA1210.severity = none 23 | 24 | # StyleCop: Blank line formatting (non-critical style preferences) 25 | dotnet_diagnostic.SA1507.severity = none 26 | dotnet_diagnostic.SA1512.severity = none 27 | dotnet_diagnostic.SA1513.severity = none 28 | dotnet_diagnostic.SA1515.severity = none 29 | dotnet_diagnostic.SA1518.severity = none 30 | 31 | # StyleCop: Whitespace formatting (non-critical) 32 | dotnet_diagnostic.SA1009.severity = none 33 | dotnet_diagnostic.SA1025.severity = none 34 | dotnet_diagnostic.SA1028.severity = none 35 | 36 | # StyleCop: File organization (design choice to have multiple types in one file) 37 | dotnet_diagnostic.SA1402.severity = none 38 | dotnet_diagnostic.SA1649.severity = none 39 | 40 | # StyleCop: Member ordering (non-critical organizational preference) 41 | dotnet_diagnostic.SA1201.severity = none 42 | dotnet_diagnostic.SA1202.severity = none 43 | 44 | # StyleCop: Parameter formatting (non-critical style preference) 45 | dotnet_diagnostic.SA1116.severity = none 46 | dotnet_diagnostic.SA1117.severity = none 47 | 48 | # StyleCop: this. prefix (style preference) 49 | dotnet_diagnostic.SA1101.severity = none 50 | 51 | # StyleCop: Braces and comment formatting (style preference) 52 | dotnet_diagnostic.SA1005.severity = none 53 | dotnet_diagnostic.SA1111.severity = none 54 | dotnet_diagnostic.SA1122.severity = none 55 | dotnet_diagnostic.SA1503.severity = none 56 | 57 | # StyleCop: Code style and conventions (style preferences) 58 | dotnet_diagnostic.SA1107.severity = none 59 | dotnet_diagnostic.SA1108.severity = none 60 | dotnet_diagnostic.SA1119.severity = none 61 | dotnet_diagnostic.SA1127.severity = none 62 | dotnet_diagnostic.SA1129.severity = none 63 | dotnet_diagnostic.SA1203.severity = none 64 | dotnet_diagnostic.SA1204.severity = none 65 | dotnet_diagnostic.SA1211.severity = none 66 | dotnet_diagnostic.SA1214.severity = none 67 | dotnet_diagnostic.SA1309.severity = none 68 | dotnet_diagnostic.SA1310.severity = none 69 | dotnet_diagnostic.SA1312.severity = none 70 | dotnet_diagnostic.SA1316.severity = none 71 | dotnet_diagnostic.SA1407.severity = none 72 | dotnet_diagnostic.SA1413.severity = none 73 | dotnet_diagnostic.SA1501.severity = none 74 | dotnet_diagnostic.SA1505.severity = none 75 | dotnet_diagnostic.SA1508.severity = none 76 | dotnet_diagnostic.SA1510.severity = none 77 | dotnet_diagnostic.SA1514.severity = none 78 | dotnet_diagnostic.SA1516.severity = none 79 | dotnet_diagnostic.SA1600.severity = none 80 | dotnet_diagnostic.SA1602.severity = none 81 | dotnet_diagnostic.SA1611.severity = none 82 | dotnet_diagnostic.SA1615.severity = none 83 | dotnet_diagnostic.SA1618.severity = none 84 | dotnet_diagnostic.SA1623.severity = none 85 | dotnet_diagnostic.SA1625.severity = none 86 | dotnet_diagnostic.SA1629.severity = none 87 | 88 | # Code Analysis: Performance and design 89 | # CA1001: Disposable pattern - Keep active to catch resource leaks 90 | # CA1305: IFormatProvider - Keep active to catch globalization issues 91 | # CA1825: Zero-length array allocations - Keep active to catch performance anti-patterns 92 | # Other CA rules suppressed for plugin compatibility or non-critical issues 93 | dotnet_diagnostic.CA1716.severity = none 94 | dotnet_diagnostic.CA1805.severity = none 95 | dotnet_diagnostic.CA1816.severity = none 96 | dotnet_diagnostic.CA1836.severity = none 97 | dotnet_diagnostic.CA1848.severity = none 98 | dotnet_diagnostic.CA1859.severity = none 99 | dotnet_diagnostic.CA1860.severity = none 100 | dotnet_diagnostic.CA1310.severity = none 101 | dotnet_diagnostic.CA2254.severity = none 102 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.SmartLists/Core/Orders/EpisodeNumberOrder.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using Jellyfin.Database.Implementations.Entities; 5 | using Jellyfin.Plugin.SmartLists.Core; 6 | using Jellyfin.Plugin.SmartLists.Services.Shared; 7 | using MediaBrowser.Controller.Entities; 8 | using MediaBrowser.Controller.Library; 9 | using Microsoft.Extensions.Logging; 10 | 11 | namespace Jellyfin.Plugin.SmartLists.Core.Orders 12 | { 13 | public class EpisodeNumberOrder : Order 14 | { 15 | public override string Name => "EpisodeNumber Ascending"; 16 | 17 | public override IEnumerable OrderBy(IEnumerable items) 18 | { 19 | if (items == null) return []; 20 | 21 | // Sort by Episode Number -> Season Number -> Name 22 | return items 23 | .OrderBy(item => OrderUtilities.GetEpisodeNumber(item)) 24 | .ThenBy(item => OrderUtilities.GetSeasonNumber(item)) 25 | .ThenBy(item => item.Name ?? "", OrderUtilities.SharedNaturalComparer); 26 | } 27 | 28 | public override IEnumerable OrderBy( 29 | IEnumerable items, 30 | User user, 31 | IUserDataManager? userDataManager, 32 | ILogger? logger, 33 | RefreshQueueService.RefreshCache? refreshCache = null) 34 | { 35 | // refreshCache not used for episode number ordering 36 | return OrderBy(items); 37 | } 38 | 39 | public override IComparable GetSortKey( 40 | BaseItem item, 41 | User user, 42 | IUserDataManager? userDataManager, 43 | ILogger? logger, 44 | Dictionary? itemRandomKeys = null, 45 | RefreshQueueService.RefreshCache? refreshCache = null) 46 | { 47 | // Return composite key matching OrderBy logic: EpisodeNumber -> SeasonNumber -> Name 48 | var episodeNumber = OrderUtilities.GetEpisodeNumber(item); 49 | var seasonNumber = OrderUtilities.GetSeasonNumber(item); 50 | var name = item.Name ?? ""; 51 | return new ComparableTuple4( 52 | episodeNumber, 53 | seasonNumber, 54 | name, 55 | "", // Fourth element not used, but ComparableTuple4 requires 4 elements 56 | comparer3: OrderUtilities.SharedNaturalComparer); 57 | } 58 | } 59 | 60 | public class EpisodeNumberOrderDesc : Order 61 | { 62 | public override string Name => "EpisodeNumber Descending"; 63 | 64 | public override IEnumerable OrderBy(IEnumerable items) 65 | { 66 | if (items == null) return []; 67 | 68 | // Sort by Episode Number (descending) -> Season Number (descending) -> Name (descending) 69 | return items 70 | .OrderByDescending(item => OrderUtilities.GetEpisodeNumber(item)) 71 | .ThenByDescending(item => OrderUtilities.GetSeasonNumber(item)) 72 | .ThenByDescending(item => item.Name ?? "", OrderUtilities.SharedNaturalComparer); 73 | } 74 | 75 | public override IEnumerable OrderBy( 76 | IEnumerable items, 77 | User user, 78 | IUserDataManager? userDataManager, 79 | ILogger? logger, 80 | RefreshQueueService.RefreshCache? refreshCache = null) 81 | { 82 | // refreshCache not used for episode number ordering 83 | return OrderBy(items); 84 | } 85 | 86 | public override IComparable GetSortKey( 87 | BaseItem item, 88 | User user, 89 | IUserDataManager? userDataManager, 90 | ILogger? logger, 91 | Dictionary? itemRandomKeys = null, 92 | RefreshQueueService.RefreshCache? refreshCache = null) 93 | { 94 | // Return composite key matching OrderBy logic: EpisodeNumber -> SeasonNumber -> Name 95 | // NOTE: Do NOT negate values here! The sorting direction is controlled by 96 | // OrderBy vs OrderByDescending in ApplyMultipleOrders for multi-level sorting. 97 | // Negating here would cause a double reversal. 98 | var episodeNumber = OrderUtilities.GetEpisodeNumber(item); 99 | var seasonNumber = OrderUtilities.GetSeasonNumber(item); 100 | var name = item.Name ?? ""; 101 | return new ComparableTuple4( 102 | episodeNumber, 103 | seasonNumber, 104 | name, 105 | "", // Fourth element not used, but ComparableTuple4 requires 4 elements 106 | comparer3: OrderUtilities.SharedNaturalComparer); 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.SmartLists/Core/Orders/SeriesNameOrder.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using Jellyfin.Database.Implementations.Entities; 4 | using Jellyfin.Plugin.SmartLists.Core; 5 | using Jellyfin.Plugin.SmartLists.Services.Shared; 6 | using MediaBrowser.Controller.Entities; 7 | using MediaBrowser.Controller.Entities.TV; 8 | using MediaBrowser.Controller.Library; 9 | using Microsoft.Extensions.Logging; 10 | 11 | namespace Jellyfin.Plugin.SmartLists.Core.Orders 12 | { 13 | public class SeriesNameOrder : PropertyOrder 14 | { 15 | public override string Name => "SeriesName Ascending"; 16 | protected override bool IsDescending => false; 17 | protected override IComparer Comparer => OrderUtilities.SharedNaturalComparer; 18 | 19 | protected override string GetSortValue( 20 | BaseItem item, 21 | User? user = null, 22 | IUserDataManager? userDataManager = null, 23 | ILogger? logger = null, 24 | RefreshQueueService.RefreshCache? refreshCache = null) 25 | { 26 | ArgumentNullException.ThrowIfNull(item); 27 | 28 | // Try to get Series Name from cache first (for episodes) 29 | if (refreshCache != null && item is Episode episode && episode.SeriesId != Guid.Empty) 30 | { 31 | // For strict SeriesName sort, we prefer the Display Name (e.g. "The IT Crowd") 32 | // This allows users to choose between "The IT Crowd" (SeriesName) and "IT Crowd" (IgnoreArticles/SortName) 33 | if (refreshCache.SeriesNameById.TryGetValue(episode.SeriesId, out var cachedName)) 34 | { 35 | return cachedName; 36 | } 37 | if (refreshCache.SeriesSortNameById.TryGetValue(episode.SeriesId, out var cachedSortName) && !string.IsNullOrEmpty(cachedSortName)) 38 | { 39 | return cachedSortName; 40 | } 41 | } 42 | 43 | // Fallback to item properties 44 | // Use SortName if set, otherwise fall back to SeriesName 45 | if (!string.IsNullOrEmpty(item.SortName)) 46 | return item.SortName; 47 | 48 | try 49 | { 50 | // SeriesName property for episodes 51 | var seriesNameProperty = item.GetType().GetProperty("SeriesName"); 52 | if (seriesNameProperty != null) 53 | { 54 | var value = seriesNameProperty.GetValue(item); 55 | if (value is string seriesName) 56 | return seriesName ?? ""; 57 | } 58 | } 59 | catch 60 | { 61 | // Ignore errors and return empty string 62 | } 63 | return ""; 64 | } 65 | } 66 | 67 | public class SeriesNameOrderDesc : PropertyOrder 68 | { 69 | public override string Name => "SeriesName Descending"; 70 | protected override bool IsDescending => true; 71 | protected override IComparer Comparer => OrderUtilities.SharedNaturalComparer; 72 | 73 | protected override string GetSortValue( 74 | BaseItem item, 75 | User? user = null, 76 | IUserDataManager? userDataManager = null, 77 | ILogger? logger = null, 78 | RefreshQueueService.RefreshCache? refreshCache = null) 79 | { 80 | ArgumentNullException.ThrowIfNull(item); 81 | 82 | // Try to get Series Name from cache first (for episodes) 83 | if (refreshCache != null && item is Episode episode && episode.SeriesId != Guid.Empty) 84 | { 85 | // For strict SeriesName sort, we prefer the Display Name (e.g. "The IT Crowd") 86 | // This allows users to choose between "The IT Crowd" (SeriesName) and "IT Crowd" (IgnoreArticles/SortName) 87 | if (refreshCache.SeriesNameById.TryGetValue(episode.SeriesId, out var cachedName)) 88 | { 89 | return cachedName; 90 | } 91 | if (refreshCache.SeriesSortNameById.TryGetValue(episode.SeriesId, out var cachedSortName) && !string.IsNullOrEmpty(cachedSortName)) 92 | { 93 | return cachedSortName; 94 | } 95 | } 96 | 97 | // Fallback to item properties 98 | // Use SortName if set, otherwise fall back to SeriesName 99 | if (!string.IsNullOrEmpty(item.SortName)) 100 | return item.SortName; 101 | 102 | try 103 | { 104 | // SeriesName property for episodes 105 | var seriesNameProperty = item.GetType().GetProperty("SeriesName"); 106 | if (seriesNameProperty != null) 107 | { 108 | var value = seriesNameProperty.GetValue(item); 109 | if (value is string seriesName) 110 | return seriesName ?? ""; 111 | } 112 | } 113 | catch 114 | { 115 | // Ignore errors and return empty string 116 | } 117 | return ""; 118 | } 119 | } 120 | } 121 | 122 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.SmartLists/Core/Orders/TrackNumberOrder.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using Jellyfin.Database.Implementations.Entities; 5 | using Jellyfin.Plugin.SmartLists.Core; 6 | using Jellyfin.Plugin.SmartLists.Services.Shared; 7 | using MediaBrowser.Controller.Entities; 8 | using MediaBrowser.Controller.Library; 9 | using Microsoft.Extensions.Logging; 10 | 11 | namespace Jellyfin.Plugin.SmartLists.Core.Orders 12 | { 13 | public class TrackNumberOrder : Order 14 | { 15 | public override string Name => "TrackNumber Ascending"; 16 | 17 | public override IEnumerable OrderBy(IEnumerable items) 18 | { 19 | if (items == null) return []; 20 | 21 | // Sort by Album -> Disc Number -> Track Number -> Name 22 | return items 23 | .OrderBy(item => item.Album ?? "", OrderUtilities.SharedNaturalComparer) 24 | .ThenBy(item => OrderUtilities.GetDiscNumber(item)) 25 | .ThenBy(item => OrderUtilities.GetTrackNumber(item)) 26 | .ThenBy(item => item.Name ?? "", OrderUtilities.SharedNaturalComparer); 27 | } 28 | 29 | public override IEnumerable OrderBy( 30 | IEnumerable items, 31 | User user, 32 | IUserDataManager? userDataManager, 33 | ILogger? logger, 34 | RefreshQueueService.RefreshCache? refreshCache = null) 35 | { 36 | // refreshCache not used for track number ordering 37 | return OrderBy(items); 38 | } 39 | 40 | public override IComparable GetSortKey( 41 | BaseItem item, 42 | User user, 43 | IUserDataManager? userDataManager, 44 | ILogger? logger, 45 | Dictionary? itemRandomKeys = null, 46 | RefreshQueueService.RefreshCache? refreshCache = null) 47 | { 48 | // For TrackNumberOrder - complex multi-level sort: Album -> Disc -> Track -> Name 49 | var album = item.Album ?? ""; 50 | var discNumber = OrderUtilities.GetDiscNumber(item); 51 | var trackNumber = OrderUtilities.GetTrackNumber(item); 52 | var name = item.Name ?? ""; 53 | 54 | // FIX: Pass SharedNaturalComparer for both album AND name to match OrderBy behavior 55 | return new ComparableTuple4( 56 | album, discNumber, trackNumber, name, 57 | OrderUtilities.SharedNaturalComparer, // for album 58 | null, // for discNumber (int uses default) 59 | null, // for trackNumber (int uses default) 60 | OrderUtilities.SharedNaturalComparer // for name - THIS WAS MISSING 61 | ); 62 | } 63 | } 64 | 65 | public class TrackNumberOrderDesc : Order 66 | { 67 | public override string Name => "TrackNumber Descending"; 68 | 69 | public override IEnumerable OrderBy(IEnumerable items) 70 | { 71 | if (items == null) return []; 72 | 73 | // Sort by Album (descending) -> Disc Number (descending) -> Track Number (descending) -> Name (descending) 74 | return items 75 | .OrderByDescending(item => item.Album ?? "", OrderUtilities.SharedNaturalComparer) 76 | .ThenByDescending(item => OrderUtilities.GetDiscNumber(item)) 77 | .ThenByDescending(item => OrderUtilities.GetTrackNumber(item)) 78 | .ThenByDescending(item => item.Name ?? "", OrderUtilities.SharedNaturalComparer); 79 | } 80 | 81 | public override IEnumerable OrderBy( 82 | IEnumerable items, 83 | User user, 84 | IUserDataManager? userDataManager, 85 | ILogger? logger, 86 | RefreshQueueService.RefreshCache? refreshCache = null) 87 | { 88 | // refreshCache not used for track number ordering 89 | return OrderBy(items); 90 | } 91 | 92 | public override IComparable GetSortKey( 93 | BaseItem item, 94 | User user, 95 | IUserDataManager? userDataManager, 96 | ILogger? logger, 97 | Dictionary? itemRandomKeys = null, 98 | RefreshQueueService.RefreshCache? refreshCache = null) 99 | { 100 | // For TrackNumberOrder - complex multi-level sort: Album -> Disc -> Track -> Name 101 | var album = item.Album ?? ""; 102 | var discNumber = OrderUtilities.GetDiscNumber(item); 103 | var trackNumber = OrderUtilities.GetTrackNumber(item); 104 | var name = item.Name ?? ""; 105 | 106 | // FIX: Pass SharedNaturalComparer for both album AND name to match OrderBy behavior 107 | return new ComparableTuple4( 108 | album, discNumber, trackNumber, name, 109 | OrderUtilities.SharedNaturalComparer, // for album 110 | null, // for discNumber (int uses default) 111 | null, // for trackNumber (int uses default) 112 | OrderUtilities.SharedNaturalComparer // for name - THIS WAS MISSING 113 | ); 114 | } 115 | } 116 | } 117 | 118 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.SmartLists/Core/Orders/ReleaseDateOrder.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using Jellyfin.Database.Implementations.Entities; 5 | using Jellyfin.Plugin.SmartLists.Core; 6 | using Jellyfin.Plugin.SmartLists.Services.Shared; 7 | using MediaBrowser.Controller.Entities; 8 | using MediaBrowser.Controller.Library; 9 | using Microsoft.Extensions.Logging; 10 | 11 | namespace Jellyfin.Plugin.SmartLists.Core.Orders 12 | { 13 | public class ReleaseDateOrder : Order 14 | { 15 | public override string Name => "ReleaseDate Ascending"; 16 | 17 | public override IEnumerable OrderBy(IEnumerable items) 18 | { 19 | if (items == null) return []; 20 | 21 | // Sort by release date (day precision), then within the same day: episodes first, then by season/episode 22 | return items 23 | .OrderBy(item => OrderUtilities.GetReleaseDate(item).Date) 24 | .ThenBy(item => OrderUtilities.IsEpisode(item) ? 0 : 1) // Episodes first within same date 25 | .ThenBy(item => OrderUtilities.IsEpisode(item) ? OrderUtilities.GetSeasonNumber(item) : 0) 26 | .ThenBy(item => OrderUtilities.IsEpisode(item) ? OrderUtilities.GetEpisodeNumber(item) : 0); 27 | } 28 | 29 | public override IEnumerable OrderBy( 30 | IEnumerable items, 31 | User user, 32 | IUserDataManager? userDataManager, 33 | ILogger? logger, 34 | RefreshQueueService.RefreshCache? refreshCache = null) 35 | { 36 | // refreshCache not used for release date ordering 37 | return OrderBy(items); 38 | } 39 | 40 | public override IComparable GetSortKey( 41 | BaseItem item, 42 | User user, 43 | IUserDataManager? userDataManager, 44 | ILogger? logger, 45 | Dictionary? itemRandomKeys = null, 46 | RefreshQueueService.RefreshCache? refreshCache = null) 47 | { 48 | // For ReleaseDateOrder - complex multi-level sort: ReleaseDate -> IsEpisode -> Season -> Episode 49 | var releaseDate = OrderUtilities.GetReleaseDate(item).Date.Ticks; 50 | // For ascending order, episodes come first 51 | var isEpisode = OrderUtilities.IsEpisode(item) ? 0 : 1; // Episodes first for ascending 52 | var seasonNumber = OrderUtilities.IsEpisode(item) ? OrderUtilities.GetSeasonNumber(item) : 0; 53 | var episodeNumber = OrderUtilities.IsEpisode(item) ? OrderUtilities.GetEpisodeNumber(item) : 0; 54 | return new ComparableTuple4(releaseDate, isEpisode, seasonNumber, episodeNumber); 55 | } 56 | } 57 | 58 | public class ReleaseDateOrderDesc : Order 59 | { 60 | public override string Name => "ReleaseDate Descending"; 61 | 62 | public override IEnumerable OrderBy(IEnumerable items) 63 | { 64 | if (items == null) return []; 65 | 66 | // Sort by release date (day precision) descending; within same day, episodes first then season/episode descending 67 | return items 68 | .OrderByDescending(item => OrderUtilities.GetReleaseDate(item).Date) 69 | .ThenBy(item => OrderUtilities.IsEpisode(item) ? 0 : 1) // Episodes first within same date 70 | .ThenByDescending(item => OrderUtilities.IsEpisode(item) ? OrderUtilities.GetSeasonNumber(item) : 0) 71 | .ThenByDescending(item => OrderUtilities.IsEpisode(item) ? OrderUtilities.GetEpisodeNumber(item) : 0); 72 | } 73 | 74 | public override IEnumerable OrderBy( 75 | IEnumerable items, 76 | User user, 77 | IUserDataManager? userDataManager, 78 | ILogger? logger, 79 | RefreshQueueService.RefreshCache? refreshCache = null) 80 | { 81 | // refreshCache not used for release date ordering 82 | return OrderBy(items); 83 | } 84 | 85 | public override IComparable GetSortKey( 86 | BaseItem item, 87 | User user, 88 | IUserDataManager? userDataManager, 89 | ILogger? logger, 90 | Dictionary? itemRandomKeys = null, 91 | RefreshQueueService.RefreshCache? refreshCache = null) 92 | { 93 | // For ReleaseDateOrder - complex multi-level sort: ReleaseDate -> IsEpisode -> Season -> Episode 94 | var releaseDate = OrderUtilities.GetReleaseDate(item).Date.Ticks; 95 | // For descending order, flip the episode marker so episodes still come first when sorted descending 96 | // Original uses .ThenBy() even for descending, we need to account for that 97 | var isEpisode = OrderUtilities.IsEpisode(item) ? 1 : 0; // Flip for descending so ThenByDescending still puts episodes first, 98 | var seasonNumber = OrderUtilities.IsEpisode(item) ? OrderUtilities.GetSeasonNumber(item) : 0; 99 | var episodeNumber = OrderUtilities.IsEpisode(item) ? OrderUtilities.GetEpisodeNumber(item) : 0; 100 | return new ComparableTuple4(releaseDate, isEpisode, seasonNumber, episodeNumber); 101 | } 102 | } 103 | } 104 | 105 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.SmartLists/Services/Shared/AutoRefreshHostedService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | using Jellyfin.Plugin.SmartLists.Services.Playlists; 5 | using Jellyfin.Plugin.SmartLists.Services.Collections; 6 | using Jellyfin.Plugin.SmartLists.Services.Shared; 7 | using MediaBrowser.Controller; 8 | using MediaBrowser.Controller.Collections; 9 | using MediaBrowser.Controller.Library; 10 | using MediaBrowser.Controller.Playlists; 11 | using MediaBrowser.Controller.Providers; 12 | using Microsoft.Extensions.DependencyInjection; 13 | using Microsoft.Extensions.Hosting; 14 | using Microsoft.Extensions.Logging; 15 | 16 | namespace Jellyfin.Plugin.SmartLists.Services.Shared 17 | { 18 | /// 19 | /// Hosted service that initializes the AutoRefreshService when Jellyfin starts. 20 | /// 21 | public class AutoRefreshHostedService : IHostedService, IDisposable 22 | { 23 | private readonly IServiceProvider _serviceProvider; 24 | private readonly ILogger _logger; 25 | private AutoRefreshService? _autoRefreshService; 26 | 27 | public AutoRefreshHostedService(IServiceProvider serviceProvider, ILogger logger) 28 | { 29 | _serviceProvider = serviceProvider; 30 | _logger = logger; 31 | } 32 | 33 | public Task StartAsync(CancellationToken cancellationToken) 34 | { 35 | try 36 | { 37 | _logger.LogInformation("Starting SmartLists AutoRefreshService..."); 38 | 39 | // Get required services from DI container 40 | var libraryManager = _serviceProvider.GetRequiredService(); 41 | var userManager = _serviceProvider.GetRequiredService(); 42 | var playlistManager = _serviceProvider.GetRequiredService(); 43 | var collectionManager = _serviceProvider.GetRequiredService(); 44 | var userDataManager = _serviceProvider.GetRequiredService(); 45 | var providerManager = _serviceProvider.GetRequiredService(); 46 | var serverApplicationPaths = _serviceProvider.GetRequiredService(); 47 | var loggerFactory = _serviceProvider.GetRequiredService(); 48 | 49 | var autoRefreshLogger = loggerFactory.CreateLogger(); 50 | var playlistServiceLogger = loggerFactory.CreateLogger(); 51 | 52 | var fileSystem = new SmartListFileSystem(serverApplicationPaths); 53 | var playlistStore = new PlaylistStore(fileSystem); 54 | var playlistService = new PlaylistService(userManager, libraryManager, playlistManager, userDataManager, playlistServiceLogger, providerManager); 55 | 56 | var collectionServiceLogger = loggerFactory.CreateLogger(); 57 | var collectionStore = new CollectionStore(fileSystem); 58 | var collectionService = new CollectionService(libraryManager, collectionManager, userManager, userDataManager, collectionServiceLogger, providerManager); 59 | 60 | // Get RefreshStatusService from DI - it should be registered as singleton 61 | // Use GetRequiredService to prevent creating duplicate instances that cause split-brain state 62 | var refreshStatusService = _serviceProvider.GetRequiredService(); 63 | 64 | // Get RefreshQueueService from DI - it's registered as singleton and required 65 | var refreshQueueService = _serviceProvider.GetRequiredService(); 66 | 67 | _autoRefreshService = new AutoRefreshService(libraryManager, autoRefreshLogger, playlistStore, playlistService, collectionStore, collectionService, userDataManager, userManager, refreshQueueService, refreshStatusService); 68 | 69 | _logger.LogInformation("SmartLists AutoRefreshService started successfully (schedule timer initialized)"); 70 | } 71 | catch (Exception ex) 72 | { 73 | _logger.LogError(ex, "Failed to start AutoRefreshService"); 74 | } 75 | 76 | return Task.CompletedTask; 77 | } 78 | 79 | public Task StopAsync(CancellationToken cancellationToken) 80 | { 81 | try 82 | { 83 | _logger.LogInformation("Stopping SmartLists AutoRefreshService..."); 84 | _autoRefreshService?.Dispose(); 85 | _autoRefreshService = null; 86 | _logger.LogInformation("SmartLists AutoRefreshService stopped successfully"); 87 | } 88 | catch (Exception ex) 89 | { 90 | _logger.LogError(ex, "Error stopping AutoRefreshService"); 91 | } 92 | 93 | return Task.CompletedTask; 94 | } 95 | 96 | /// 97 | /// Disposes the hosted service and cleans up resources. 98 | /// 99 | public void Dispose() 100 | { 101 | _autoRefreshService?.Dispose(); 102 | _autoRefreshService = null; 103 | } 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.SmartLists/Core/Models/SmartListDto.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text.Json.Serialization; 5 | using Jellyfin.Plugin.SmartLists.Core.Enums; 6 | using Jellyfin.Plugin.SmartLists.Core.QueryEngine; 7 | 8 | namespace Jellyfin.Plugin.SmartLists.Core.Models 9 | { 10 | /// 11 | /// Base class for all smart lists (Playlists and Collections) 12 | /// Contains all shared properties and logic 13 | /// 14 | [Serializable] 15 | [JsonPolymorphic(TypeDiscriminatorPropertyName = "Type")] 16 | [JsonDerivedType(typeof(SmartPlaylistDto), typeDiscriminator: "Playlist")] 17 | [JsonDerivedType(typeof(SmartCollectionDto), typeDiscriminator: "Collection")] 18 | public abstract class SmartListDto 19 | { 20 | /// 21 | /// Type discriminator - determines if this is a Playlist or Collection 22 | /// 23 | public SmartListType Type { get; set; } 24 | 25 | // Core identification 26 | // Id is optional for creation (generated if not provided) 27 | [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] 28 | public string? Id { get; set; } 29 | public required string Name { get; set; } 30 | [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] 31 | public string? FileName { get; set; } 32 | 33 | /// 34 | /// Owner user ID - the user this list belongs to or whose context is used for rule evaluation 35 | /// For playlists using UserPlaylists array, this will be null. For collections, this contains the owner user ID. 36 | /// 37 | [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] 38 | public string? UserId { get; set; } 39 | 40 | // Query and filtering 41 | public List ExpressionSets { get; set; } = []; 42 | // Order is optional for creation (initialized if not provided) 43 | [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] 44 | public OrderDto? Order { get; set; } 45 | 46 | private List _mediaTypes = []; 47 | 48 | /// 49 | /// Pre-filter media types with validation to prevent corruption 50 | /// 51 | public List MediaTypes 52 | { 53 | get => _mediaTypes; 54 | set 55 | { 56 | var source = value ?? []; 57 | // Keep only known types and remove duplicates (ordinal) 58 | // Filter out nulls before ContainsKey check to prevent ArgumentNullException 59 | _mediaTypes = source 60 | .Where(mt => mt != null && Core.Constants.MediaTypes.MediaTypeToBaseItemKind.ContainsKey(mt)) 61 | .Distinct(StringComparer.Ordinal) 62 | .ToList(); 63 | } 64 | } 65 | 66 | // State and limits 67 | public bool Enabled { get; set; } = true; // Default to enabled 68 | public int? MaxItems { get; set; } // Nullable to support backwards compatibility 69 | public int? MaxPlayTimeMinutes { get; set; } // Nullable to support backwards compatibility 70 | 71 | // Auto-refresh 72 | public AutoRefreshMode AutoRefresh { get; set; } = AutoRefreshMode.Never; // Default to never for backward compatibility 73 | 74 | // Scheduling 75 | [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] 76 | public List Schedules { get; set; } = []; 77 | 78 | // Timestamps 79 | [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] 80 | public DateTime? LastRefreshed { get; set; } 81 | [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] 82 | public DateTime? DateCreated { get; set; } 83 | 84 | // Statistics (calculated during refresh) 85 | [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] 86 | public int? ItemCount { get; set; } 87 | [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] 88 | public double? TotalRuntimeMinutes { get; set; } 89 | 90 | // Similarity comparison fields 91 | [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] 92 | public List SimilarityComparisonFields { get; set; } = []; 93 | 94 | /// 95 | /// Migrates legacy IsPlayed rules to PlaybackStatus. 96 | /// Called after deserialization. 97 | /// 98 | public void MigrateLegacyFields() 99 | { 100 | if (ExpressionSets != null) 101 | { 102 | foreach (var expressionSet in ExpressionSets) 103 | { 104 | if (expressionSet.Expressions != null) 105 | { 106 | foreach (var expression in expressionSet.Expressions) 107 | { 108 | if (expression.MemberName == "IsPlayed") 109 | { 110 | expression.MemberName = "PlaybackStatus"; 111 | expression.TargetValue = expression.TargetValue == "true" ? "Played" : "Unplayed"; 112 | } 113 | } 114 | } 115 | } 116 | } 117 | } 118 | } 119 | } 120 | 121 | -------------------------------------------------------------------------------- /docs/content/examples/common-use-cases.md: -------------------------------------------------------------------------------- 1 | # Common Use Cases 2 | 3 | Here are some popular playlist and collection types you can create: 4 | 5 | ## TV Shows & Movies 6 | 7 | ### Continue Watching 8 | - **Next Unwatched** = True 9 | - Shows next episodes to watch for each series 10 | 11 | ### Family Continue Watching (Multi-User) 12 | - **Next Unwatched** = True (no user selected - uses each playlist user's data) 13 | - **Users**: Select multiple family members 14 | - Each family member gets their own personalized "Continue Watching" playlist showing their next unwatched episodes 15 | - Auto-refreshes when any family member watches an episode 16 | 17 | ### Family Movie Night 18 | - **Next Unwatched** = True AND **Parental Rating** = "PG" or "G" 19 | 20 | ### Unwatched Action Movies 21 | - **Playback Status** = Unplayed AND **Genre** contains "Action" 22 | 23 | ### Continue Watching (In Progress) 24 | - **Playback Status** = In Progress 25 | - Shows all movies and episodes that have been started but not finished 26 | - Perfect for picking up where you left off 27 | 28 | ### Recent Additions 29 | - **Date Created** newer than "2 weeks" 30 | 31 | ### Holiday Classics 32 | - **Tags** contain "Christmas" AND **Production Year** before "2000" 33 | 34 | ### Complete Franchise Collection 35 | - **Collections** contains "Movie Franchise" (includes all movies in the franchise) 36 | - **Note**: For Playlists, this fetches all media items from within the collection. For Collections, you can optionally enable "Include collection only" to create a meta-collection that contains the collection object itself 37 | 38 | ### Meta-Collection (Collection of Collections) 39 | - **Collections** is in "Marvel;DC;Star Wars" with "Include collection only" enabled 40 | - **List Type**: Collection 41 | - **Note**: When "Include collection only" is enabled, your selected media types are ignored, and the collection will contain the actual collection objects rather than the media items within them 42 | - Creates a single collection that organizes multiple collections together (e.g., a "Superhero Universes" collection containing your Marvel, DC, and other superhero collections) 43 | - **Important**: The smart collection will never include itself in the results, even if its name matches the rule. So you can safely name your meta-collection "Superhero Universes" and use rules that match "Marvel" without worrying about it including itself 44 | 45 | ### Combine Multiple Playlists 46 | - **Playlists** is in "Favorites;Top Rated;Recent Additions" 47 | - **List Type**: Playlist 48 | - **Note**: For playlists, this fetches all media items from within the specified playlists and combines them into a single "super playlist" 49 | - Creates a playlist that merges content from multiple existing playlists 50 | - Perfect for creating aggregated playlists like "Best of All Time" that combines your various curated playlists 51 | - **Important**: Only playlists you own or that are marked as public are accessible. The smart playlist will never include itself in the results. 52 | 53 | ### Playlist Organization Collection 54 | - **Playlists** contains "workout" with "Include playlist only" enabled 55 | - **List Type**: Collection 56 | - **Note**: When "Include playlist only" is enabled, the collection contains the actual playlist objects (not the media items within them) 57 | - Creates a collection that organizes your playlists by category (e.g., a "Workout Playlists" collection containing all your workout-related playlists) 58 | - Useful for managing large numbers of playlists by grouping them into categories 59 | - **Important**: The smart collection will never include itself, and only playlists you own or that are public are accessible 60 | 61 | ### Unplayed Sitcom Episodes 62 | - **Tags** contains "Sitcom" (with parent series tags enabled) AND **Playback Status** = Unplayed 63 | 64 | ## Music 65 | 66 | ### Workout Mix 67 | - **Genre** contains "Electronic" OR "Rock" AND **Max Playtime** 45 minutes 68 | 69 | ### Discover New Music 70 | - **Play Count** = 0 AND **Date Created** newer than "1 month" 71 | 72 | ### Top Rated Favorites 73 | - **Is Favorite** = True AND **Community Rating** greater than 8 74 | 75 | ### Rediscover Music 76 | - **Last Played** older than 6 months 77 | 78 | ### Family Favorites Playlist (Multi-User) 79 | - **Is Favorite** = True (no user selected - uses each playlist user's data) 80 | - **Users**: Select multiple family members (e.g., "Mom", "Dad", "Alice", "Bob") 81 | - Each family member gets their own personalized playlist showing their favorites 82 | - Auto-refreshes when any family member marks/unmarks items as favorites 83 | 84 | ## Home Videos & Photos 85 | 86 | ### Recent Family Memories 87 | - **Date Created** newer than "3 months" (both videos and photos) 88 | 89 | ### Vacation Videos Only 90 | - **Tags** contain "Vacation" (select Home Videos media type) 91 | 92 | ### Photo Slideshow 93 | - **Production Year** = 2024 (select Home Photos media type) 94 | 95 | ### Birthday Memories 96 | - **File Name** contains "birthday" OR **Tags** contain "Birthday" 97 | 98 | ## Collections 99 | 100 | Collections are great for organizing related content that you want to browse together: 101 | 102 | ### Action Movie Collection 103 | - **Genre** contains "Action" 104 | - **Media Type**: Movie 105 | - **List Type**: Collection 106 | - Creates a collection that groups all action movies together for easy browsing 107 | 108 | ### Holiday Collection 109 | - **Tags** contain "Christmas" OR "Holiday" 110 | - **List Type**: Collection 111 | - Groups all holiday-themed content (movies, TV shows, music) into one collection 112 | 113 | ### Director's Collection 114 | - **People** contains "Christopher Nolan" (Director role) 115 | - **List Type**: Collection 116 | - Creates a collection of all movies by a specific director 117 | 118 | -------------------------------------------------------------------------------- /docs/content/user-guide/user-selection.md: -------------------------------------------------------------------------------- 1 | # User Selection 2 | 3 | When creating a smart list, you must select one or more users. How user selection works differs significantly between **Playlists** and **Collections**. 4 | 5 | ## Playlists: Multi-User Support 6 | 7 | For **Playlists**, you can select one or more users who will be the **owners** of the playlist: 8 | 9 | ### Single User 10 | - When you select a single user, one Jellyfin playlist is created and owned by that user 11 | - The playlist is filtered based on that user's data (watch status, favorites, play count, etc.) 12 | - The playlist appears in that user's library 13 | 14 | ### Multiple Users 15 | - When you select multiple users, a **separate Jellyfin playlist is created for each user** 16 | - Each user gets their own personalized version of the same smart playlist 17 | - Each playlist is filtered based on the respective user's own data 18 | - This allows the same smart playlist configuration to show different content for each user 19 | 20 | !!! example "Multi-User Playlist Example" 21 | If you create a "My Favorites" playlist and select three users (Alice, Bob, and Charlie): 22 | 23 | - Alice will see a playlist containing her favorite items 24 | - Bob will see a playlist containing his favorite items 25 | - Charlie will see a playlist containing his favorite items 26 | 27 | Each user sees only their own favorites, even though they're all using the same smart playlist configuration. 28 | 29 | !!! note "User-Specific Rules Must Use 'Default' Target" 30 | For multi-user playlists to work correctly with personalized content, user-specific rules (like "Is Favorite", "Playback Status", "Play Count", etc.) must have their user target set to **"Default"**. 31 | 32 | - **Default**: The rule will be evaluated for each playlist owner individually, creating personalized content 33 | - **Specific User**: If you change the rule to target a specific user (e.g., "Is Favorite for Alice"), then all playlists will use Alice's data. 34 | 35 | When creating rules, the user target dropdown defaults to "Default" - keep it that way for multi-user personalization to work as expected. 36 | 37 | ### Visibility Settings 38 | 39 | Playlists also have a **"Make playlist public"** option: 40 | 41 | - **Private (unchecked)**: The playlist is only visible to the selected user(s) 42 | - **Public (checked)**: The playlist is visible to all logged-in users on the server, but the content is still based on the owner's data 43 | 44 | !!! note "Public Playlists with Multiple Users" 45 | When you select multiple users for a playlist, the "Make playlist public" option is automatically hidden and disabled. This is because each user gets their own separate playlist, and it wouldn't make sense for one user's personalized playlist to be visible to others. 46 | 47 | ## Collections: Reference User 48 | 49 | For **Collections**, the user selection works differently because collections don't have "owners": 50 | 51 | ### No Ownership 52 | - Collections are **server-wide** and visible to all users 53 | - There is no concept of a collection "owner" 54 | - All users can see the same collection 55 | 56 | ### Reference User for Filtering 57 | - The user you select is used as a **reference** when fetching and filtering media items 58 | - This user's context is used for: 59 | - **Library access permissions**: Only items this user has access to will be included 60 | - **User-specific rules**: Rules like "Playback Status", "Is Favorite", "Play Count", etc. are evaluated based on this user's data (unless you specifically choose a different user in the rule) 61 | - **User-specific fields**: Any user-dependent filtering uses this user's context (unless you specifically choose a different user in the rule) 62 | 63 | !!! example "Collection Reference User Example" 64 | If you create a "Recently Watched Movies" collection and select Bob as the list user: 65 | 66 | - The collection will only include movies Bob has access to in his libraries 67 | - The "recently watched" status is based on Bob's watch history 68 | - All users can see this collection, but the content is determined by Bob's data 69 | 70 | This means Alice and Charlie will see the same collection showing movies that Bob recently watched, not their own recently watched movies. 71 | 72 | !!! warning "Choosing the Right Reference User" 73 | When creating collections with user-specific rules, carefully consider which user to select as the reference: 74 | 75 | - For collections based on user-specific data (favorites, watch status, etc.), select a user whose data represents what you want to show to everyone 76 | - For collections based only on metadata (genre, year, rating, etc.), the user selection matters less, but you should still select a user who has access to all the content you want to include 77 | 78 | ## Summary 79 | 80 | | Feature | Playlists | Collections | 81 | |---------|-----------|-------------| 82 | | **User Selection** | One or more users | Single reference user | 83 | | **Ownership** | Each selected user owns their playlist | No ownership (server-wide) | 84 | | **Visibility** | Private or public | Always visible to all users | 85 | | **Content Filtering** | Based on each owner's data | Based on reference user's data | 86 | | **Multiple Instances** | One playlist per selected user | Single collection for all users | 87 | 88 | ## Selecting Users 89 | 90 | In the SmartLists configuration interface: 91 | 92 | ### For Playlists 93 | 1. Click on the **Playlist User(s)** multi-select dropdown 94 | 2. Check the boxes for the users you want to create playlists for 95 | 3. At least one user must be selected 96 | 4. Each selected user will get their own personalized playlist 97 | 98 | ### For Collections 99 | 1. Select a single user from the **Collection User** dropdown 100 | 2. This user will be used as the reference for filtering and rule evaluation 101 | 3. The collection will be visible to all users on the server 102 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.SmartLists/Core/Orders/LastPlayedOrderBase.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using Jellyfin.Database.Implementations.Entities; 5 | using Jellyfin.Plugin.SmartLists.Services.Shared; 6 | using MediaBrowser.Controller.Entities; 7 | using MediaBrowser.Controller.Library; 8 | using Microsoft.Extensions.Logging; 9 | 10 | namespace Jellyfin.Plugin.SmartLists.Core.Orders 11 | { 12 | /// 13 | /// Base class for LastPlayed ordering to eliminate duplication 14 | /// 15 | public abstract class LastPlayedOrderBase : Order 16 | { 17 | protected abstract bool IsDescending { get; } 18 | 19 | public override IEnumerable OrderBy( 20 | IEnumerable items, 21 | User user, 22 | IUserDataManager? userDataManager, 23 | ILogger? logger, 24 | RefreshQueueService.RefreshCache? refreshCache = null) 25 | { 26 | if (items == null) return []; 27 | if (userDataManager == null || user == null) 28 | { 29 | logger?.LogWarning("UserDataManager or User is null for LastPlayed sorting, returning unsorted items"); 30 | return items; 31 | } 32 | 33 | try 34 | { 35 | // Pre-fetch all user data to avoid repeated database calls during sorting 36 | var list = items as IList ?? items.ToList(); 37 | var sortValueCache = new Dictionary(list.Count); 38 | 39 | foreach (var item in list) 40 | { 41 | try 42 | { 43 | object? userData = null; 44 | 45 | // Try to get user data from cache if available 46 | if (refreshCache != null && refreshCache.UserDataCache.TryGetValue((item.Id, user.Id), out var cachedUserData)) 47 | { 48 | userData = cachedUserData; 49 | } 50 | else 51 | { 52 | userData = userDataManager.GetUserData(user, item); 53 | } 54 | 55 | sortValueCache[item] = GetLastPlayedDateFromUserData(userData); 56 | } 57 | catch (Exception ex) 58 | { 59 | logger?.LogWarning(ex, "Error getting user data for item {ItemName} for user {UserId}", item.Name, user.Id); 60 | sortValueCache[item] = DateTime.MinValue; // Default to never played 61 | } 62 | } 63 | 64 | // Sort using cached DateTime values directly (no tie-breaker to avoid album grouping) 65 | return IsDescending 66 | ? list.OrderByDescending(item => sortValueCache[item]) 67 | : list.OrderBy(item => sortValueCache[item]); 68 | } 69 | catch (Exception ex) 70 | { 71 | logger?.LogError(ex, "Error in LastPlayed sorting for user {UserId}, returning unsorted items", user.Id); 72 | return items; 73 | } 74 | } 75 | 76 | public override IComparable GetSortKey( 77 | BaseItem item, 78 | User user, 79 | IUserDataManager? userDataManager, 80 | ILogger? logger, 81 | Dictionary? itemRandomKeys = null, 82 | RefreshQueueService.RefreshCache? refreshCache = null) 83 | { 84 | try 85 | { 86 | object? userData = null; 87 | 88 | // Try to get user data from cache if available 89 | if (refreshCache != null && refreshCache.UserDataCache.TryGetValue((item.Id, user.Id), out var cachedUserData)) 90 | { 91 | userData = cachedUserData; 92 | } 93 | else if (userDataManager != null) 94 | { 95 | userData = userDataManager.GetUserData(user, item); 96 | } 97 | 98 | return GetLastPlayedDateFromUserData(userData).Ticks; 99 | } 100 | catch (Exception ex) 101 | { 102 | logger?.LogError(ex, "Error getting last played date for item {ItemId} user {UserId}", item.Id, user.Id); 103 | return DateTime.MinValue.Ticks; 104 | } 105 | } 106 | 107 | /// 108 | /// Extracts LastPlayedDate from user data, handling both DateTime and Nullable<DateTime> 109 | /// 110 | private static DateTime GetLastPlayedDateFromUserData(object? userData) 111 | { 112 | if (userData == null) return DateTime.MinValue; 113 | 114 | var lastPlayedProp = userData.GetType().GetProperty("LastPlayedDate"); 115 | if (lastPlayedProp == null) return DateTime.MinValue; 116 | 117 | var lastPlayedValue = lastPlayedProp.GetValue(userData); 118 | 119 | // Handle non-nullable DateTime 120 | if (lastPlayedValue is DateTime dt && dt != DateTime.MinValue) 121 | { 122 | return dt; 123 | } 124 | 125 | // Handle nullable DateTime? 126 | if (lastPlayedValue != null) 127 | { 128 | var valueType = lastPlayedValue.GetType(); 129 | if (valueType.IsGenericType && valueType.GetGenericTypeDefinition() == typeof(Nullable<>)) 130 | { 131 | var hasValueProp = valueType.GetProperty("HasValue"); 132 | var valueProp = valueType.GetProperty("Value"); 133 | 134 | if (hasValueProp?.GetValue(lastPlayedValue) is true) 135 | { 136 | if (valueProp?.GetValue(lastPlayedValue) is DateTime nullableDt && nullableDt != DateTime.MinValue) 137 | { 138 | return nullableDt; 139 | } 140 | } 141 | } 142 | } 143 | 144 | return DateTime.MinValue; 145 | } 146 | } 147 | } 148 | 149 | -------------------------------------------------------------------------------- /docs/content/user-guide/auto-refresh.md: -------------------------------------------------------------------------------- 1 | # Auto-Refresh 2 | 3 | Smart playlists and collections can update automatically in multiple ways. 4 | 5 | ## Real-Time Auto-Refresh 6 | 7 | Configure lists to refresh automatically when your library changes: 8 | 9 | - **Per-List Setting**: Each list can be set to `Never`, `On Library Changes`, or `On All Changes` 10 | - **Global Default**: Set the default auto-refresh mode for new lists in Settings 11 | - **Unified Batching**: All changes use intelligent 3-second batching to prevent spam during bulk operations 12 | - **Performance Optimized**: Uses advanced caching to only refresh lists that are actually affected by changes 13 | - **Automatic Deduplication**: Multiple events for the same item are combined into a single refresh 14 | 15 | ### Auto-Refresh Modes 16 | 17 | - **Never**: Scheduled and manual refresh only (original behavior) 18 | - **On Library Changes**: Refresh only when new items are added to your library 19 | - **On All Changes**: Refresh for library additions AND all updates (metadata changes, playback status, favorites, etc.) 20 | 21 | ## Custom List Scheduling 22 | 23 | Configure individual lists with their own refresh schedules: 24 | 25 | - **Per-list scheduling**: Each list can have its own schedule 26 | - **Schedule types**: Daily, Weekly, Monthly, Yearly, or Interval 27 | - **Flexible intervals**: 15 min, 30 min, 1 h, 2 h, 3 h, 4 h, 6 h, 8 h, 12 h, or 24 h 28 | 29 | ### Schedule Options 30 | 31 | - **Daily**: Refresh at a specific time each day (e.g., 3:00 AM) 32 | - **Weekly**: Refresh on a specific day and time each week (e.g., Sunday at 8:00 PM) 33 | - **Monthly**: Refresh on a specific day and time each month (e.g., 1st at 2:00 AM) 34 | - **Yearly**: Refresh on a specific month, day and time each year (e.g., January 1st at midnight) 35 | - **Interval**: Refresh at regular intervals (e.g., every 2 hours, every 30 minutes) 36 | - **No schedule**: Disable all scheduled refreshes (auto-refresh and manual only) 37 | 38 | !!! tip "Multiple Schedules" 39 | You can add multiple schedules to a single list. For example, you could set both a Daily schedule at 6:00 AM and an Interval schedule every 4 hours to refresh the list both at a specific time and at regular intervals throughout the day. 40 | 41 | ## Legacy Scheduled Tasks 42 | 43 | !!! warning "Deprecated and Removed" 44 | Legacy scheduled tasks have been deprecated and removed. The original Jellyfin scheduled tasks (Audio Smart Playlist and Media Smart Playlist) are no longer used. All lists now use the custom scheduling system described above, or rely on auto-refresh and manual refresh only. 45 | 46 | ## Example Use Cases 47 | 48 | ### Custom Scheduling Examples 49 | 50 | - **Daily Random Mix**: Random-sorted playlist with a Daily schedule at 6:00 AM → fresh random order every morning 51 | - **Weekly Discoveries**: New-content playlist with a Weekly schedule on Sunday at 8:00 PM → weekly refresh for weekend planning 52 | - **Monthly Archive**: Year-based movie playlist with a Monthly schedule on the 1st at 2:00 AM → monthly refresh for archival content 53 | - **Background Refresh**: Mood-based music playlist with 4-hour intervals → regular updates without being intrusive 54 | 55 | ### Auto-Refresh Examples 56 | 57 | - **Continue Watching**: NextUnwatched playlist with auto-refresh on all changes → updates when episodes are watched (batched) 58 | - **New Releases**: Date-based list with auto-refresh on library changes → updates when new content is added 59 | - **Favorites Collection**: Favorite-based collection with auto-refresh on all changes → updates when items are favorited/unfavorited (batched) 60 | - **Multi-User Playlists**: When a playlist is associated with multiple users, it will auto-refresh when any of those users' playback data changes (watched status, favorites, etc.). Each user's personalized playlist is updated independently based on their own data changes. 61 | 62 | ### Mixed Approach 63 | 64 | Combine both systems for optimal performance: 65 | 66 | - Use **custom scheduling** for lists that benefit from regular refresh (random order, time-based rules) 67 | - Use **auto-refresh** for lists that need immediate updates (playback status, new additions) 68 | 69 | ## Scheduled Refresh Control 70 | 71 | Perfect for randomized lists: 72 | 73 | - Enable scheduled refresh for randomized lists to get fresh random order daily/hourly 74 | - Disable for rule-based lists that rely on real-time auto-refresh instead 75 | - Mix and match: some lists on schedule, others auto-refresh only 76 | 77 | ## Manual Refresh 78 | 79 | - Use the **"Refresh All Lists"** button in the Settings tab to trigger a refresh of all lists 80 | - Use the **"Refresh"** button next to each list in the Manage Lists tab to refresh individual lists 81 | 82 | !!! note "Refresh Time" 83 | A full refresh of all lists can take some time depending on how many media items and lists you have. Large libraries with many lists may take several minutes or even hours to complete, depending on the hardware. Individual list refreshes are typically faster. 84 | 85 | !!! tip "Monitor Refresh Progress" 86 | When you click "Refresh All Lists", you'll be automatically redirected to the **Status** page where you can monitor the progress of all refresh operations in real-time. The Status page shows ongoing operations with progress bars, estimated time remaining, and detailed refresh history. See the [Configuration](configuration.md#3-status) guide for more details. 87 | 88 | ## Performance Considerations 89 | 90 | ### Auto-Refresh Settings 91 | 92 | - **`Never`**: No automatic refreshes 93 | - **`On Library Changes`**: Good performance, refreshes only for library additions 94 | - **`On All Changes`**: Refreshes for additions AND updates (metadata, playback status, etc.) 95 | 96 | ### Large Library Recommendations 97 | 98 | - All changes are automatically batched with a 3-second delay to prevent spam during bulk operations 99 | - Even `On All Changes` is efficient thanks to intelligent batching during large library scans 100 | - **Removed items** are automatically handled by Jellyfin (no refresh needed) 101 | - Consider limiting the number of lists with auto-refresh enabled to optimize server performance 102 | 103 | ### Third-Party Plugin Compatibility 104 | 105 | - Plugins that sync watched status may trigger many simultaneous updates 106 | - If you experience performance issues during bulk sync operations, temporarily set lists to `Never` or `On Library Changes` -------------------------------------------------------------------------------- /Jellyfin.Plugin.SmartLists/Utilities/NameFormatter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Jellyfin.Plugin.SmartLists.Configuration; 3 | 4 | namespace Jellyfin.Plugin.SmartLists.Utilities 5 | { 6 | /// 7 | /// Utility class for formatting smart list names with prefix and suffix. 8 | /// 9 | public static class NameFormatter 10 | { 11 | /// 12 | /// Default suffix used when no configuration is available or configured. 13 | /// 14 | private const string DefaultSuffix = "[Smart]"; 15 | 16 | /// 17 | /// Formats a playlist name based on plugin configuration settings. 18 | /// 19 | /// The base playlist name 20 | /// The formatted playlist name 21 | public static string FormatPlaylistName(string playlistName) 22 | { 23 | try 24 | { 25 | var config = Plugin.Instance?.Configuration; 26 | if (config == null) 27 | { 28 | // Fallback to default behavior if configuration is not available 29 | return FormatPlaylistNameWithSettings(playlistName, "", DefaultSuffix); 30 | } 31 | 32 | var prefix = config.PlaylistNamePrefix ?? ""; 33 | var suffix = config.PlaylistNameSuffix ?? DefaultSuffix; 34 | 35 | return FormatPlaylistNameWithSettings(playlistName, prefix, suffix); 36 | } 37 | catch (Exception) 38 | { 39 | // Fallback to default behavior if any error occurs 40 | return FormatPlaylistNameWithSettings(playlistName, "", DefaultSuffix); 41 | } 42 | } 43 | 44 | /// 45 | /// Formats a playlist name with specific prefix and suffix values. 46 | /// 47 | /// The base playlist name 48 | /// The prefix to add (can be null or empty) 49 | /// The suffix to add (can be null or empty) 50 | /// The formatted playlist name 51 | public static string FormatPlaylistNameWithSettings(string baseName, string prefix, string suffix) 52 | { 53 | // Guard against null baseName 54 | if (baseName == null) 55 | { 56 | baseName = string.Empty; 57 | } 58 | 59 | var formatted = baseName; 60 | if (!string.IsNullOrEmpty(prefix)) 61 | { 62 | formatted = prefix + " " + formatted; 63 | } 64 | if (!string.IsNullOrEmpty(suffix)) 65 | { 66 | formatted = formatted + " " + suffix; 67 | } 68 | return formatted.Trim(); 69 | } 70 | 71 | /// 72 | /// Strips the configured prefix and suffix from a collection/playlist name. 73 | /// This is useful for matching collection names in rules, where users may enter 74 | /// the base name without prefix/suffix, but the actual collection has them applied. 75 | /// 76 | /// The formatted name that may contain prefix/suffix 77 | /// The base name without prefix/suffix 78 | public static string StripPrefixAndSuffix(string formattedName) 79 | { 80 | try 81 | { 82 | var config = Plugin.Instance?.Configuration; 83 | if (config == null) 84 | { 85 | // Fallback to default suffix if configuration is not available 86 | return StripPrefixAndSuffixWithSettings(formattedName, "", DefaultSuffix); 87 | } 88 | 89 | var prefix = config.PlaylistNamePrefix ?? ""; 90 | var suffix = config.PlaylistNameSuffix ?? DefaultSuffix; 91 | 92 | return StripPrefixAndSuffixWithSettings(formattedName, prefix, suffix); 93 | } 94 | catch (Exception) 95 | { 96 | // Fallback to default behavior if any error occurs 97 | return StripPrefixAndSuffixWithSettings(formattedName, "", DefaultSuffix); 98 | } 99 | } 100 | 101 | /// 102 | /// Strips specific prefix and suffix values from a name. 103 | /// 104 | /// The formatted name that may contain prefix/suffix 105 | /// The prefix to remove (can be null or empty) 106 | /// The suffix to remove (can be null or empty) 107 | /// The base name without prefix/suffix 108 | public static string StripPrefixAndSuffixWithSettings(string formattedName, string prefix, string suffix) 109 | { 110 | if (string.IsNullOrEmpty(formattedName)) 111 | return formattedName ?? string.Empty; 112 | 113 | var result = formattedName; 114 | 115 | // Remove suffix first (from the end) 116 | if (!string.IsNullOrEmpty(suffix)) 117 | { 118 | var suffixWithSpace = " " + suffix; 119 | if (result.EndsWith(suffixWithSpace, StringComparison.OrdinalIgnoreCase)) 120 | { 121 | result = result.Substring(0, result.Length - suffixWithSpace.Length); 122 | } 123 | // Also check without space (in case of edge cases) 124 | else if (result.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) 125 | { 126 | result = result.Substring(0, result.Length - suffix.Length); 127 | } 128 | } 129 | 130 | // Remove prefix (from the beginning) 131 | if (!string.IsNullOrEmpty(prefix)) 132 | { 133 | var prefixWithSpace = prefix + " "; 134 | if (result.StartsWith(prefixWithSpace, StringComparison.OrdinalIgnoreCase)) 135 | { 136 | result = result.Substring(prefixWithSpace.Length); 137 | } 138 | // Also check without space (in case of edge cases) 139 | else if (result.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) 140 | { 141 | result = result.Substring(prefix.Length); 142 | } 143 | } 144 | 145 | return result.Trim(); 146 | } 147 | } 148 | } --------------------------------------------------------------------------------