├── examples ├── configPage.png ├── collage_1_images.jpg ├── collage_2_images.jpg ├── collage_3_images.jpg ├── collage_4_images.jpg ├── collage_5_images.jpg ├── collage_6_images.jpg ├── collage_7_images.jpg ├── collage_8_images.jpg └── collage_9_images.jpg ├── test ├── grid │ ├── 1 │ │ └── folder.jpg │ ├── 2 │ │ ├── folder.jpg │ │ └── folder copy.jpg │ ├── 3 │ │ ├── folder.jpg │ │ ├── folder copy 2.jpg │ │ └── folder copy.jpg │ └── 4 │ │ ├── folder.jpg │ │ ├── folder copy 2.jpg │ │ ├── folder copy 3.jpg │ │ └── folder copy.jpg └── TestCollageGenerator.cs ├── Plugin.xml ├── .gitignore ├── LICENSE ├── jellyfin.ruleset ├── Jellyfin.Plugin.CollectionImageGenerator.csproj ├── Configuration ├── PluginConfiguration.cs └── configPage.html ├── README.md ├── Plugin.cs ├── Api ├── ConfigurationController.cs └── CollectionImageGeneratorController.cs ├── generate_manifest.sh ├── ScheduledTasks └── CollectionImageGeneratorScheduledTask.cs ├── .github └── workflows │ └── release.yml ├── manifest.json ├── Tasks └── CollectionImageGeneratorTask.cs └── ImageProcessor └── CollageGenerator.cs /examples/configPage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnpc/jellyfin-plugin-collection-image-generator/HEAD/examples/configPage.png -------------------------------------------------------------------------------- /test/grid/1/folder.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnpc/jellyfin-plugin-collection-image-generator/HEAD/test/grid/1/folder.jpg -------------------------------------------------------------------------------- /test/grid/2/folder.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnpc/jellyfin-plugin-collection-image-generator/HEAD/test/grid/2/folder.jpg -------------------------------------------------------------------------------- /test/grid/3/folder.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnpc/jellyfin-plugin-collection-image-generator/HEAD/test/grid/3/folder.jpg -------------------------------------------------------------------------------- /test/grid/4/folder.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnpc/jellyfin-plugin-collection-image-generator/HEAD/test/grid/4/folder.jpg -------------------------------------------------------------------------------- /examples/collage_1_images.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnpc/jellyfin-plugin-collection-image-generator/HEAD/examples/collage_1_images.jpg -------------------------------------------------------------------------------- /examples/collage_2_images.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnpc/jellyfin-plugin-collection-image-generator/HEAD/examples/collage_2_images.jpg -------------------------------------------------------------------------------- /examples/collage_3_images.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnpc/jellyfin-plugin-collection-image-generator/HEAD/examples/collage_3_images.jpg -------------------------------------------------------------------------------- /examples/collage_4_images.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnpc/jellyfin-plugin-collection-image-generator/HEAD/examples/collage_4_images.jpg -------------------------------------------------------------------------------- /examples/collage_5_images.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnpc/jellyfin-plugin-collection-image-generator/HEAD/examples/collage_5_images.jpg -------------------------------------------------------------------------------- /examples/collage_6_images.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnpc/jellyfin-plugin-collection-image-generator/HEAD/examples/collage_6_images.jpg -------------------------------------------------------------------------------- /examples/collage_7_images.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnpc/jellyfin-plugin-collection-image-generator/HEAD/examples/collage_7_images.jpg -------------------------------------------------------------------------------- /examples/collage_8_images.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnpc/jellyfin-plugin-collection-image-generator/HEAD/examples/collage_8_images.jpg -------------------------------------------------------------------------------- /examples/collage_9_images.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnpc/jellyfin-plugin-collection-image-generator/HEAD/examples/collage_9_images.jpg -------------------------------------------------------------------------------- /test/grid/2/folder copy.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnpc/jellyfin-plugin-collection-image-generator/HEAD/test/grid/2/folder copy.jpg -------------------------------------------------------------------------------- /test/grid/3/folder copy 2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnpc/jellyfin-plugin-collection-image-generator/HEAD/test/grid/3/folder copy 2.jpg -------------------------------------------------------------------------------- /test/grid/3/folder copy.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnpc/jellyfin-plugin-collection-image-generator/HEAD/test/grid/3/folder copy.jpg -------------------------------------------------------------------------------- /test/grid/4/folder copy 2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnpc/jellyfin-plugin-collection-image-generator/HEAD/test/grid/4/folder copy 2.jpg -------------------------------------------------------------------------------- /test/grid/4/folder copy 3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnpc/jellyfin-plugin-collection-image-generator/HEAD/test/grid/4/folder copy 3.jpg -------------------------------------------------------------------------------- /test/grid/4/folder copy.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnpc/jellyfin-plugin-collection-image-generator/HEAD/test/grid/4/folder copy.jpg -------------------------------------------------------------------------------- /Plugin.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Collection Image Generator 4 | Automatically generates collage images for collections without images 5 | CollectionImageGenerator 6 | 1.0.0.0 7 | 10.8.0.0 8 | jellyfin-collection-image-generator 9 | Metadata 10 | 10.8.0 11 | Jellyfin.Plugin.CollectionImageGenerator.dll 12 | 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Build results 2 | [Dd]ebug/ 3 | [Dd]ebugPublic/ 4 | [Rr]elease/ 5 | [Rr]eleases/ 6 | x64/ 7 | x86/ 8 | [Ww][Ii][Nn]32/ 9 | [Aa][Rr][Mm]/ 10 | [Aa][Rr][Mm]64/ 11 | bld/ 12 | [Bb]in/ 13 | [Oo]bj/ 14 | [Ll]og/ 15 | [Ll]ogs/ 16 | 17 | # Visual Studio files 18 | .vs/ 19 | *.user 20 | *.userosscache 21 | *.suo 22 | *.userprefs 23 | *.pidb 24 | *.sln.docstates 25 | 26 | # JetBrains Rider 27 | .idea/ 28 | *.sln.iml 29 | 30 | # macOS 31 | .DS_Store 32 | .AppleDouble 33 | .LSOverride 34 | 35 | # Thumbnails 36 | ._* 37 | 38 | # Files that might appear in the root of a volume 39 | .DocumentRevisions-V100 40 | .fseventsd 41 | .Spotlight-V100 42 | .TemporaryItems 43 | .Trashes 44 | .VolumeIcon.icns 45 | .com.apple.timemachine.donotpresent 46 | 47 | # Directories potentially created on remote AFP share 48 | .AppleDB 49 | .AppleDesktop 50 | Network Trash Folder 51 | Temporary Items 52 | .apdisk 53 | *.zip 54 | 55 | # Test output 56 | 57 | test/output 58 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Jellyfin Collection Image Generator Contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /jellyfin.ruleset: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /Jellyfin.Plugin.CollectionImageGenerator.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net9.0 5 | Jellyfin.Plugin.CollectionImageGenerator 6 | 1.0.0.0 7 | 1.0.0.0 8 | true 9 | false 10 | enable 11 | AllEnabledByDefault 12 | jellyfin.ruleset 13 | true 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /Configuration/PluginConfiguration.cs: -------------------------------------------------------------------------------- 1 | using MediaBrowser.Model.Plugins; 2 | 3 | namespace Jellyfin.Plugin.CollectionImageGenerator.Configuration 4 | { 5 | /// 6 | /// Plugin configuration. 7 | /// 8 | public class PluginConfiguration : BasePluginConfiguration 9 | { 10 | /// 11 | /// Initializes a new instance of the class. 12 | /// 13 | public PluginConfiguration() 14 | { 15 | MaxImagesInCollage = 4; 16 | ScheduledTaskTimeOfDay = "03:00"; 17 | EnableScheduledTask = true; 18 | } 19 | 20 | /// 21 | /// Gets or sets the maximum number of images to include in the collage. 22 | /// 23 | public int MaxImagesInCollage { get; set; } 24 | 25 | /// 26 | /// Gets or sets the time of day to run the scheduled task (24-hour format). 27 | /// 28 | public string ScheduledTaskTimeOfDay { get; set; } 29 | 30 | /// 31 | /// Gets or sets a value indicating whether the scheduled task is enabled. 32 | /// 33 | public bool EnableScheduledTask { get; set; } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Jellyfin Collection Image Generator Plugin

2 | 3 |

4 | Jellyfin Collection Image Generator Plugin is a plugin that automatically creates Collection Images for collections that do not already have an image specified. I hate that when I create a jellyfin collection, the image is just... empty! With this plugin, it generates and image that is a collage of the posters of the content inside the collection. 5 |

6 | 7 | screenshot 8 | 9 | 10 | ## Install Process 11 | 12 | 1. In Jellyfin, go to `Dashboard -> Plugins -> Catalog -> Gear Icon (upper left)` add and a repository. 13 | 1. Set the Repository name to @johnpc (Collection Image Generator) 14 | 1. Set the Repository URL to https://raw.githubusercontent.com/johnpc/jellyfin-plugin-collection-image-generator/refs/heads/main/manifest.json 15 | 1. Click "Save" 16 | 1. Go to Catalog and search for Collection Image Generator 17 | 1. Click on it and install 18 | 1. Restart Jellyfin 19 | 20 | ## User Guide 21 | 22 | 1. To set it up, visit `Dashboard -> Plugins -> My Plugins -> Collection Image Generator -> Settings` 23 | 1. Configure your settings (how many posters in the collage etc) 24 | 1. Choose "Save" 25 | 1. Choose "Run Collection Image Generator" 26 | 1. Viola! Your collections now have images. 27 | 1. Note: The Collection Image Generator Sync task is also available in your Scheduled Tasks section. 28 | 29 | ![](examples/configPage.png) 30 | -------------------------------------------------------------------------------- /Plugin.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using Jellyfin.Plugin.CollectionImageGenerator.Configuration; 4 | using MediaBrowser.Common.Configuration; 5 | using MediaBrowser.Common.Plugins; 6 | using MediaBrowser.Model.Plugins; 7 | using MediaBrowser.Model.Serialization; 8 | 9 | namespace Jellyfin.Plugin.CollectionImageGenerator 10 | { 11 | /// 12 | /// The main plugin class. 13 | /// 14 | public class Plugin : BasePlugin, IHasWebPages 15 | { 16 | /// 17 | /// Initializes a new instance of the class. 18 | /// 19 | /// Instance of the interface. 20 | /// Instance of the interface. 21 | public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) 22 | : base(applicationPaths, xmlSerializer) 23 | { 24 | Instance = this; 25 | } 26 | 27 | /// 28 | public override string Name => "Collection Image Generator"; 29 | 30 | /// 31 | public override Guid Id => Guid.Parse("e29b0e3d-f15e-47b9-9b3d-ed3df892e33d"); 32 | 33 | /// 34 | /// Gets the current plugin instance. 35 | /// 36 | public static Plugin? Instance { get; private set; } 37 | 38 | /// 39 | /// Gets or sets the plugin configuration. 40 | /// 41 | public new PluginConfiguration Configuration 42 | { 43 | get => base.Configuration; 44 | set => base.Configuration = value; 45 | } 46 | 47 | /// 48 | public IEnumerable GetPages() 49 | { 50 | return new[] 51 | { 52 | new PluginPageInfo 53 | { 54 | Name = Name, 55 | EmbeddedResourcePath = GetType().Namespace + ".Configuration.configPage.html", 56 | } 57 | }; 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Api/ConfigurationController.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Jellyfin.Plugin.CollectionImageGenerator.Configuration; 3 | using MediaBrowser.Common.Configuration; 4 | using Microsoft.AspNetCore.Http; 5 | using Microsoft.AspNetCore.Mvc; 6 | 7 | namespace Jellyfin.Plugin.CollectionImageGenerator.Api 8 | { 9 | /// 10 | /// The configuration controller for the Collection Image Generator plugin. 11 | /// 12 | [ApiController] 13 | [Route("Plugins/CollectionImageGenerator/Configuration")] 14 | public class ConfigurationController : ControllerBase 15 | { 16 | private readonly IConfigurationManager _configurationManager; 17 | 18 | /// 19 | /// Initializes a new instance of the class. 20 | /// 21 | /// Instance of the interface. 22 | public ConfigurationController(IConfigurationManager configurationManager) 23 | { 24 | _configurationManager = configurationManager; 25 | } 26 | 27 | /// 28 | /// Gets the plugin configuration. 29 | /// 30 | /// The plugin configuration. 31 | [HttpGet] 32 | [ProducesResponseType(StatusCodes.Status200OK)] 33 | public IActionResult GetConfiguration() 34 | { 35 | var config = Plugin.Instance?.Configuration; 36 | return Ok(config); 37 | } 38 | 39 | /// 40 | /// Updates the plugin configuration. 41 | /// 42 | /// The updated configuration. 43 | /// A indicating success. 44 | [HttpPost] 45 | [ProducesResponseType(StatusCodes.Status204NoContent)] 46 | public IActionResult UpdateConfiguration([FromBody] PluginConfiguration configuration) 47 | { 48 | if (Plugin.Instance == null) 49 | { 50 | return BadRequest("Plugin instance is not available"); 51 | } 52 | 53 | // Update the plugin configuration 54 | Plugin.Instance.Configuration = configuration; 55 | 56 | // Save the configuration directly using the plugin's SaveConfiguration method 57 | Plugin.Instance.SaveConfiguration(); 58 | 59 | return NoContent(); 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Api/CollectionImageGeneratorController.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.ComponentModel.DataAnnotations; 3 | using System.Threading.Tasks; 4 | using Jellyfin.Plugin.CollectionImageGenerator.Tasks; 5 | using MediaBrowser.Controller.Collections; 6 | using MediaBrowser.Controller.Library; 7 | using MediaBrowser.Controller.Providers; 8 | using Microsoft.AspNetCore.Http; 9 | using Microsoft.AspNetCore.Mvc; 10 | using Microsoft.Extensions.Logging; 11 | 12 | namespace Jellyfin.Plugin.CollectionImageGenerator.Api 13 | { 14 | /// 15 | /// The collection image generator controller. 16 | /// 17 | [ApiController] 18 | [Route("CollectionImageGenerator")] 19 | public class CollectionImageGeneratorController : ControllerBase 20 | { 21 | private readonly ILibraryManager _libraryManager; 22 | private readonly ICollectionManager _collectionManager; 23 | private readonly ILoggerFactory _loggerFactory; 24 | private readonly IProviderManager _providerManager; 25 | 26 | /// 27 | /// Initializes a new instance of the class. 28 | /// 29 | /// Instance of the interface. 30 | /// Instance of the interface. 31 | /// Instance of the interface. 32 | /// Instance of the interface. 33 | public CollectionImageGeneratorController( 34 | ILibraryManager libraryManager, 35 | ICollectionManager collectionManager, 36 | ILoggerFactory loggerFactory, 37 | IProviderManager providerManager) 38 | { 39 | _libraryManager = libraryManager; 40 | _collectionManager = collectionManager; 41 | _loggerFactory = loggerFactory; 42 | _providerManager = providerManager; 43 | } 44 | 45 | /// 46 | /// Run the collection image generator task. 47 | /// 48 | /// Task started successfully. 49 | /// A representing the asynchronous operation. 50 | [HttpPost("Run")] 51 | [ProducesResponseType(StatusCodes.Status204NoContent)] 52 | public async Task RunTask() 53 | { 54 | // Create a new instance of the task directly 55 | var logger = _loggerFactory.CreateLogger(); 56 | var task = new CollectionImageGeneratorTask(logger, _libraryManager, _collectionManager); 57 | 58 | await task.ExecuteAsync(new Progress(), default).ConfigureAwait(false); 59 | return NoContent(); 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /generate_manifest.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Configuration 4 | REPO="johnpc/jellyfin-plugin-collection-image-generator" 5 | OUTPUT_FILE="manifest.json" 6 | TEMP_DIR=$(mktemp -d) 7 | GITHUB_API_URL="https://api.github.com/repos/$REPO/releases" 8 | 9 | echo "Fetching releases from $REPO..." 10 | RELEASES=$(curl -s "$GITHUB_API_URL") 11 | 12 | # Check if GitHub API request was successful 13 | if [[ $RELEASES == *"API rate limit exceeded"* ]]; then 14 | echo "Error: GitHub API rate limit exceeded. Try again later or use a token." 15 | exit 1 16 | fi 17 | 18 | # Extract release tags 19 | echo "Processing releases..." 20 | RELEASE_TAGS=$(echo "$RELEASES" | grep -o '"tag_name": "[^"]*' | sed 's/"tag_name": "//') 21 | 22 | # Download the first manifest to get the base structure 23 | FIRST_TAG=$(echo "$RELEASE_TAGS" | head -n 1) 24 | FIRST_MANIFEST_URL="https://github.com/$REPO/releases/download/$FIRST_TAG/manifest.json" 25 | FIRST_MANIFEST=$(curl -s -L -H "Accept: application/json" "$FIRST_MANIFEST_URL") 26 | 27 | if [[ -z "$FIRST_MANIFEST" || "$FIRST_MANIFEST" == "Not Found" ]]; then 28 | echo "Error: Could not fetch first manifest from $FIRST_MANIFEST_URL" 29 | exit 1 30 | fi 31 | 32 | # Extract the base structure (everything except versions) 33 | BASE_MANIFEST=$(echo "$FIRST_MANIFEST" | jq '.[0] | del(.versions)') 34 | 35 | # Process each release to collect all versions 36 | ALL_VERSIONS="[]" 37 | for TAG in $RELEASE_TAGS; do 38 | echo "Processing release $TAG..." 39 | MANIFEST_URL="https://github.com/$REPO/releases/download/$TAG/manifest.json" 40 | 41 | # Download the manifest for this release 42 | RELEASE_MANIFEST=$(curl -s -L -H "Accept: application/json" "$MANIFEST_URL") 43 | 44 | if [[ -z "$RELEASE_MANIFEST" || "$RELEASE_MANIFEST" == "Not Found" ]]; then 45 | echo "Warning: Could not fetch manifest for $TAG, skipping..." 46 | continue 47 | fi 48 | 49 | # Extract the version entry from the release manifest 50 | VERSION_ENTRY=$(echo "$RELEASE_MANIFEST" | jq '.[0].versions[0]') 51 | 52 | if [[ "$VERSION_ENTRY" == "null" || -z "$VERSION_ENTRY" ]]; then 53 | echo "Warning: No valid version entry found in manifest for $TAG, skipping..." 54 | continue 55 | fi 56 | 57 | # Add this version to our collection 58 | if [[ "$ALL_VERSIONS" == "[]" ]]; then 59 | ALL_VERSIONS="[$VERSION_ENTRY]" 60 | else 61 | ALL_VERSIONS=$(echo "$ALL_VERSIONS" | jq ". + [$VERSION_ENTRY]") 62 | fi 63 | done 64 | 65 | # Sort versions by semver (highest to lowest) 66 | echo "Sorting versions by semantic versioning (highest to lowest)..." 67 | SORTED_VERSIONS=$(echo "$ALL_VERSIONS" | jq 'sort_by(.version | ltrimstr("v") | split(".") | map(tonumber)) | reverse') 68 | 69 | # Create the final manifest by combining the base with sorted versions 70 | FINAL_MANIFEST=$(echo "$BASE_MANIFEST" | jq --argjson versions "$SORTED_VERSIONS" '. + {versions: $versions}') 71 | 72 | # Format as an array with one object (to match the example format) 73 | FINAL_MANIFEST="[$FINAL_MANIFEST]" 74 | 75 | # Pretty print the JSON 76 | echo "$FINAL_MANIFEST" | jq '.' > "$OUTPUT_FILE" 77 | 78 | echo "Generated manifest.json with $(echo "$SORTED_VERSIONS" | jq 'length') versions (sorted by semver)" 79 | echo "Output saved to $OUTPUT_FILE" 80 | 81 | # Clean up 82 | rm -rf "$TEMP_DIR" 83 | -------------------------------------------------------------------------------- /ScheduledTasks/CollectionImageGeneratorScheduledTask.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | using Jellyfin.Plugin.CollectionImageGenerator.Tasks; 6 | using MediaBrowser.Controller.Collections; 7 | using MediaBrowser.Controller.Library; 8 | using MediaBrowser.Controller.Providers; 9 | using MediaBrowser.Model.Tasks; 10 | using Microsoft.Extensions.Logging; 11 | 12 | namespace Jellyfin.Plugin.CollectionImageGenerator.ScheduledTasks 13 | { 14 | /// 15 | /// Scheduled task for generating collection images. 16 | /// 17 | public class CollectionImageGeneratorScheduledTask : IScheduledTask, IConfigurableScheduledTask 18 | { 19 | private readonly ILogger _logger; 20 | private readonly ILibraryManager _libraryManager; 21 | private readonly ICollectionManager _collectionManager; 22 | private readonly IProviderManager _providerManager; 23 | 24 | /// 25 | /// Initializes a new instance of the class. 26 | /// 27 | /// Instance of the interface. 28 | /// Instance of the interface. 29 | /// Instance of the interface. 30 | /// Instance of the interface. 31 | public CollectionImageGeneratorScheduledTask( 32 | ILogger logger, 33 | ILibraryManager libraryManager, 34 | ICollectionManager collectionManager, 35 | IProviderManager providerManager) 36 | { 37 | _logger = logger; 38 | _libraryManager = libraryManager; 39 | _collectionManager = collectionManager; 40 | _providerManager = providerManager; 41 | } 42 | 43 | /// 44 | public string Name => "Generate Collection Images"; 45 | 46 | /// 47 | public string Key => "CollectionImageGeneratorTask"; 48 | 49 | /// 50 | public string Description => "Generates collage images for collections without images"; 51 | 52 | /// 53 | public string Category => "Library"; 54 | 55 | /// 56 | public bool IsHidden => false; 57 | 58 | /// 59 | public bool IsEnabled => Plugin.Instance?.Configuration?.EnableScheduledTask ?? false; 60 | 61 | /// 62 | public bool IsLogged => true; 63 | 64 | /// 65 | public Task ExecuteAsync(IProgress progress, CancellationToken cancellationToken) 66 | { 67 | // Create a new logger for the task 68 | var taskLogger = (ILogger)LoggerFactory.Create(builder => 69 | builder.AddConsole()).CreateLogger(); 70 | 71 | var task = new CollectionImageGeneratorTask(taskLogger, _libraryManager, _collectionManager); 72 | return task.ExecuteAsync(progress, cancellationToken); 73 | } 74 | 75 | /// 76 | public IEnumerable GetDefaultTriggers() 77 | { 78 | var config = Plugin.Instance?.Configuration; 79 | 80 | // Scheduled task triggers removed for compatibility 81 | yield break; 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Build and Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | permissions: 9 | contents: write # This is required for creating releases 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | 18 | - name: Setup .NET 19 | uses: actions/setup-dotnet@v3 20 | with: 21 | dotnet-version: 9.0.x 22 | 23 | - name: Restore dependencies 24 | run: dotnet restore 25 | 26 | - name: Build 27 | run: dotnet build --configuration Release --no-restore /p:TreatWarningsAsErrors=false 28 | 29 | - name: Get version from tag 30 | id: get_version 31 | run: | 32 | VERSION=${GITHUB_REF#refs/tags/} 33 | VERSION=${VERSION#v} 34 | echo "VERSION=$VERSION" >> $GITHUB_OUTPUT 35 | 36 | - name: Create release directory 37 | run: | 38 | mkdir -p release 39 | cp bin/Release/net9.0/Jellyfin.Plugin.CollectionImageGenerator.dll release/ 40 | cp bin/Release/net9.0/SixLabors.ImageSharp.dll release/ 41 | cp bin/Release/net9.0/SixLabors.ImageSharp.Drawing.dll release/ 42 | cp bin/Release/net9.0/SixLabors.Fonts.dll release/ 43 | cp Plugin.xml release/ 44 | 45 | - name: Create zip file 46 | run: | 47 | cd release 48 | zip -r ../collection-image-generator-${{ steps.get_version.outputs.VERSION }}.zip . 49 | cd .. 50 | 51 | - name: Calculate checksum 52 | id: calculate_checksum 53 | run: | 54 | CHECKSUM=$(md5sum collection-image-generator-${{ steps.get_version.outputs.VERSION }}.zip | awk '{ print $1 }') 55 | echo "CHECKSUM=$CHECKSUM" >> $GITHUB_OUTPUT 56 | 57 | - name: Create manifest.json 58 | run: | 59 | cat > manifest.json << EOF 60 | [ 61 | { 62 | "guid": "e29b0e3d-f15e-47b9-9b3d-ed3df892e33d", 63 | "name": "Collection Image Generator", 64 | "overview": "Automatically generates collage images for collections without images", 65 | "description": "A Jellyfin plugin that automatically generates collage images for collections without images. It runs as a scheduled task once per day and creates collages from movie/show posters within each collection.", 66 | "owner": "jellyfin-collection-image-generator", 67 | "category": "Metadata", 68 | "versions": [ 69 | { 70 | "version": "${{ steps.get_version.outputs.VERSION }}", 71 | "changelog": "- See the full changelog at [GitHub](https://github.com/johnpc/jellyfin-plugin-collection-image-generator/releases/tag/${{ steps.get_version.outputs.VERSION }})\n", 72 | "targetAbi": "10.11.0.0", 73 | "sourceUrl": "https://github.com/johnpc/jellyfin-plugin-collection-image-generator/releases/download/${{ steps.get_version.outputs.VERSION }}/collection-image-generator-${{ steps.get_version.outputs.VERSION }}.zip", 74 | "checksum": "${{ steps.calculate_checksum.outputs.CHECKSUM }}", 75 | "timestamp": "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" 76 | } 77 | ] 78 | } 79 | ] 80 | EOF 81 | 82 | - name: Create GitHub Release 83 | id: create_release 84 | uses: softprops/action-gh-release@v1 85 | with: 86 | files: | 87 | collection-image-generator-${{ steps.get_version.outputs.VERSION }}.zip 88 | manifest.json 89 | name: Collection Image Generator ${{ steps.get_version.outputs.VERSION }} 90 | draft: false 91 | prerelease: false 92 | body: | 93 | # Collection Image Generator ${{ steps.get_version.outputs.VERSION }} 94 | 95 | A Jellyfin plugin that automatically generates collage images for collections without images. 96 | 97 | ## Installation 98 | 99 | 1. Download the zip file 100 | 2. In Jellyfin, go to Dashboard → Plugins → Catalog 101 | 3. Click on "..." and select "Install from file" 102 | 4. Select the downloaded zip file 103 | 5. Restart Jellyfin 104 | 105 | ## Features 106 | 107 | - Automatically scans for collections without images 108 | - Creates collage images from movie/show posters within each collection 109 | - Runs as a scheduled task once per day 110 | - Configurable through the Jellyfin admin dashboard 111 | - Supports manual execution through the configuration page 112 | env: 113 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 114 | -------------------------------------------------------------------------------- /Configuration/configPage.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Collection Image Generator 5 | 6 | 7 |
8 |
9 |
10 |
11 |
12 |

Collection Image Generator Settings

13 | 14 |
15 | 16 | 17 |
The maximum number of images to include in the collage (1-9)
18 |
19 | 20 |
21 | 22 | 23 |
The time of day to run the scheduled task (24-hour format)
24 |
25 | 26 |
27 | 31 |
32 | 33 |
34 | 37 |
38 |
39 | 40 |
41 | 44 |
45 |
46 |
47 |
48 | 49 | 112 |
113 | 114 | 115 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "guid": "e29b0e3d-f15e-47b9-9b3d-ed3df892e33d", 4 | "name": "Collection Image Generator", 5 | "overview": "Automatically generates collage images for collections without images", 6 | "description": "A Jellyfin plugin that automatically generates collage images for collections without images. It runs as a scheduled task once per day and creates collages from movie/show posters within each collection.", 7 | "owner": "jellyfin-collection-image-generator", 8 | "category": "Metadata", 9 | "versions": [ 10 | { 11 | "version": "0.0.0.21", 12 | "changelog": "- See the full changelog at [GitHub](https://github.com/johnpc/jellyfin-plugin-collection-image-generator/releases/tag/0.0.0.21)\n", 13 | "targetAbi": "10.11.0.0", 14 | "sourceUrl": "https://github.com/johnpc/jellyfin-plugin-collection-image-generator/releases/download/0.0.0.21/collection-image-generator-0.0.0.21.zip", 15 | "checksum": "e89e05b6bfd3c9a27d3f39d04090e642", 16 | "timestamp": "2025-12-08T04:57:17Z" 17 | }, 18 | { 19 | "version": "0.0.0.20", 20 | "changelog": "- See the full changelog at [GitHub](https://github.com/johnpc/jellyfin-plugin-collection-image-generator/releases/tag/0.0.0.20)\n", 21 | "targetAbi": "10.11.0.0", 22 | "sourceUrl": "https://github.com/johnpc/jellyfin-plugin-collection-image-generator/releases/download/0.0.0.20/collection-image-generator-0.0.0.20.zip", 23 | "checksum": "86ac64885fff2c6b3acf2a7c35e81948", 24 | "timestamp": "2025-12-08T03:04:44Z" 25 | }, 26 | { 27 | "version": "0.0.0.19", 28 | "changelog": "- See the full changelog at [GitHub](https://github.com/johnpc/jellyfin-plugin-collection-image-generator/releases/tag/0.0.0.19)\n", 29 | "targetAbi": "10.8.0.0", 30 | "sourceUrl": "https://github.com/johnpc/jellyfin-plugin-collection-image-generator/releases/download/0.0.0.19/collection-image-generator-0.0.0.19.zip", 31 | "checksum": "754b2d4170fea223729e060be1b5c574", 32 | "timestamp": "2025-06-03T22:02:07Z" 33 | }, 34 | { 35 | "version": "0.0.0.17", 36 | "changelog": "- See the full changelog at [GitHub](https://github.com/johnpc/jellyfin-plugin-collection-image-generator/releases/tag/0.0.0.17)\n", 37 | "targetAbi": "10.8.0.0", 38 | "sourceUrl": "https://github.com/johnpc/jellyfin-plugin-collection-image-generator/releases/download/0.0.0.17/collection-image-generator-0.0.0.17.zip", 39 | "checksum": "388272b0ff38b1d6860b52a8262c058e", 40 | "timestamp": "2025-06-03T20:57:33Z" 41 | }, 42 | { 43 | "version": "0.0.0.16", 44 | "changelog": "- See the full changelog at [GitHub](https://github.com/johnpc/jellyfin-plugin-collection-image-generator/releases/tag/0.0.0.16)\n", 45 | "targetAbi": "10.8.0.0", 46 | "sourceUrl": "https://github.com/johnpc/jellyfin-plugin-collection-image-generator/releases/download/0.0.0.16/collection-image-generator-0.0.0.16.zip", 47 | "checksum": "c5ffcfdf4cdad3e7e3a319d4528fc555", 48 | "timestamp": "2025-06-03T20:46:50Z" 49 | }, 50 | { 51 | "version": "0.0.0.15", 52 | "changelog": "- See the full changelog at [GitHub](https://github.com/johnpc/jellyfin-plugin-collection-image-generator/releases/tag/0.0.0.15)\n", 53 | "targetAbi": "10.8.0.0", 54 | "sourceUrl": "https://github.com/johnpc/jellyfin-plugin-collection-image-generator/releases/download/0.0.0.15/collection-image-generator-0.0.0.15.zip", 55 | "checksum": "1484c2dc15ca198d5b1d71f3677aa304", 56 | "timestamp": "2025-06-03T20:33:24Z" 57 | }, 58 | { 59 | "version": "0.0.0.14", 60 | "changelog": "- See the full changelog at [GitHub](https://github.com/johnpc/jellyfin-plugin-collection-image-generator/releases/tag/0.0.0.14)\n", 61 | "targetAbi": "10.8.0.0", 62 | "sourceUrl": "https://github.com/johnpc/jellyfin-plugin-collection-image-generator/releases/download/0.0.0.14/collection-image-generator-0.0.0.14.zip", 63 | "checksum": "878d182ffdb13f420659fd37503189c3", 64 | "timestamp": "2025-06-03T20:21:14Z" 65 | }, 66 | { 67 | "version": "0.0.0.13", 68 | "changelog": "- See the full changelog at [GitHub](https://github.com/johnpc/jellyfin-plugin-collection-image-generator/releases/tag/0.0.0.13)\n", 69 | "targetAbi": "10.8.0.0", 70 | "sourceUrl": "https://github.com/johnpc/jellyfin-plugin-collection-image-generator/releases/download/0.0.0.13/collection-image-generator-0.0.0.13.zip", 71 | "checksum": "f06bef2d39dcb1e2532667a2b7dea654", 72 | "timestamp": "2025-06-03T20:02:47Z" 73 | }, 74 | { 75 | "version": "0.0.0.12", 76 | "changelog": "- See the full changelog at [GitHub](https://github.com/johnpc/jellyfin-plugin-collection-image-generator/releases/tag/0.0.0.12)\n", 77 | "targetAbi": "10.8.0.0", 78 | "sourceUrl": "https://github.com/johnpc/jellyfin-plugin-collection-image-generator/releases/download/0.0.0.12/collection-image-generator-0.0.0.12.zip", 79 | "checksum": "0729e833450700f8743db3aa86c68178", 80 | "timestamp": "2025-06-03T19:32:39Z" 81 | }, 82 | { 83 | "version": "0.0.0.11", 84 | "changelog": "- See the full changelog at [GitHub](https://github.com/johnpc/jellyfin-plugin-collection-image-generator/releases/tag/0.0.0.11)\n", 85 | "targetAbi": "10.8.0.0", 86 | "sourceUrl": "https://github.com/johnpc/jellyfin-plugin-collection-image-generator/releases/download/0.0.0.11/collection-image-generator-0.0.0.11.zip", 87 | "checksum": "f8f2f950739630045b4681a00840421f", 88 | "timestamp": "2025-06-03T19:28:13Z" 89 | }, 90 | { 91 | "version": "0.0.0.10", 92 | "changelog": "- See the full changelog at [GitHub](https://github.com/johnpc/jellyfin-plugin-collection-image-generator/releases/tag/0.0.0.10)\n", 93 | "targetAbi": "10.8.0.0", 94 | "sourceUrl": "https://github.com/johnpc/jellyfin-plugin-collection-image-generator/releases/download/0.0.0.10/collection-image-generator-0.0.0.10.zip", 95 | "checksum": "9d36672594f7ee05760464050a89c3c4", 96 | "timestamp": "2025-06-03T19:17:55Z" 97 | }, 98 | { 99 | "version": "0.0.0.9", 100 | "changelog": "- See the full changelog at [GitHub](https://github.com/johnpc/jellyfin-plugin-collection-image-generator/releases/tag/0.0.0.9)\n", 101 | "targetAbi": "10.8.0.0", 102 | "sourceUrl": "https://github.com/johnpc/jellyfin-plugin-collection-image-generator/releases/download/0.0.0.9/collection-image-generator-0.0.0.9.zip", 103 | "checksum": "c614d9ef51400330ac93be8abbd53c97", 104 | "timestamp": "2025-06-03T19:04:59Z" 105 | }, 106 | { 107 | "version": "0.0.0.8", 108 | "changelog": "- See the full changelog at [GitHub](https://github.com/johnpc/jellyfin-plugin-collection-image-generator/releases/tag/0.0.0.8)\n", 109 | "targetAbi": "10.8.0.0", 110 | "sourceUrl": "https://github.com/johnpc/jellyfin-plugin-collection-image-generator/releases/download/0.0.0.8/collection-image-generator-0.0.0.8.zip", 111 | "checksum": "0ba526b10c8176dc98b46c73bd8d730b", 112 | "timestamp": "2025-06-03T18:59:05Z" 113 | }, 114 | { 115 | "version": "0.0.0.7", 116 | "changelog": "- See the full changelog at [GitHub](https://github.com/johnpc/jellyfin-plugin-collection-image-generator/releases/tag/0.0.0.7)\n", 117 | "targetAbi": "10.8.0.0", 118 | "sourceUrl": "https://github.com/johnpc/jellyfin-plugin-collection-image-generator/releases/download/0.0.0.7/collection-image-generator-0.0.0.7.zip", 119 | "checksum": "07049d10b8b6bf11befa4c9db1150ec8", 120 | "timestamp": "2025-06-03T16:27:53Z" 121 | }, 122 | { 123 | "version": "0.0.0.6", 124 | "changelog": "- See the full changelog at [GitHub](https://github.com/johnpc/jellyfin-plugin-collection-image-generator/releases/tag/0.0.0.6)\n", 125 | "targetAbi": "10.8.0.0", 126 | "sourceUrl": "https://github.com/johnpc/jellyfin-plugin-collection-image-generator/releases/download/0.0.0.6/collection-image-generator-0.0.0.6.zip", 127 | "checksum": "41db07a6902e785d5e3cec594e4e68ab", 128 | "timestamp": "2025-06-03T16:19:22Z" 129 | }, 130 | { 131 | "version": "0.0.0.5", 132 | "changelog": "- See the full changelog at [GitHub](https://github.com/johnpc/jellyfin-plugin-collection-image-generator/releases/tag/0.0.0.5)\n", 133 | "targetAbi": "10.8.0.0", 134 | "sourceUrl": "https://github.com/johnpc/jellyfin-plugin-collection-image-generator/releases/download/0.0.0.5/collection-image-generator-0.0.0.5.zip", 135 | "checksum": "bcd70e4960351a6fca55e5787ae81909", 136 | "timestamp": "2025-06-03T16:11:35Z" 137 | }, 138 | { 139 | "version": "0.0.0.4", 140 | "changelog": "- See the full changelog at [GitHub](https://github.com/johnpc/jellyfin-plugin-collection-image-generator/releases/tag/0.0.0.4)\n", 141 | "targetAbi": "10.8.0.0", 142 | "sourceUrl": "https://github.com/johnpc/jellyfin-plugin-collection-image-generator/releases/download/0.0.0.4/collection-image-generator-0.0.0.4.zip", 143 | "checksum": "250e8c6d34c460a146f627663b7b4098", 144 | "timestamp": "2025-06-03T16:05:51Z" 145 | }, 146 | { 147 | "version": "0.0.0.3", 148 | "changelog": "- See the full changelog at [GitHub](https://github.com/johnpc/jellyfin-plugin-collection-image-generator/releases/tag/0.0.0.3)\n", 149 | "targetAbi": "10.8.0.0", 150 | "sourceUrl": "https://github.com/johnpc/jellyfin-plugin-collection-image-generator/releases/download/0.0.0.3/collection-image-generator-0.0.0.3.zip", 151 | "checksum": "11043dbe48dd217972ba1cc628eeb283", 152 | "timestamp": "2025-06-03T15:53:47Z" 153 | }, 154 | { 155 | "version": "0.0.0.2", 156 | "changelog": "- See the full changelog at [GitHub](https://github.com/johnpc/jellyfin-plugin-collection-image-generator/releases/tag/0.0.0.2)\n", 157 | "targetAbi": "10.8.0.0", 158 | "sourceUrl": "https://github.com/johnpc/jellyfin-plugin-collection-image-generator/releases/download/0.0.0.2/collection-image-generator-0.0.0.2.zip", 159 | "checksum": "40771fbad5a8b3a33f3d162ea6161e73", 160 | "timestamp": "2025-06-03T15:44:24Z" 161 | }, 162 | { 163 | "version": "0.0.0.1", 164 | "changelog": "- See the full changelog at [GitHub](https://github.com/jellyfin-collection-image-generator/jellyfin-collection-image-generator/releases/tag/0.0.0.1)\n", 165 | "targetAbi": "10.8.0.0", 166 | "sourceUrl": "https://github.com/jellyfin-collection-image-generator/jellyfin-collection-image-generator/releases/download/0.0.0.1/collection-image-generator-0.0.0.1.zip", 167 | "checksum": "55cd9a61cf6bf3157092a54e12fa78cf", 168 | "timestamp": "2025-06-03T15:29:20Z" 169 | } 170 | ] 171 | } 172 | ] 173 | -------------------------------------------------------------------------------- /test/TestCollageGenerator.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | using Jellyfin.Plugin.CollectionImageGenerator.ImageProcessor; 7 | using Microsoft.Extensions.Logging; 8 | 9 | namespace Jellyfin.Plugin.CollectionImageGenerator 10 | { 11 | /// 12 | /// Simple test program for the CollageGenerator. 13 | /// 14 | public class TestCollageGenerator 15 | { 16 | public static async Task Main(string[] args) 17 | { 18 | // Create a simple logger 19 | var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole()); 20 | var logger = loggerFactory.CreateLogger(); 21 | 22 | var collageGenerator = new CollageGenerator(logger); 23 | var cancellationToken = CancellationToken.None; 24 | 25 | // Get the project root directory 26 | var projectRoot = Directory.GetCurrentDirectory(); 27 | var testFolder = Path.Combine(projectRoot, "test", "grid"); 28 | var outputFolder = Path.Combine(projectRoot, "test", "output"); 29 | 30 | // Create output directory if it doesn't exist 31 | Directory.CreateDirectory(outputFolder); 32 | 33 | Console.WriteLine("Testing CollageGenerator with different grid layouts..."); 34 | Console.WriteLine($"Test images folder: {testFolder}"); 35 | Console.WriteLine($"Output folder: {outputFolder}"); 36 | Console.WriteLine(); 37 | 38 | // Test 1 image 39 | await TestLayout(collageGenerator, testFolder, outputFolder, "1", 1, cancellationToken); 40 | 41 | // Test 3 images 42 | await TestLayout(collageGenerator, testFolder, outputFolder, "3", 3, cancellationToken); 43 | 44 | // Test 4 images 45 | await TestLayout(collageGenerator, testFolder, outputFolder, "4", 4, cancellationToken); 46 | 47 | // Test 2 images (using first 2 from the 3-image folder) 48 | await TestLayoutCustom(collageGenerator, testFolder, outputFolder, "2", "3", 2, cancellationToken); 49 | 50 | // Test 5 images (using 4 images + 1 from 3-image folder) 51 | await TestLayout5Images(collageGenerator, testFolder, outputFolder, cancellationToken); 52 | 53 | // Test 6 images 54 | await TestLayoutMultipleImages(collageGenerator, testFolder, outputFolder, "6", 6, cancellationToken); 55 | 56 | // Test 7 images 57 | await TestLayoutMultipleImages(collageGenerator, testFolder, outputFolder, "7", 7, cancellationToken); 58 | 59 | // Test 8 images 60 | await TestLayoutMultipleImages(collageGenerator, testFolder, outputFolder, "8", 8, cancellationToken); 61 | 62 | // Test 9 images 63 | await TestLayoutMultipleImages(collageGenerator, testFolder, outputFolder, "9", 9, cancellationToken); 64 | 65 | Console.WriteLine(); 66 | Console.WriteLine("All tests completed! Check the output folder for results."); 67 | } 68 | 69 | private static async Task TestLayout(CollageGenerator generator, string testFolder, string outputFolder, 70 | string folderName, int imageCount, CancellationToken cancellationToken) 71 | { 72 | try 73 | { 74 | var imageFolder = Path.Combine(testFolder, folderName); 75 | if (!Directory.Exists(imageFolder)) 76 | { 77 | Console.WriteLine($"⚠️ Skipping {imageCount} images test - folder {imageFolder} not found"); 78 | return; 79 | } 80 | 81 | var imageFiles = Directory.GetFiles(imageFolder, "*.jpg"); 82 | if (imageFiles.Length < imageCount) 83 | { 84 | Console.WriteLine($"⚠️ Skipping {imageCount} images test - only {imageFiles.Length} images found"); 85 | return; 86 | } 87 | 88 | var imagePaths = new List(); 89 | for (int i = 0; i < imageCount; i++) 90 | { 91 | imagePaths.Add(imageFiles[i]); 92 | } 93 | 94 | var outputPath = Path.Combine(outputFolder, $"collage_{imageCount}_images.jpg"); 95 | 96 | Console.WriteLine($"🎬 Testing {imageCount} image(s) layout..."); 97 | await generator.CreateCollageAsync(imagePaths, outputPath, cancellationToken); 98 | Console.WriteLine($"✅ Generated: {outputPath}"); 99 | } 100 | catch (Exception ex) 101 | { 102 | Console.WriteLine($"❌ Error testing {imageCount} images: {ex.Message}"); 103 | } 104 | } 105 | 106 | private static async Task TestLayoutCustom(CollageGenerator generator, string testFolder, string outputFolder, 107 | string outputName, string sourceFolder, int imageCount, CancellationToken cancellationToken) 108 | { 109 | try 110 | { 111 | var imageFolder = Path.Combine(testFolder, sourceFolder); 112 | if (!Directory.Exists(imageFolder)) 113 | { 114 | Console.WriteLine($"⚠️ Skipping {outputName} images test - folder {imageFolder} not found"); 115 | return; 116 | } 117 | 118 | var imageFiles = Directory.GetFiles(imageFolder, "*.jpg"); 119 | if (imageFiles.Length < imageCount) 120 | { 121 | Console.WriteLine($"⚠️ Skipping {outputName} images test - only {imageFiles.Length} images found"); 122 | return; 123 | } 124 | 125 | var imagePaths = new List(); 126 | for (int i = 0; i < imageCount; i++) 127 | { 128 | imagePaths.Add(imageFiles[i]); 129 | } 130 | 131 | var outputPath = Path.Combine(outputFolder, $"collage_{outputName}_images.jpg"); 132 | 133 | Console.WriteLine($"🎬 Testing {outputName} image(s) layout..."); 134 | await generator.CreateCollageAsync(imagePaths, outputPath, cancellationToken); 135 | Console.WriteLine($"✅ Generated: {outputPath}"); 136 | } 137 | catch (Exception ex) 138 | { 139 | Console.WriteLine($"❌ Error testing {outputName} images: {ex.Message}"); 140 | } 141 | } 142 | 143 | private static async Task TestLayout5Images(CollageGenerator generator, string testFolder, string outputFolder, CancellationToken cancellationToken) 144 | { 145 | try 146 | { 147 | var imagePaths = new List(); 148 | 149 | // Get 4 images from the '4' folder 150 | var folder4 = Path.Combine(testFolder, "4"); 151 | if (Directory.Exists(folder4)) 152 | { 153 | var images4 = Directory.GetFiles(folder4, "*.jpg"); 154 | foreach (var img in images4) 155 | { 156 | imagePaths.Add(img); 157 | } 158 | } 159 | 160 | // Get 1 image from the '3' folder (if we need more) 161 | if (imagePaths.Count < 5) 162 | { 163 | var folder3 = Path.Combine(testFolder, "3"); 164 | if (Directory.Exists(folder3)) 165 | { 166 | var images3 = Directory.GetFiles(folder3, "*.jpg"); 167 | for (int i = 0; i < Math.Min(images3.Length, 5 - imagePaths.Count); i++) 168 | { 169 | imagePaths.Add(images3[i]); 170 | } 171 | } 172 | } 173 | 174 | if (imagePaths.Count >= 5) 175 | { 176 | // Take only first 5 177 | imagePaths = imagePaths.GetRange(0, 5); 178 | 179 | var outputPath = Path.Combine(outputFolder, "collage_5_images.jpg"); 180 | 181 | Console.WriteLine("🎬 Testing 5 images layout..."); 182 | await generator.CreateCollageAsync(imagePaths, outputPath, cancellationToken); 183 | Console.WriteLine($"✅ Generated: {outputPath}"); 184 | } 185 | else 186 | { 187 | Console.WriteLine($"⚠️ Skipping 5 images test - only {imagePaths.Count} images found"); 188 | } 189 | } 190 | catch (Exception ex) 191 | { 192 | Console.WriteLine($"❌ Error testing 5 images: {ex.Message}"); 193 | } 194 | } 195 | 196 | private static async Task TestLayoutMultipleImages(CollageGenerator generator, string testFolder, string outputFolder, 197 | string outputName, int imageCount, CancellationToken cancellationToken) 198 | { 199 | try 200 | { 201 | var imagePaths = new List(); 202 | 203 | // Collect images from all available folders to reach the desired count 204 | var folders = new[] { "4", "3", "2", "1" }; 205 | 206 | foreach (var folder in folders) 207 | { 208 | var folderPath = Path.Combine(testFolder, folder); 209 | if (Directory.Exists(folderPath)) 210 | { 211 | var images = Directory.GetFiles(folderPath, "*.jpg"); 212 | foreach (var img in images) 213 | { 214 | if (imagePaths.Count < imageCount) 215 | { 216 | imagePaths.Add(img); 217 | } 218 | } 219 | } 220 | 221 | if (imagePaths.Count >= imageCount) 222 | break; 223 | } 224 | 225 | // If we still don't have enough images, duplicate some 226 | while (imagePaths.Count < imageCount && imagePaths.Count > 0) 227 | { 228 | imagePaths.Add(imagePaths[imagePaths.Count % imagePaths.Count]); 229 | } 230 | 231 | if (imagePaths.Count >= imageCount) 232 | { 233 | // Take only the number we need 234 | imagePaths = imagePaths.GetRange(0, imageCount); 235 | 236 | var outputPath = Path.Combine(outputFolder, $"collage_{outputName}_images.jpg"); 237 | 238 | Console.WriteLine($"🎬 Testing {outputName} images layout..."); 239 | await generator.CreateCollageAsync(imagePaths, outputPath, cancellationToken); 240 | Console.WriteLine($"✅ Generated: {outputPath}"); 241 | } 242 | else 243 | { 244 | Console.WriteLine($"⚠️ Skipping {outputName} images test - only {imagePaths.Count} images found"); 245 | } 246 | } 247 | catch (Exception ex) 248 | { 249 | Console.WriteLine($"❌ Error testing {outputName} images: {ex.Message}"); 250 | } 251 | } 252 | } 253 | } -------------------------------------------------------------------------------- /Tasks/CollectionImageGeneratorTask.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | using Jellyfin.Data.Enums; 8 | using Jellyfin.Plugin.CollectionImageGenerator.Configuration; 9 | using MediaBrowser.Controller.Collections; 10 | using MediaBrowser.Controller.Entities; 11 | using MediaBrowser.Controller.Entities.Movies; 12 | using MediaBrowser.Controller.Library; 13 | using MediaBrowser.Controller.Providers; 14 | using MediaBrowser.Model.Entities; 15 | using MediaBrowser.Model.Tasks; 16 | using Microsoft.Extensions.Logging; 17 | using SixLabors.ImageSharp; 18 | using SixLabors.ImageSharp.PixelFormats; 19 | using SixLabors.ImageSharp.Processing; 20 | 21 | namespace Jellyfin.Plugin.CollectionImageGenerator.Tasks 22 | { 23 | /// 24 | /// Task that generates images for collections. 25 | /// 26 | public class CollectionImageGeneratorTask : IScheduledTask 27 | { 28 | private readonly ILogger _logger; 29 | private readonly ILibraryManager _libraryManager; 30 | private readonly ICollectionManager _collectionManager; 31 | 32 | /// 33 | /// Initializes a new instance of the class. 34 | /// 35 | /// Instance of the interface. 36 | /// Instance of the interface. 37 | /// Instance of the interface. 38 | public CollectionImageGeneratorTask( 39 | ILogger logger, 40 | ILibraryManager libraryManager, 41 | ICollectionManager collectionManager) 42 | { 43 | _logger = logger; 44 | _libraryManager = libraryManager; 45 | _collectionManager = collectionManager; 46 | } 47 | 48 | /// 49 | public string Name => "Generate Collection Images"; 50 | 51 | /// 52 | public string Key => "CollectionImageGeneratorTask"; 53 | 54 | /// 55 | public string Description => "Generates collage images for collections without images"; 56 | 57 | /// 58 | public string Category => "Library"; 59 | 60 | /// 61 | public async Task ExecuteAsync(IProgress progress, CancellationToken cancellationToken) 62 | { 63 | _logger.LogInformation("Starting collection image generation task"); 64 | 65 | var config = Plugin.Instance!.Configuration; 66 | var collections = _libraryManager.GetItemList(new MediaBrowser.Controller.Entities.InternalItemsQuery 67 | { 68 | IncludeItemTypes = new[] { BaseItemKind.BoxSet } 69 | }); 70 | 71 | var totalCollections = collections.Count; 72 | var processedCount = 0; 73 | 74 | foreach (var collection in collections) 75 | { 76 | if (cancellationToken.IsCancellationRequested) 77 | { 78 | break; 79 | } 80 | 81 | try 82 | { 83 | var boxSet = (BoxSet)collection; 84 | 85 | // Check if collection already has an image 86 | if (string.IsNullOrEmpty(boxSet.PrimaryImagePath)) 87 | { 88 | _logger.LogInformation("Generating image for collection: {Name} (ID: {Id})", boxSet.Name, boxSet.Id); 89 | _logger.LogInformation("Collection path: {Path}", boxSet.Path); 90 | 91 | // Get items in the collection 92 | var collectionItems = boxSet.GetLinkedChildren(); 93 | _logger.LogInformation("Collection {Name} has {Count} items", boxSet.Name, collectionItems.Count()); 94 | 95 | var itemsWithImages = collectionItems 96 | .Where(i => !string.IsNullOrEmpty(i.PrimaryImagePath) && File.Exists(i.PrimaryImagePath)) 97 | .ToList(); 98 | 99 | _logger.LogInformation("Collection {Name} has {Count} items with valid images", boxSet.Name, itemsWithImages.Count); 100 | 101 | if (itemsWithImages.Count > 0) 102 | { 103 | // Log the first few items with their image paths 104 | foreach (var item in itemsWithImages.Take(3)) 105 | { 106 | _logger.LogInformation("Item in collection: {ItemName}, Image path: {ImagePath}", 107 | item.Name, item.PrimaryImagePath); 108 | } 109 | 110 | // Take a sample of items for the collage 111 | var sampleSize = Math.Min(config.MaxImagesInCollage, itemsWithImages.Count); 112 | var sampleItems = itemsWithImages 113 | .OrderBy(_ => Guid.NewGuid()) // Randomize the order 114 | .Take(sampleSize) 115 | .ToList(); 116 | 117 | _logger.LogInformation("Selected {Count} items for collage in collection {Name}", 118 | sampleItems.Count, boxSet.Name); 119 | 120 | // Generate and save the collage 121 | await GenerateAndSaveCollageAsync(boxSet, sampleItems, cancellationToken).ConfigureAwait(false); 122 | } 123 | else 124 | { 125 | _logger.LogInformation("No items with images found in collection: {Name}", boxSet.Name); 126 | } 127 | } 128 | } 129 | catch (Exception ex) 130 | { 131 | _logger.LogError(ex, "Error processing collection {Name}", collection.Name); 132 | } 133 | 134 | processedCount++; 135 | progress.Report(100.0 * processedCount / totalCollections); 136 | } 137 | 138 | _logger.LogInformation("Collection image generation task completed"); 139 | } 140 | 141 | /// 142 | public IEnumerable GetDefaultTriggers() 143 | { 144 | var config = Plugin.Instance!.Configuration; 145 | 146 | if (config.EnableScheduledTask) 147 | { 148 | // Parse the time of day from configuration 149 | if (TimeSpan.TryParse(config.ScheduledTaskTimeOfDay, out var time)) 150 | { 151 | yield return new TaskTriggerInfo 152 | { 153 | Type = TaskTriggerInfoType.DailyTrigger, 154 | TimeOfDayTicks = time.Ticks 155 | }; 156 | } 157 | else 158 | { 159 | // Default to 3 AM if parsing fails 160 | yield return new TaskTriggerInfo 161 | { 162 | Type = TaskTriggerInfoType.DailyTrigger, 163 | TimeOfDayTicks = TimeSpan.FromHours(3).Ticks 164 | }; 165 | } 166 | } 167 | } 168 | 169 | private async Task GenerateAndSaveCollageAsync(BoxSet collection, List items, CancellationToken cancellationToken) 170 | { 171 | try 172 | { 173 | // Determine the layout based on the number of images 174 | var imageCount = items.Count; 175 | var (rows, cols) = GetGridDimensions(imageCount); 176 | 177 | _logger.LogInformation("Creating collage with {Count} images in a {Rows}x{Cols} grid for collection {Name}", 178 | imageCount, rows, cols, collection.Name); 179 | 180 | // Create a new image with appropriate dimensions 181 | const int targetWidth = 1000; 182 | const int targetHeight = 1500; 183 | 184 | _logger.LogInformation("Creating output image with dimensions {Width}x{Height}", targetWidth, targetHeight); 185 | 186 | using var outputImage = new Image(targetWidth, targetHeight); 187 | 188 | // Calculate the size of each poster in the grid 189 | var posterWidth = targetWidth / cols; 190 | var posterHeight = targetHeight / rows; 191 | 192 | _logger.LogInformation("Each poster will be sized {Width}x{Height}", posterWidth, posterHeight); 193 | 194 | // Load and place each poster image 195 | for (var i = 0; i < imageCount; i++) 196 | { 197 | if (cancellationToken.IsCancellationRequested) 198 | { 199 | return; 200 | } 201 | 202 | var item = items[i]; 203 | var row = i / cols; 204 | var col = i % cols; 205 | 206 | try 207 | { 208 | _logger.LogInformation("Loading image for item {ItemName} from {Path}", item.Name, item.PrimaryImagePath); 209 | 210 | using var posterImage = await Image.LoadAsync(item.PrimaryImagePath, cancellationToken); 211 | 212 | // Resize the poster to fit in the grid 213 | posterImage.Mutate(x => x.Resize(posterWidth, posterHeight)); 214 | 215 | // Calculate position 216 | var x = col * posterWidth; 217 | var y = row * posterHeight; 218 | 219 | _logger.LogInformation("Placing image for {ItemName} at position ({X},{Y})", item.Name, x, y); 220 | 221 | // Draw the poster onto the output image 222 | outputImage.Mutate(ctx => ctx.DrawImage(posterImage, new Point(x, y), 1f)); 223 | } 224 | catch (Exception ex) 225 | { 226 | _logger.LogError(ex, "Error processing image for item {Name}", item.Name); 227 | } 228 | } 229 | 230 | // First save the collage to a temporary file 231 | var tempFile = Path.Combine(Path.GetTempPath(), $"collage_{collection.Id}.jpg"); 232 | _logger.LogInformation("Saving temporary collage to {Path}", tempFile); 233 | await outputImage.SaveAsJpegAsync(tempFile, cancellationToken); 234 | 235 | if (File.Exists(tempFile)) 236 | { 237 | _logger.LogInformation("Temporary collage file successfully created at {Path}", tempFile); 238 | 239 | try 240 | { 241 | // Save to the file system first 242 | var directory = collection.Path; 243 | var filename = $"folder{Path.DirectorySeparatorChar}poster.jpg"; 244 | var outputPath = Path.Combine(directory, filename); 245 | 246 | _logger.LogInformation("Saving collage to file system at {Path}", outputPath); 247 | 248 | // Ensure the directory exists 249 | var folderPath = Path.GetDirectoryName(outputPath); 250 | Directory.CreateDirectory(folderPath!); 251 | 252 | // Copy the temp file to the final location 253 | File.Copy(tempFile, outputPath, true); 254 | 255 | // Use Jellyfin's provider manager to set the image 256 | _logger.LogInformation("Setting primary image for collection {Name} using provider manager", collection.Name); 257 | 258 | // Read the image file into a byte array 259 | byte[] imageBytes = await File.ReadAllBytesAsync(tempFile, cancellationToken); 260 | 261 | // Set the image using the provider manager 262 | await SetCollectionImageAsync(collection, imageBytes, cancellationToken); 263 | 264 | // Force a refresh of the collection 265 | _logger.LogInformation("Refreshing metadata for collection {Name}", collection.Name); 266 | await collection.RefreshMetadata(cancellationToken).ConfigureAwait(false); 267 | 268 | // Try to force the collection to reload its images 269 | _logger.LogInformation("Forcing image refresh for collection {Name}", collection.Name); 270 | await collection.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, cancellationToken).ConfigureAwait(false); 271 | 272 | _logger.LogInformation("Successfully generated and set collage for collection: {Name}", collection.Name); 273 | 274 | // Clean up the temp file 275 | try 276 | { 277 | File.Delete(tempFile); 278 | } 279 | catch (Exception ex) 280 | { 281 | _logger.LogWarning(ex, "Failed to delete temporary file {Path}", tempFile); 282 | } 283 | } 284 | catch (Exception ex) 285 | { 286 | _logger.LogError(ex, "Error setting image for collection {Name}", collection.Name); 287 | } 288 | } 289 | else 290 | { 291 | _logger.LogError("Failed to create temporary collage file at {Path}", tempFile); 292 | } 293 | } 294 | catch (Exception ex) 295 | { 296 | _logger.LogError(ex, "Error generating collage for collection {Name}", collection.Name); 297 | } 298 | } 299 | 300 | private async Task SetCollectionImageAsync(BoxSet collection, byte[] imageData, CancellationToken cancellationToken) 301 | { 302 | try 303 | { 304 | _logger.LogInformation("Setting primary image for collection {Name} (ID: {Id})", collection.Name, collection.Id); 305 | 306 | // Save the image to the standard location 307 | var directory = collection.Path; 308 | var filename = $"folder{Path.DirectorySeparatorChar}poster.jpg"; 309 | var outputPath = Path.Combine(directory, filename); 310 | 311 | // Ensure the directory exists 312 | var folderPath = Path.GetDirectoryName(outputPath); 313 | Directory.CreateDirectory(folderPath!); 314 | 315 | // Save the image 316 | File.WriteAllBytes(outputPath, imageData); 317 | 318 | _logger.LogInformation("Saved image to {Path}", outputPath); 319 | 320 | collection.SetImage(new ItemImageInfo 321 | { 322 | Path = outputPath, 323 | Type = ImageType.Primary 324 | }, 0); 325 | 326 | await _libraryManager.UpdateItemAsync( 327 | collection, 328 | collection.GetParent(), 329 | ItemUpdateType.ImageUpdate, 330 | CancellationToken.None); 331 | 332 | // Force a refresh of the collection 333 | _logger.LogInformation("Refreshing metadata for collection {Name}", collection.Name); 334 | 335 | // First, try to clear any existing image cache 336 | try 337 | { 338 | // We can't directly set PrimaryImagePath as it's read-only 339 | // Instead, force a metadata refresh and image update 340 | await collection.RefreshMetadata(cancellationToken).ConfigureAwait(false); 341 | _logger.LogInformation("Refreshed metadata"); 342 | } 343 | catch (Exception ex) 344 | { 345 | _logger.LogWarning(ex, "Error refreshing metadata, continuing anyway"); 346 | } 347 | 348 | // Force image update 349 | await collection.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, cancellationToken).ConfigureAwait(false); 350 | 351 | // Additional image refresh to ensure it's picked up 352 | await collection.UpdateToRepositoryAsync(ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false); 353 | 354 | _logger.LogInformation("Successfully set primary image for collection {Name}", collection.Name); 355 | } 356 | catch (Exception ex) 357 | { 358 | _logger.LogError(ex, "Error setting primary image for collection {Name}", collection.Name); 359 | throw; 360 | } 361 | } 362 | 363 | private static (int rows, int cols) GetGridDimensions(int count) 364 | { 365 | return count switch 366 | { 367 | 1 => (1, 1), 368 | 2 => (1, 2), 369 | 3 => (1, 3), 370 | 4 => (2, 2), 371 | 5 => (2, 3), // 2x3 with one empty space 372 | 6 => (2, 3), 373 | 7 => (3, 3), // 3x3 with two empty spaces 374 | 8 => (3, 3), // 3x3 with one empty space 375 | 9 => (3, 3), 376 | _ => (3, 3), // Default to 3x3 for any larger number 377 | }; 378 | } 379 | } 380 | } 381 | -------------------------------------------------------------------------------- /ImageProcessor/CollageGenerator.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | using Microsoft.Extensions.Logging; 8 | using SixLabors.ImageSharp; 9 | using SixLabors.ImageSharp.Drawing.Processing; 10 | using SixLabors.ImageSharp.PixelFormats; 11 | using SixLabors.ImageSharp.Processing; 12 | 13 | namespace Jellyfin.Plugin.CollectionImageGenerator.ImageProcessor 14 | { 15 | /// 16 | /// Utility class for generating collage images. 17 | /// 18 | public class CollageGenerator 19 | { 20 | private readonly ILogger _logger; 21 | 22 | /// 23 | /// Initializes a new instance of the class. 24 | /// 25 | /// Instance of the interface. 26 | public CollageGenerator(ILogger logger) 27 | { 28 | _logger = logger; 29 | } 30 | 31 | /// 32 | /// Creates a collage from a list of image paths. 33 | /// 34 | /// The list of image paths to include in the collage. 35 | /// The path where the collage should be saved. 36 | /// The cancellation token. 37 | /// A representing the asynchronous operation. 38 | public async Task CreateCollageAsync(List imagePaths, string outputPath, CancellationToken cancellationToken) 39 | { 40 | try 41 | { 42 | // Determine the layout based on the number of images 43 | var imageCount = imagePaths.Count; 44 | 45 | // Create a new image with appropriate dimensions 46 | const int targetWidth = 1000; 47 | const int targetHeight = 1500; 48 | const int padding = 20; // Padding around each image 49 | 50 | using var outputImage = new Image(targetWidth, targetHeight); 51 | 52 | // Get dynamic background color from the input images 53 | var backgroundColor = await GetDynamicBackgroundColorAsync(imagePaths, cancellationToken).ConfigureAwait(false); 54 | 55 | // Fill background with the dynamic color 56 | outputImage.Mutate(x => x.BackgroundColor(backgroundColor)); 57 | 58 | // Get custom layout positions for this image count 59 | var positions = GetCustomPositions(imageCount, targetWidth, targetHeight, padding); 60 | 61 | // Load and place each poster image 62 | for (var i = 0; i < imageCount; i++) 63 | { 64 | if (cancellationToken.IsCancellationRequested) 65 | { 66 | return; 67 | } 68 | 69 | var imagePath = imagePaths[i]; 70 | var position = positions[i]; 71 | 72 | try 73 | { 74 | using var posterImage = await Image.LoadAsync(imagePath, cancellationToken).ConfigureAwait(false); 75 | 76 | // Use consistent grid size for all items (same width and height) 77 | var gridWidth = position.Width - (padding * 2); 78 | var gridHeight = position.Height - (padding * 2); 79 | 80 | // Resize the poster to fit within grid size without cropping 81 | posterImage.Mutate(x => x.Resize(new ResizeOptions 82 | { 83 | Size = new Size(gridWidth, gridHeight), 84 | Mode = ResizeMode.Max, 85 | Position = AnchorPositionMode.Center 86 | })); 87 | 88 | // Center the resized image within the grid space 89 | var centeredX = position.X + padding + (gridWidth - posterImage.Width) / 2; 90 | var centeredY = position.Y + padding + (gridHeight - posterImage.Height) / 2; 91 | var posX = centeredX; 92 | var posY = centeredY; 93 | 94 | // Draw the poster onto the output image 95 | outputImage.Mutate(ctx => ctx.DrawImage(posterImage, new Point(posX, posY), 1f)); 96 | 97 | // Add border around the image based on background brightness 98 | var borderColor = GetBorderColor(backgroundColor); 99 | var borderThickness = 6f; 100 | var borderRect = new RectangleF(posX - borderThickness/2, posY - borderThickness/2, 101 | posterImage.Width + borderThickness, posterImage.Height + borderThickness); 102 | outputImage.Mutate(ctx => ctx.Draw(borderColor, borderThickness, borderRect)); 103 | } 104 | catch (Exception ex) 105 | { 106 | _logger.LogError(ex, "Error processing image {Path}", imagePath); 107 | } 108 | } 109 | 110 | // Ensure the directory exists 111 | Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!); 112 | 113 | // Save the image 114 | await outputImage.SaveAsJpegAsync(outputPath, cancellationToken).ConfigureAwait(false); 115 | 116 | _logger.LogInformation("Successfully generated collage at {Path}", outputPath); 117 | } 118 | catch (Exception ex) 119 | { 120 | _logger.LogError(ex, "Error generating collage"); 121 | } 122 | } 123 | 124 | /// 125 | /// Gets custom positions for each image based on the layout type with consistent padding. 126 | /// 127 | private static List<(int X, int Y, int Width, int Height)> GetCustomPositions(int count, int canvasWidth, int canvasHeight, int padding) 128 | { 129 | return count switch 130 | { 131 | 1 => GetSingleImageLayout(canvasWidth, canvasHeight, padding), 132 | 2 => GetDiagonalLayout(canvasWidth, canvasHeight, padding), 133 | 3 => GetTriangularLayout(canvasWidth, canvasHeight, padding), 134 | 4 => GetQuadLayout(canvasWidth, canvasHeight, padding), 135 | 5 => GetLayout_2_1_2(canvasWidth, canvasHeight, padding), 136 | 6 => GetLayout_2_2_2(canvasWidth, canvasHeight, padding), 137 | 7 => GetLayout_2_3_2(canvasWidth, canvasHeight, padding), 138 | 8 => GetLayout_3_2_3(canvasWidth, canvasHeight, padding), 139 | _ => GetStandardGridLayout(count, canvasWidth, canvasHeight, padding) 140 | }; 141 | } 142 | 143 | /// 144 | /// Gets cell dimensions for a 3x3 grid layout. 145 | /// 146 | private static (int width, int height) Get3x3CellSize(int canvasWidth, int canvasHeight, int padding) 147 | { 148 | return ((canvasWidth - (padding * 4)) / 3, (canvasHeight - (padding * 4)) / 3); 149 | } 150 | 151 | /// 152 | /// Gets cell dimensions for a 2x2 grid layout. 153 | /// 154 | private static (int width, int height) Get2x2CellSize(int canvasWidth, int canvasHeight, int padding) 155 | { 156 | return ((canvasWidth - (padding * 3)) / 2, (canvasHeight - (padding * 3)) / 2); 157 | } 158 | 159 | /// 160 | /// Adds a row of images with standard grid spacing. 161 | /// 162 | private static void AddStandardRow(List<(int X, int Y, int Width, int Height)> positions, 163 | int rowIndex, int startCol, int itemCount, int cellWidth, int cellHeight, int padding) 164 | { 165 | var rowY = padding + rowIndex * (cellHeight + padding); 166 | for (var i = 0; i < itemCount; i++) 167 | { 168 | var colX = padding + (startCol + i) * (cellWidth + padding); 169 | positions.Add((colX, rowY, cellWidth, cellHeight)); 170 | } 171 | } 172 | 173 | /// 174 | /// Adds a row of images centered horizontally. 175 | /// 176 | private static void AddCenteredRow(List<(int X, int Y, int Width, int Height)> positions, 177 | int rowIndex, int itemCount, int cellWidth, int cellHeight, int padding, int canvasWidth) 178 | { 179 | var rowY = padding + rowIndex * (cellHeight + padding); 180 | var centerOffset = (cellWidth + padding) / 2; 181 | var startX = padding + centerOffset; 182 | 183 | for (var i = 0; i < itemCount; i++) 184 | { 185 | var colX = startX + i * (cellWidth + padding); 186 | positions.Add((colX, rowY, cellWidth, cellHeight)); 187 | } 188 | } 189 | 190 | /// 191 | /// Single image centered with padding. 192 | /// 193 | private static List<(int X, int Y, int Width, int Height)> GetSingleImageLayout(int canvasWidth, int canvasHeight, int padding) 194 | { 195 | return new List<(int X, int Y, int Width, int Height)> 196 | { 197 | (padding, padding, canvasWidth - (padding * 2), canvasHeight - (padding * 2)) 198 | }; 199 | } 200 | 201 | /// 202 | /// Diagonal arrangement with 1/8 canvas width inward spacing. 203 | /// 204 | private static List<(int X, int Y, int Width, int Height)> GetDiagonalLayout(int canvasWidth, int canvasHeight, int padding) 205 | { 206 | var (width, height) = Get2x2CellSize(canvasWidth, canvasHeight, padding); 207 | var eighthWidth = canvasWidth / 8; 208 | 209 | return new List<(int X, int Y, int Width, int Height)> 210 | { 211 | (padding + eighthWidth, padding, width, height), 212 | (canvasWidth - width - padding - eighthWidth, canvasHeight - height - padding, width, height) 213 | }; 214 | } 215 | 216 | /// 217 | /// Triangular arrangement - 1 top centered, 2 bottom. 218 | /// 219 | private static List<(int X, int Y, int Width, int Height)> GetTriangularLayout(int canvasWidth, int canvasHeight, int padding) 220 | { 221 | var (width, height) = Get2x2CellSize(canvasWidth, canvasHeight, padding); 222 | var topCenterX = (canvasWidth - width) / 2; 223 | 224 | return new List<(int X, int Y, int Width, int Height)> 225 | { 226 | (topCenterX, padding, width, height), // Top center 227 | (padding, padding * 2 + height, width, height), // Bottom left 228 | (padding * 2 + width, padding * 2 + height, width, height) // Bottom right 229 | }; 230 | } 231 | 232 | /// 233 | /// Standard 2x2 grid layout. 234 | /// 235 | private static List<(int X, int Y, int Width, int Height)> GetQuadLayout(int canvasWidth, int canvasHeight, int padding) 236 | { 237 | var (width, height) = Get2x2CellSize(canvasWidth, canvasHeight, padding); 238 | var positions = new List<(int X, int Y, int Width, int Height)>(); 239 | 240 | AddStandardRow(positions, 0, 0, 2, width, height, padding); 241 | AddStandardRow(positions, 1, 0, 2, width, height, padding); 242 | 243 | return positions; 244 | } 245 | 246 | /// 247 | /// 2-1-2 arrangement: top row 2 images, middle row 1 centered, bottom row 2 images. 248 | /// 249 | private static List<(int X, int Y, int Width, int Height)> GetLayout_2_1_2(int canvasWidth, int canvasHeight, int padding) 250 | { 251 | var (width, height) = Get3x3CellSize(canvasWidth, canvasHeight, padding); 252 | var positions = new List<(int X, int Y, int Width, int Height)>(); 253 | 254 | // Top row (2 images centered) 255 | AddCenteredRow(positions, 0, 2, width, height, padding, canvasWidth); 256 | 257 | // Middle row (1 centered image) 258 | var centerX = (canvasWidth - width) / 2; 259 | var middleY = padding * 2 + height; 260 | positions.Add((centerX, middleY, width, height)); 261 | 262 | // Bottom row (2 images centered) 263 | AddCenteredRow(positions, 2, 2, width, height, padding, canvasWidth); 264 | 265 | return positions; 266 | } 267 | 268 | /// 269 | /// 2-2-2 arrangement (3 rows, 2 columns each) with wider spacing in middle row. 270 | /// 271 | private static List<(int X, int Y, int Width, int Height)> GetLayout_2_2_2(int canvasWidth, int canvasHeight, int padding) 272 | { 273 | var (width, height) = Get3x3CellSize(canvasWidth, canvasHeight, padding); 274 | var positions = new List<(int X, int Y, int Width, int Height)>(); 275 | 276 | // Top row (2 images centered) 277 | AddCenteredRow(positions, 0, 2, width, height, padding, canvasWidth); 278 | 279 | // Middle row (2 images with wider spacing) 280 | var middleRowSpacing = (int)(width * 0.4f); 281 | var middleStartX = (canvasWidth - (width * 2 + middleRowSpacing)) / 2; 282 | var middleY = padding * 2 + height; 283 | positions.Add((middleStartX, middleY, width, height)); 284 | positions.Add((middleStartX + width + middleRowSpacing, middleY, width, height)); 285 | 286 | // Bottom row (2 images centered) 287 | AddCenteredRow(positions, 2, 2, width, height, padding, canvasWidth); 288 | 289 | return positions; 290 | } 291 | 292 | /// 293 | /// 2-3-2 arrangement with consistent cell sizes. 294 | /// 295 | private static List<(int X, int Y, int Width, int Height)> GetLayout_2_3_2(int canvasWidth, int canvasHeight, int padding) 296 | { 297 | var (width, height) = Get3x3CellSize(canvasWidth, canvasHeight, padding); 298 | var positions = new List<(int X, int Y, int Width, int Height)>(); 299 | 300 | // Top row (2 images centered) 301 | AddCenteredRow(positions, 0, 2, width, height, padding, canvasWidth); 302 | 303 | // Middle row (3 images) 304 | AddStandardRow(positions, 1, 0, 3, width, height, padding); 305 | 306 | // Bottom row (2 images centered) 307 | AddCenteredRow(positions, 2, 2, width, height, padding, canvasWidth); 308 | 309 | return positions; 310 | } 311 | 312 | /// 313 | /// 3-2-3 arrangement with consistent cell sizes. 314 | /// 315 | private static List<(int X, int Y, int Width, int Height)> GetLayout_3_2_3(int canvasWidth, int canvasHeight, int padding) 316 | { 317 | var (width, height) = Get3x3CellSize(canvasWidth, canvasHeight, padding); 318 | var positions = new List<(int X, int Y, int Width, int Height)>(); 319 | 320 | // Top row (3 images) 321 | AddStandardRow(positions, 0, 0, 3, width, height, padding); 322 | 323 | // Middle row (2 images centered) 324 | AddCenteredRow(positions, 1, 2, width, height, padding, canvasWidth); 325 | 326 | // Bottom row (3 images) 327 | AddStandardRow(positions, 2, 0, 3, width, height, padding); 328 | 329 | return positions; 330 | } 331 | 332 | /// 333 | /// Standard 3x3 grid for 9+ images with consistent padding. 334 | /// 335 | private static List<(int X, int Y, int Width, int Height)> GetStandardGridLayout(int count, int canvasWidth, int canvasHeight, int padding) 336 | { 337 | var (width, height) = Get3x3CellSize(canvasWidth, canvasHeight, padding); 338 | var positions = new List<(int X, int Y, int Width, int Height)>(); 339 | 340 | for (var i = 0; i < Math.Min(count, 9); i++) 341 | { 342 | var row = i / 3; 343 | var col = i % 3; 344 | var x = padding + col * (width + padding); 345 | var y = padding + row * (height + padding); 346 | positions.Add((x, y, width, height)); 347 | } 348 | 349 | return positions; 350 | } 351 | 352 | /// 353 | /// Extracts dynamic background color from input images using k-means clustering. 354 | /// 355 | private async Task GetDynamicBackgroundColorAsync(List imagePaths, CancellationToken cancellationToken) 356 | { 357 | try 358 | { 359 | var allColors = new List(); 360 | 361 | // Sample colors from all images in the collection 362 | // For large collections, we'll sample fewer pixels per image to maintain performance 363 | var pixelSampleRate = imagePaths.Count > 6 ? 6 : 3; // Sample every 6th pixel for large collections, every 3rd for smaller ones 364 | 365 | foreach (var imagePath in imagePaths) 366 | { 367 | if (cancellationToken.IsCancellationRequested) 368 | break; 369 | 370 | try 371 | { 372 | using var image = await Image.LoadAsync(imagePath, cancellationToken).ConfigureAwait(false); 373 | var sampledColors = SampleImageColors(image, pixelSampleRate); 374 | allColors.AddRange(sampledColors); 375 | } 376 | catch (Exception ex) 377 | { 378 | _logger.LogError(ex, "Error sampling colors from image {Path}", imagePath); 379 | } 380 | } 381 | 382 | if (allColors.Count == 0) 383 | { 384 | return Color.FromRgb(45, 45, 45); // Fallback neutral dark color 385 | } 386 | 387 | // Perform k-means clustering to find dominant colors 388 | var clusters = PerformKMeansClustering(allColors, k: 4); 389 | 390 | // Choose the best background color from clusters 391 | var backgroundColor = SelectBackgroundColor(clusters); 392 | 393 | return backgroundColor; 394 | } 395 | catch (Exception ex) 396 | { 397 | _logger.LogError(ex, "Error extracting dynamic background color"); 398 | return Color.FromRgb(45, 45, 45); // Fallback neutral dark color 399 | } 400 | } 401 | 402 | /// 403 | /// Samples colors from an image by resizing and sampling every Nth pixel. 404 | /// 405 | private static List SampleImageColors(Image image, int sampleRate = 3) 406 | { 407 | var colors = new List(); 408 | 409 | // Resize to 100x150px for speed as suggested in TODO 410 | using var resizedImage = image.Clone(); 411 | resizedImage.Mutate(x => x.Resize(100, 150)); 412 | 413 | // Sample every Nth pixel to reduce computation (default every 3rd) 414 | for (var y = 0; y < resizedImage.Height; y += sampleRate) 415 | { 416 | for (var x = 0; x < resizedImage.Width; x += sampleRate) 417 | { 418 | var pixel = resizedImage[x, y]; 419 | 420 | // Skip nearly transparent pixels 421 | if (pixel.A > 128) 422 | { 423 | colors.Add(pixel); 424 | } 425 | } 426 | } 427 | 428 | return colors; 429 | } 430 | 431 | /// 432 | /// Performs k-means clustering on color data to find dominant color groups. 433 | /// 434 | private static List PerformKMeansClustering(List colors, int k) 435 | { 436 | if (colors.Count == 0 || k <= 0) 437 | return new List(); 438 | 439 | var clusters = new List(); 440 | 441 | // Initialize cluster centroids deterministically by spreading them evenly 442 | for (var i = 0; i < k; i++) 443 | { 444 | var index = i * colors.Count / k; 445 | var randomColor = colors[index]; 446 | clusters.Add(new ColorCluster 447 | { 448 | CentroidR = randomColor.R, 449 | CentroidG = randomColor.G, 450 | CentroidB = randomColor.B, 451 | Colors = new List() 452 | }); 453 | } 454 | 455 | // Perform k-means iterations (max 10 iterations for performance) 456 | for (var iteration = 0; iteration < 10; iteration++) 457 | { 458 | // Clear previous assignments 459 | foreach (var cluster in clusters) 460 | { 461 | cluster.Colors.Clear(); 462 | } 463 | 464 | // Assign each color to the nearest cluster 465 | foreach (var color in colors) 466 | { 467 | var nearestCluster = clusters 468 | .OrderBy(c => ColorDistance(color, c.CentroidR, c.CentroidG, c.CentroidB)) 469 | .First(); 470 | nearestCluster.Colors.Add(color); 471 | } 472 | 473 | // Update centroids 474 | var hasChanged = false; 475 | foreach (var cluster in clusters) 476 | { 477 | if (cluster.Colors.Count > 0) 478 | { 479 | var avgR = (byte)cluster.Colors.Average(c => c.R); 480 | var avgG = (byte)cluster.Colors.Average(c => c.G); 481 | var avgB = (byte)cluster.Colors.Average(c => c.B); 482 | 483 | if (avgR != cluster.CentroidR || avgG != cluster.CentroidG || avgB != cluster.CentroidB) 484 | { 485 | hasChanged = true; 486 | cluster.CentroidR = avgR; 487 | cluster.CentroidG = avgG; 488 | cluster.CentroidB = avgB; 489 | } 490 | } 491 | } 492 | 493 | // If centroids didn't change, we've converged 494 | if (!hasChanged) 495 | break; 496 | } 497 | 498 | return clusters.Where(c => c.Colors.Count > 0).ToList(); 499 | } 500 | 501 | /// 502 | /// Calculates the Euclidean distance between a color and centroid values. 503 | /// 504 | private static double ColorDistance(Rgba32 color, byte centroidR, byte centroidG, byte centroidB) 505 | { 506 | var rDiff = color.R - centroidR; 507 | var gDiff = color.G - centroidG; 508 | var bDiff = color.B - centroidB; 509 | return Math.Sqrt(rDiff * rDiff + gDiff * gDiff + bDiff * bDiff); 510 | } 511 | 512 | /// 513 | /// Selects the best background color from color clusters. 514 | /// Skips dark/black clusters and chooses the second most prominent. 515 | /// 516 | private static Color SelectBackgroundColor(List clusters) 517 | { 518 | if (clusters.Count == 0) 519 | return Color.FromRgb(45, 45, 45); // Neutral fallback 520 | 521 | // Sort clusters by size (most prominent first) 522 | var sortedClusters = clusters.OrderByDescending(c => c.Colors.Count).ToList(); 523 | 524 | foreach (var cluster in sortedClusters) 525 | { 526 | var r = cluster.CentroidR; 527 | var g = cluster.CentroidG; 528 | var b = cluster.CentroidB; 529 | 530 | // Skip if the color is too dark/black (common in movie posters) 531 | var brightness = (r + g + b) / 3.0; 532 | if (brightness < 40) // Skip very dark colors 533 | continue; 534 | 535 | // Skip if the color is too bright/white 536 | if (brightness > 230) // Skip very bright colors 537 | continue; 538 | 539 | // Found a suitable color 540 | return Color.FromRgb(r, g, b); 541 | } 542 | 543 | // If all clusters are too extreme, use the second most prominent with adjustment 544 | if (sortedClusters.Count > 1) 545 | { 546 | var secondCluster = sortedClusters[1]; 547 | var r = Math.Max(60, Math.Min(180, (int)secondCluster.CentroidR)); // Clamp to moderate range 548 | var g = Math.Max(60, Math.Min(180, (int)secondCluster.CentroidG)); 549 | var b = Math.Max(60, Math.Min(180, (int)secondCluster.CentroidB)); 550 | return Color.FromRgb((byte)r, (byte)g, (byte)b); 551 | } 552 | 553 | // Final fallback: neutral color 554 | return Color.FromRgb(75, 75, 85); // Slightly blue-gray neutral 555 | } 556 | 557 | /// 558 | /// Determines the appropriate border color (black or white) based on background brightness. 559 | /// 560 | private static Color GetBorderColor(Color backgroundColor) 561 | { 562 | // Convert to Rgba32 to access color components 563 | var rgba = backgroundColor.ToPixel(); 564 | 565 | // Calculate perceived brightness using the standard luminance formula 566 | var luminance = (0.299 * rgba.R + 0.587 * rgba.G + 0.114 * rgba.B) / 255.0; 567 | 568 | // Use white border for dark backgrounds, black border for light backgrounds 569 | return luminance < 0.5 ? Color.White : Color.Black; 570 | } 571 | 572 | /// 573 | /// Represents a color cluster for k-means clustering. 574 | /// 575 | private class ColorCluster 576 | { 577 | public byte CentroidR { get; set; } 578 | public byte CentroidG { get; set; } 579 | public byte CentroidB { get; set; } 580 | public List Colors { get; set; } = new(); 581 | } 582 | } 583 | } 584 | --------------------------------------------------------------------------------