├── 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 |
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 | 
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 |
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 |
--------------------------------------------------------------------------------