├── .github └── FUNDING.yml ├── Images ├── tag.png ├── logo.png └── preview.png ├── Assets └── Roboto-Bold.ttf ├── EmbeddedIcons ├── pr_g.png ├── pr_r.png ├── ac_aac.png ├── ac_ac3.png ├── ac_dts.png ├── ac_mp3.png ├── ch_5.1.png ├── ch_7.1.png ├── hdr_dv.png ├── pr_pg.png ├── res_4k.png ├── vc_av1.png ├── vc_avc.png ├── vc_mp4.png ├── vc_vc1.png ├── vc_vp9.png ├── ac_eac3.png ├── ac_flac.png ├── ac_truehd.png ├── ar..4x3.png ├── ar_160x67.png ├── ar_16x9.png ├── ar_2.40x1.png ├── ar_40x17.png ├── ch_Mono.png ├── ch_stereo.png ├── hdr_hdr.png ├── pr_nc-17.png ├── pr_pg-13.png ├── pr_tv-14.png ├── pr_tv-ma.png ├── pr_tv-pg.png ├── res_1080p.png ├── res_480p.png ├── res_576p.png ├── res_720p.png ├── t_splat.png ├── t_tomato.png ├── tag_bob.png ├── vc_h264.png ├── vc_hevc.png ├── vc_mpeg4.png ├── lang_danish.png ├── lang_french.png ├── lang_german.png ├── lang_indian.png ├── rating_imdb.png ├── sub_chinese.png ├── sub_danish.png ├── sub_english.png ├── sub_french.png ├── sub_german.png ├── sub_indian.png ├── sub_swedish.png ├── hdr_hdr10plus.png ├── lang_chinese.png ├── lang_english.png ├── lang_japanese.png ├── lang_norwegian.png ├── lang_swedish.png ├── sub_japanese.png └── sub_norwegian.png ├── IconExamples ├── ac.aac.png ├── ac.ac3.png ├── ac.dts.png ├── ac.eac3.png ├── ac.flac.png ├── ac.mp3.png ├── ar..4x3.png ├── ar.16x9.png ├── ch.5.1.jpg ├── ch.7.1.jpg ├── ch.Mono.jpg ├── hdr.dv.png ├── hdr.hdr.png ├── res.4k.png ├── tag.bob.jpg ├── vc.av1.png ├── vc.avc.png ├── vc.h264.png ├── vc.hevc.png ├── vc.mp4.png ├── vc.vc1.png ├── vc.vp9.png ├── ac.truehd.png ├── ar.160x67.png ├── ar.2.40x1.png ├── ar.40x17.png ├── ch.stereo.jpg ├── res.1080p.png ├── res.480p.png ├── res.576p.png ├── res.720p.png ├── sub.danish.jpg ├── sub.french.png ├── sub.german.jpg ├── sub.indian.jpg ├── vc.mpeg4.png ├── lang.chinese.jpg ├── lang.danish.jpg ├── lang.english.jpg ├── lang.french.png ├── lang.german.jpg ├── lang.indian.jpg ├── lang.swedish.png ├── rating.imdb.png ├── sub.chinese.jpg ├── sub.english.jpg ├── sub.japanese.jpg ├── sub.swedish.png ├── hdr.hdr10plus.png └── lang.japanese.jpg ├── Services ├── IOverlayInfo.cs ├── ProfileService.cs ├── ApiRoutesService.cs ├── ValidationService.cs ├── ScanProgressService.cs ├── AspectRatioService.cs ├── CacheManagerService.cs ├── MemoryUsageService.cs ├── PreviewService.cs ├── ProfileManagerService.cs ├── SeriesTroubleshooterService.cs └── IconManagerService.cs ├── Helpers ├── LanguageHelper.cs ├── PluginHelper.cs ├── FontHelper.cs ├── Trie.cs ├── FileUtils.cs └── IconDrawer.cs ├── Configuration ├── EmbyIconsConfiguration.AddProfileTemplate.html ├── EmbyIconsConfiguration.RenameProfileTemplate.html ├── EmbyIconsConfiguration.IconManager.html ├── EmbyIconsConfiguration.Utils.js ├── Constants.cs ├── EmbyIconsConfiguration.Dom.js ├── StringConstants.cs ├── EmbyIconsConfiguration.DomCache.js ├── EmbyIconsConfiguration.ProfileUI.js ├── EmbyIconsConfiguration.Events.js ├── EmbyIconsConfiguration.Api.js ├── EmbyIconsConfiguration.DataLoader.js ├── EmbyIconsConfiguration.UIHandlers.js ├── EmbyIconsConfiguration.Troubleshooter.html ├── EmbyIconsConfiguration.Readme.html ├── EmbyIconsConfiguration.Advanced.html └── PluginOptions.cs ├── ImageProcessing ├── IImageProcessor.cs ├── ImageProcessingCapabilities.cs ├── EmbyNativeImageProcessor.cs ├── ImageProcessorFactory.cs └── SkiaSharpImageProcessor.cs ├── plugin.js ├── LICENSE ├── Models └── OverlayData.cs ├── EmbyIcons.sln ├── Api └── ApiRoutes.cs ├── EmbyIconsConfiguration.html ├── Caching └── EpisodeIconCache.cs ├── EmbyIcons.csproj └── EmbyIconsConfiguration.js /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | buy_me_a_coffee: yockser 2 | -------------------------------------------------------------------------------- /Images/tag.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/Images/tag.png -------------------------------------------------------------------------------- /Images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/Images/logo.png -------------------------------------------------------------------------------- /Images/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/Images/preview.png -------------------------------------------------------------------------------- /Assets/Roboto-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/Assets/Roboto-Bold.ttf -------------------------------------------------------------------------------- /EmbeddedIcons/pr_g.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/EmbeddedIcons/pr_g.png -------------------------------------------------------------------------------- /EmbeddedIcons/pr_r.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/EmbeddedIcons/pr_r.png -------------------------------------------------------------------------------- /EmbeddedIcons/ac_aac.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/EmbeddedIcons/ac_aac.png -------------------------------------------------------------------------------- /EmbeddedIcons/ac_ac3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/EmbeddedIcons/ac_ac3.png -------------------------------------------------------------------------------- /EmbeddedIcons/ac_dts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/EmbeddedIcons/ac_dts.png -------------------------------------------------------------------------------- /EmbeddedIcons/ac_mp3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/EmbeddedIcons/ac_mp3.png -------------------------------------------------------------------------------- /EmbeddedIcons/ch_5.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/EmbeddedIcons/ch_5.1.png -------------------------------------------------------------------------------- /EmbeddedIcons/ch_7.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/EmbeddedIcons/ch_7.1.png -------------------------------------------------------------------------------- /EmbeddedIcons/hdr_dv.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/EmbeddedIcons/hdr_dv.png -------------------------------------------------------------------------------- /EmbeddedIcons/pr_pg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/EmbeddedIcons/pr_pg.png -------------------------------------------------------------------------------- /EmbeddedIcons/res_4k.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/EmbeddedIcons/res_4k.png -------------------------------------------------------------------------------- /EmbeddedIcons/vc_av1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/EmbeddedIcons/vc_av1.png -------------------------------------------------------------------------------- /EmbeddedIcons/vc_avc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/EmbeddedIcons/vc_avc.png -------------------------------------------------------------------------------- /EmbeddedIcons/vc_mp4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/EmbeddedIcons/vc_mp4.png -------------------------------------------------------------------------------- /EmbeddedIcons/vc_vc1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/EmbeddedIcons/vc_vc1.png -------------------------------------------------------------------------------- /EmbeddedIcons/vc_vp9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/EmbeddedIcons/vc_vp9.png -------------------------------------------------------------------------------- /IconExamples/ac.aac.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/IconExamples/ac.aac.png -------------------------------------------------------------------------------- /IconExamples/ac.ac3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/IconExamples/ac.ac3.png -------------------------------------------------------------------------------- /IconExamples/ac.dts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/IconExamples/ac.dts.png -------------------------------------------------------------------------------- /IconExamples/ac.eac3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/IconExamples/ac.eac3.png -------------------------------------------------------------------------------- /IconExamples/ac.flac.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/IconExamples/ac.flac.png -------------------------------------------------------------------------------- /IconExamples/ac.mp3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/IconExamples/ac.mp3.png -------------------------------------------------------------------------------- /IconExamples/ar..4x3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/IconExamples/ar..4x3.png -------------------------------------------------------------------------------- /IconExamples/ar.16x9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/IconExamples/ar.16x9.png -------------------------------------------------------------------------------- /IconExamples/ch.5.1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/IconExamples/ch.5.1.jpg -------------------------------------------------------------------------------- /IconExamples/ch.7.1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/IconExamples/ch.7.1.jpg -------------------------------------------------------------------------------- /IconExamples/ch.Mono.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/IconExamples/ch.Mono.jpg -------------------------------------------------------------------------------- /IconExamples/hdr.dv.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/IconExamples/hdr.dv.png -------------------------------------------------------------------------------- /IconExamples/hdr.hdr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/IconExamples/hdr.hdr.png -------------------------------------------------------------------------------- /IconExamples/res.4k.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/IconExamples/res.4k.png -------------------------------------------------------------------------------- /IconExamples/tag.bob.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/IconExamples/tag.bob.jpg -------------------------------------------------------------------------------- /IconExamples/vc.av1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/IconExamples/vc.av1.png -------------------------------------------------------------------------------- /IconExamples/vc.avc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/IconExamples/vc.avc.png -------------------------------------------------------------------------------- /IconExamples/vc.h264.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/IconExamples/vc.h264.png -------------------------------------------------------------------------------- /IconExamples/vc.hevc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/IconExamples/vc.hevc.png -------------------------------------------------------------------------------- /IconExamples/vc.mp4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/IconExamples/vc.mp4.png -------------------------------------------------------------------------------- /IconExamples/vc.vc1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/IconExamples/vc.vc1.png -------------------------------------------------------------------------------- /IconExamples/vc.vp9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/IconExamples/vc.vp9.png -------------------------------------------------------------------------------- /EmbeddedIcons/ac_eac3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/EmbeddedIcons/ac_eac3.png -------------------------------------------------------------------------------- /EmbeddedIcons/ac_flac.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/EmbeddedIcons/ac_flac.png -------------------------------------------------------------------------------- /EmbeddedIcons/ac_truehd.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/EmbeddedIcons/ac_truehd.png -------------------------------------------------------------------------------- /EmbeddedIcons/ar..4x3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/EmbeddedIcons/ar..4x3.png -------------------------------------------------------------------------------- /EmbeddedIcons/ar_160x67.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/EmbeddedIcons/ar_160x67.png -------------------------------------------------------------------------------- /EmbeddedIcons/ar_16x9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/EmbeddedIcons/ar_16x9.png -------------------------------------------------------------------------------- /EmbeddedIcons/ar_2.40x1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/EmbeddedIcons/ar_2.40x1.png -------------------------------------------------------------------------------- /EmbeddedIcons/ar_40x17.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/EmbeddedIcons/ar_40x17.png -------------------------------------------------------------------------------- /EmbeddedIcons/ch_Mono.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/EmbeddedIcons/ch_Mono.png -------------------------------------------------------------------------------- /EmbeddedIcons/ch_stereo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/EmbeddedIcons/ch_stereo.png -------------------------------------------------------------------------------- /EmbeddedIcons/hdr_hdr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/EmbeddedIcons/hdr_hdr.png -------------------------------------------------------------------------------- /EmbeddedIcons/pr_nc-17.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/EmbeddedIcons/pr_nc-17.png -------------------------------------------------------------------------------- /EmbeddedIcons/pr_pg-13.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/EmbeddedIcons/pr_pg-13.png -------------------------------------------------------------------------------- /EmbeddedIcons/pr_tv-14.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/EmbeddedIcons/pr_tv-14.png -------------------------------------------------------------------------------- /EmbeddedIcons/pr_tv-ma.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/EmbeddedIcons/pr_tv-ma.png -------------------------------------------------------------------------------- /EmbeddedIcons/pr_tv-pg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/EmbeddedIcons/pr_tv-pg.png -------------------------------------------------------------------------------- /EmbeddedIcons/res_1080p.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/EmbeddedIcons/res_1080p.png -------------------------------------------------------------------------------- /EmbeddedIcons/res_480p.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/EmbeddedIcons/res_480p.png -------------------------------------------------------------------------------- /EmbeddedIcons/res_576p.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/EmbeddedIcons/res_576p.png -------------------------------------------------------------------------------- /EmbeddedIcons/res_720p.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/EmbeddedIcons/res_720p.png -------------------------------------------------------------------------------- /EmbeddedIcons/t_splat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/EmbeddedIcons/t_splat.png -------------------------------------------------------------------------------- /EmbeddedIcons/t_tomato.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/EmbeddedIcons/t_tomato.png -------------------------------------------------------------------------------- /EmbeddedIcons/tag_bob.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/EmbeddedIcons/tag_bob.png -------------------------------------------------------------------------------- /EmbeddedIcons/vc_h264.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/EmbeddedIcons/vc_h264.png -------------------------------------------------------------------------------- /EmbeddedIcons/vc_hevc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/EmbeddedIcons/vc_hevc.png -------------------------------------------------------------------------------- /EmbeddedIcons/vc_mpeg4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/EmbeddedIcons/vc_mpeg4.png -------------------------------------------------------------------------------- /IconExamples/ac.truehd.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/IconExamples/ac.truehd.png -------------------------------------------------------------------------------- /IconExamples/ar.160x67.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/IconExamples/ar.160x67.png -------------------------------------------------------------------------------- /IconExamples/ar.2.40x1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/IconExamples/ar.2.40x1.png -------------------------------------------------------------------------------- /IconExamples/ar.40x17.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/IconExamples/ar.40x17.png -------------------------------------------------------------------------------- /IconExamples/ch.stereo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/IconExamples/ch.stereo.jpg -------------------------------------------------------------------------------- /IconExamples/res.1080p.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/IconExamples/res.1080p.png -------------------------------------------------------------------------------- /IconExamples/res.480p.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/IconExamples/res.480p.png -------------------------------------------------------------------------------- /IconExamples/res.576p.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/IconExamples/res.576p.png -------------------------------------------------------------------------------- /IconExamples/res.720p.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/IconExamples/res.720p.png -------------------------------------------------------------------------------- /IconExamples/sub.danish.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/IconExamples/sub.danish.jpg -------------------------------------------------------------------------------- /IconExamples/sub.french.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/IconExamples/sub.french.png -------------------------------------------------------------------------------- /IconExamples/sub.german.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/IconExamples/sub.german.jpg -------------------------------------------------------------------------------- /IconExamples/sub.indian.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/IconExamples/sub.indian.jpg -------------------------------------------------------------------------------- /IconExamples/vc.mpeg4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/IconExamples/vc.mpeg4.png -------------------------------------------------------------------------------- /EmbeddedIcons/lang_danish.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/EmbeddedIcons/lang_danish.png -------------------------------------------------------------------------------- /EmbeddedIcons/lang_french.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/EmbeddedIcons/lang_french.png -------------------------------------------------------------------------------- /EmbeddedIcons/lang_german.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/EmbeddedIcons/lang_german.png -------------------------------------------------------------------------------- /EmbeddedIcons/lang_indian.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/EmbeddedIcons/lang_indian.png -------------------------------------------------------------------------------- /EmbeddedIcons/rating_imdb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/EmbeddedIcons/rating_imdb.png -------------------------------------------------------------------------------- /EmbeddedIcons/sub_chinese.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/EmbeddedIcons/sub_chinese.png -------------------------------------------------------------------------------- /EmbeddedIcons/sub_danish.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/EmbeddedIcons/sub_danish.png -------------------------------------------------------------------------------- /EmbeddedIcons/sub_english.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/EmbeddedIcons/sub_english.png -------------------------------------------------------------------------------- /EmbeddedIcons/sub_french.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/EmbeddedIcons/sub_french.png -------------------------------------------------------------------------------- /EmbeddedIcons/sub_german.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/EmbeddedIcons/sub_german.png -------------------------------------------------------------------------------- /EmbeddedIcons/sub_indian.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/EmbeddedIcons/sub_indian.png -------------------------------------------------------------------------------- /EmbeddedIcons/sub_swedish.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/EmbeddedIcons/sub_swedish.png -------------------------------------------------------------------------------- /IconExamples/lang.chinese.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/IconExamples/lang.chinese.jpg -------------------------------------------------------------------------------- /IconExamples/lang.danish.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/IconExamples/lang.danish.jpg -------------------------------------------------------------------------------- /IconExamples/lang.english.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/IconExamples/lang.english.jpg -------------------------------------------------------------------------------- /IconExamples/lang.french.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/IconExamples/lang.french.png -------------------------------------------------------------------------------- /IconExamples/lang.german.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/IconExamples/lang.german.jpg -------------------------------------------------------------------------------- /IconExamples/lang.indian.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/IconExamples/lang.indian.jpg -------------------------------------------------------------------------------- /IconExamples/lang.swedish.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/IconExamples/lang.swedish.png -------------------------------------------------------------------------------- /IconExamples/rating.imdb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/IconExamples/rating.imdb.png -------------------------------------------------------------------------------- /IconExamples/sub.chinese.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/IconExamples/sub.chinese.jpg -------------------------------------------------------------------------------- /IconExamples/sub.english.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/IconExamples/sub.english.jpg -------------------------------------------------------------------------------- /IconExamples/sub.japanese.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/IconExamples/sub.japanese.jpg -------------------------------------------------------------------------------- /IconExamples/sub.swedish.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/IconExamples/sub.swedish.png -------------------------------------------------------------------------------- /EmbeddedIcons/hdr_hdr10plus.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/EmbeddedIcons/hdr_hdr10plus.png -------------------------------------------------------------------------------- /EmbeddedIcons/lang_chinese.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/EmbeddedIcons/lang_chinese.png -------------------------------------------------------------------------------- /EmbeddedIcons/lang_english.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/EmbeddedIcons/lang_english.png -------------------------------------------------------------------------------- /EmbeddedIcons/lang_japanese.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/EmbeddedIcons/lang_japanese.png -------------------------------------------------------------------------------- /EmbeddedIcons/lang_norwegian.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/EmbeddedIcons/lang_norwegian.png -------------------------------------------------------------------------------- /EmbeddedIcons/lang_swedish.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/EmbeddedIcons/lang_swedish.png -------------------------------------------------------------------------------- /EmbeddedIcons/sub_japanese.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/EmbeddedIcons/sub_japanese.png -------------------------------------------------------------------------------- /EmbeddedIcons/sub_norwegian.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/EmbeddedIcons/sub_norwegian.png -------------------------------------------------------------------------------- /IconExamples/hdr.hdr10plus.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/IconExamples/hdr.hdr10plus.png -------------------------------------------------------------------------------- /IconExamples/lang.japanese.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yocksers/EmbyIcons/HEAD/IconExamples/lang.japanese.jpg -------------------------------------------------------------------------------- /Services/IOverlayInfo.cs: -------------------------------------------------------------------------------- 1 | using EmbyIcons.Configuration; 2 | 3 | namespace EmbyIcons.Services 4 | { 5 | internal interface IOverlayInfo 6 | { 7 | IconAlignment Alignment { get; } 8 | int Priority { get; } 9 | bool HorizontalLayout { get; } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Helpers/LanguageHelper.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Globalization; 4 | using System.Linq; 5 | 6 | namespace EmbyIcons.Helpers 7 | { 8 | internal static class LanguageHelper 9 | { 10 | public static string NormalizeLangCode(string code) 11 | { 12 | if (string.IsNullOrWhiteSpace(code)) 13 | return code; 14 | 15 | return code.ToLowerInvariant(); 16 | } 17 | } 18 | } -------------------------------------------------------------------------------- /Services/ProfileService.cs: -------------------------------------------------------------------------------- 1 | using EmbyIcons.Api; 2 | using EmbyIcons.Configuration; 3 | using MediaBrowser.Model.Services; 4 | using System.Threading.Tasks; 5 | 6 | namespace EmbyIcons.Services 7 | { 8 | [Route(ApiRoutes.DefaultProfile, "GET", Summary = "Gets a new icon profile with default settings")] 9 | public class GetDefaultProfile : IReturn { } 10 | 11 | public class ProfileService : IService 12 | { 13 | public Task Get(GetDefaultProfile request) 14 | { 15 | return Task.FromResult(new IconProfile()); 16 | } 17 | } 18 | } -------------------------------------------------------------------------------- /Helpers/PluginHelper.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace EmbyIcons.Helpers 4 | { 5 | internal static class PluginHelper 6 | { 7 | public static bool IsDebugLoggingEnabled => Plugin.Instance?.Configuration.EnableDebugLogging ?? false; 8 | 9 | public static void SafeDispose(IDisposable? disposable, Action? onError = null) 10 | { 11 | if (disposable == null) return; 12 | 13 | try 14 | { 15 | disposable.Dispose(); 16 | } 17 | catch (Exception ex) 18 | { 19 | onError?.Invoke(ex); 20 | } 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Configuration/EmbyIconsConfiguration.AddProfileTemplate.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |

Add New Profile

4 |
5 |
6 |
7 |
8 | 9 |
10 |
11 | 12 |
13 |
14 |
-------------------------------------------------------------------------------- /Configuration/EmbyIconsConfiguration.RenameProfileTemplate.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |

Rename Profile

4 |
5 |
6 |
7 |
8 | 9 |
10 |
11 | 12 |
13 |
14 |
-------------------------------------------------------------------------------- /Configuration/EmbyIconsConfiguration.IconManager.html: -------------------------------------------------------------------------------- 1 |
2 |

Icon Manager

3 |

Scan your media library and custom icon folder to find missing or unused icons. On very large libraries this can take several minutes; results are cached until server restart or configuration changes.

4 |
5 | 8 |
9 |
10 | 11 |
12 |
-------------------------------------------------------------------------------- /Configuration/EmbyIconsConfiguration.Utils.js: -------------------------------------------------------------------------------- 1 | define([], function () { 2 | 'use strict'; 3 | 4 | const transparentPixel = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='; 5 | 6 | function debounce(func, wait) { 7 | let timeout; 8 | return function () { 9 | const context = this; 10 | const args = Array.prototype.slice.call(arguments); 11 | clearTimeout(timeout); 12 | timeout = setTimeout(function () { 13 | func.apply(context, args); 14 | }, wait); 15 | }; 16 | } 17 | 18 | return { 19 | debounce: debounce, 20 | transparentPixel: transparentPixel 21 | }; 22 | }); 23 | -------------------------------------------------------------------------------- /ImageProcessing/IImageProcessor.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | 4 | namespace EmbyIcons.ImageProcessing 5 | { 6 | public interface IImageProcessor : IDisposable 7 | { 8 | string Name { get; } 9 | bool IsAvailable { get; } 10 | object DecodeImage(Stream inputStream); 11 | void GetImageDimensions(object image, out int width, out int height); 12 | object CreateBlankImage(int width, int height); 13 | void DrawImage(object targetImage, object sourceImage, int x, int y, int width, int height, bool enableSmoothing); 14 | void DrawText(object image, string text, int x, int y, float fontSize, string color, string fontFamily, bool enableSmoothing); 15 | void EncodeImage(object image, Stream outputStream, string format, int quality); 16 | void DisposeImage(object image); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Services/ApiRoutesService.cs: -------------------------------------------------------------------------------- 1 | using EmbyIcons.Api; 2 | using MediaBrowser.Model.Services; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using System.Reflection; 6 | using System.Threading.Tasks; 7 | 8 | namespace EmbyIcons.Services 9 | { 10 | [Route(ApiRoutes.GetApiRoutes, "GET", Summary = "Gets all API routes for the plugin")] 11 | public class GetApiRoutesRequest : IReturn> { } 12 | 13 | public class ApiRoutesService : IService 14 | { 15 | public Task Get(GetApiRoutesRequest request) 16 | { 17 | var routes = typeof(ApiRoutes) 18 | .GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy) 19 | .Where(fi => fi.IsLiteral && !fi.IsInitOnly && fi.FieldType == typeof(string)) 20 | .ToDictionary(fi => fi.Name, fi => (string)fi.GetRawConstantValue()!); 21 | 22 | return Task.FromResult(routes); 23 | } 24 | } 25 | } -------------------------------------------------------------------------------- /plugin.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | const pluginId = "b8d0f5a4-3e96-4c0f-a6e2-9f0c2ecb5c5f"; 3 | 4 | const pluginVersion = "5.42.2"; 5 | 6 | window.Dashboard.getPluginPages = function () { 7 | return [ 8 | { 9 | name: 'EmbyIcons', 10 | path: Dashboard.getConfigurationPageUrl('EmbyIconsConfiguration'), 11 | plugin: 'EmbyIcons', 12 | icon: 'photo' 13 | } 14 | ]; 15 | }; 16 | 17 | window.Dashboard.getPluginRoutes = function () { 18 | return [ 19 | { 20 | path: '/plugins/embyiconsconfiguration.html', 21 | id: 'embyiconsconfiguration', 22 | 23 | controller: 'plugins/embyicons/embyiconsconfiguration.js?v=' + pluginVersion, 24 | 25 | template: 'plugins/embyicons/embyiconsconfiguration.html?v=' + pluginVersion, 26 | title: 'EmbyIcons', 27 | mobile: true 28 | } 29 | ]; 30 | }; 31 | 32 | })(); -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 yock 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 | -------------------------------------------------------------------------------- /Models/OverlayData.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace EmbyIcons.Models 4 | { 5 | internal class OverlayData 6 | { 7 | public HashSet AudioLanguages { get; set; } = new(System.StringComparer.OrdinalIgnoreCase); 8 | public HashSet SubtitleLanguages { get; set; } = new(System.StringComparer.OrdinalIgnoreCase); 9 | public HashSet AudioCodecs { get; set; } = new(System.StringComparer.OrdinalIgnoreCase); 10 | public HashSet VideoCodecs { get; set; } = new(System.StringComparer.OrdinalIgnoreCase); 11 | public HashSet Tags { get; set; } = new(); 12 | public HashSet SourceIcons { get; set; } = new(System.StringComparer.OrdinalIgnoreCase); 13 | public string? ChannelIconName { get; set; } 14 | public string? VideoFormatIconName { get; set; } 15 | public string? ResolutionIconName { get; set; } 16 | public float? CommunityRating { get; set; } 17 | public float? RottenTomatoesRating { get; set; } 18 | public string? AspectRatioIconName { get; set; } 19 | public string? ParentalRatingIconName { get; set; } 20 | } 21 | } -------------------------------------------------------------------------------- /EmbyIcons.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.14.36429.23 d17.14 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EmbyIcons", "EmbyIcons.csproj", "{BC45F2D3-20B4-EA89-3086-A43858F09AFA}" 7 | EndProject 8 | Global 9 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 10 | Debug|Any CPU = Debug|Any CPU 11 | Release|Any CPU = Release|Any CPU 12 | EndGlobalSection 13 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 14 | {BC45F2D3-20B4-EA89-3086-A43858F09AFA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 15 | {BC45F2D3-20B4-EA89-3086-A43858F09AFA}.Debug|Any CPU.Build.0 = Debug|Any CPU 16 | {BC45F2D3-20B4-EA89-3086-A43858F09AFA}.Release|Any CPU.ActiveCfg = Release|Any CPU 17 | {BC45F2D3-20B4-EA89-3086-A43858F09AFA}.Release|Any CPU.Build.0 = Release|Any CPU 18 | EndGlobalSection 19 | GlobalSection(SolutionProperties) = preSolution 20 | HideSolutionNode = FALSE 21 | EndGlobalSection 22 | GlobalSection(ExtensibilityGlobals) = postSolution 23 | SolutionGuid = {0FDF6311-69C8-49BE-BEAD-8E695EE6B583} 24 | EndGlobalSection 25 | EndGlobal 26 | -------------------------------------------------------------------------------- /Api/ApiRoutes.cs: -------------------------------------------------------------------------------- 1 | namespace EmbyIcons.Api 2 | { 3 | internal static class ApiRoutes 4 | { 5 | public const string DefaultProfile = "/EmbyIcons/DefaultProfile"; 6 | public const string RefreshCache = "/EmbyIcons/RefreshCache"; 7 | public const string IconManagerReport = "/EmbyIcons/IconManagerReport"; 8 | public const string Preview = "/EmbyIcons/Preview"; 9 | public const string ValidatePath = "/EmbyIcons/ValidatePath"; 10 | public const string SeriesTroubleshooter = "/EmbyIcons/SeriesTroubleshooter"; 11 | public const string AspectRatio = "/EmbyIcons/AspectRatio"; 12 | public const string GetApiRoutes = "/EmbyIcons/ApiRoutes"; 13 | public const string ScanProgress = "/EmbyIcons/ScanProgress"; 14 | public const string MemoryUsage = "/EmbyIcons/MemoryUsage"; 15 | 16 | public const string ExportProfiles = "/EmbyIcons/ExportProfiles"; 17 | public const string ImportProfiles = "/EmbyIcons/ImportProfiles"; 18 | public const string ValidateProfileImport = "/EmbyIcons/ValidateProfileImport"; 19 | public const string TemplateCacheStats = "/EmbyIcons/TemplateCacheStats"; 20 | public const string ClearTemplateCache = "/EmbyIcons/ClearTemplateCache"; 21 | } 22 | } -------------------------------------------------------------------------------- /Configuration/Constants.cs: -------------------------------------------------------------------------------- 1 | using EmbyIcons.Caching; 2 | using System.Collections.Generic; 3 | 4 | namespace EmbyIcons.Configuration 5 | { 6 | internal static class Constants 7 | { 8 | public const string Episode = StringConstants.EpisodeType; 9 | 10 | public const int DefaultProviderPathCacheSize = 5000; 11 | public const double CacheCompactionPercentage = 0.1; 12 | public const double MinMaintenanceIntervalHours = 0.5; 13 | 14 | public static readonly Dictionary PrefixMap = new() 15 | { 16 | { IconCacheManager.IconType.Language, "lang" }, 17 | { IconCacheManager.IconType.Subtitle, "sub" }, 18 | { IconCacheManager.IconType.Channel, "ch" }, 19 | { IconCacheManager.IconType.VideoFormat, "hdr" }, 20 | { IconCacheManager.IconType.Resolution, "res" }, 21 | { IconCacheManager.IconType.AudioCodec, "ac" }, 22 | { IconCacheManager.IconType.VideoCodec, "vc" }, 23 | { IconCacheManager.IconType.Tag, "tag" }, 24 | { IconCacheManager.IconType.CommunityRating, "rating" }, 25 | { IconCacheManager.IconType.AspectRatio, "ar" }, 26 | { IconCacheManager.IconType.ParentalRating, "pr" }, 27 | { IconCacheManager.IconType.Source, "source" } 28 | }; 29 | } 30 | } -------------------------------------------------------------------------------- /Configuration/EmbyIconsConfiguration.Dom.js: -------------------------------------------------------------------------------- 1 | define([], function () { 2 | 'use strict'; 3 | 4 | function createFilenameMappingRow(keyword, iconName) { 5 | const newRow = document.createElement('div'); 6 | newRow.classList.add('filenameMappingRow'); 7 | newRow.style.display = 'flex'; 8 | newRow.style.gap = '1em'; 9 | newRow.style.alignItems = 'center'; 10 | newRow.style.marginBottom = '1em'; 11 | 12 | newRow.innerHTML = ` 13 |
14 | 15 |
Case-insensitive text to find in the filename.
16 |
17 |
18 | 19 |
e.g., 'remux' for 'source.remux.png'
20 |
21 | 22 | `; 23 | 24 | return newRow; 25 | } 26 | 27 | return { 28 | createFilenameMappingRow: createFilenameMappingRow 29 | }; 30 | }); 31 | -------------------------------------------------------------------------------- /Configuration/StringConstants.cs: -------------------------------------------------------------------------------- 1 | namespace EmbyIcons.Configuration 2 | { 3 | internal static class StringConstants 4 | { 5 | public const string DolbyVision = "dolby vision"; 6 | public const string DolbyVisionCompact = "dolbyvision"; 7 | public const string DolbyShort = "dolby"; 8 | public const string DVShort = "dv"; 9 | public const string HDR10Plus = "hdr10+"; 10 | public const string HDR10PlusCompact = "hdr10plus"; 11 | public const string HDR = "hdr"; 12 | 13 | public const string RottenTomatoesProvider = "rotten"; 14 | public const string RTShort = "rt"; 15 | public const string ImdbProvider = "Imdb"; 16 | public const string TmdbProvider = "Tmdb"; 17 | 18 | public const string TomatoIcon = "t.tomato"; 19 | public const string SplatIcon = "t.splat"; 20 | public const string ImdbIcon = "imdb"; 21 | 22 | public const string RatingPropertyName = "Rating"; 23 | public const string RatingsPropertyName = "Ratings"; 24 | public const string ExternalPropertyName = "External"; 25 | public const string SourcePropertyName = "Source"; 26 | public const string NamePropertyName = "Name"; 27 | public const string KeyPropertyName = "Key"; 28 | public const string ValuePropertyName = "Value"; 29 | public const string ScorePropertyName = "Score"; 30 | 31 | public const string EpisodeType = "Episode"; 32 | public const string MovieType = "Movie"; 33 | 34 | public const string LogPrefix = "[EmbyIcons]"; 35 | 36 | public const string PercentFormat = "F1"; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /EmbyIconsConfiguration.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 |
5 | 6 |
7 | Settings 8 | Advanced 9 | Icon Manager 10 | Troubleshooter 11 | Readme 12 |
13 | 14 | 15 |
16 |
17 |
18 |
19 |
20 | 21 |
22 |
23 | 24 | 25 | 26 |
-------------------------------------------------------------------------------- /Services/ValidationService.cs: -------------------------------------------------------------------------------- 1 | using EmbyIcons.Api; 2 | using EmbyIcons.Caching; 3 | using MediaBrowser.Model.IO; 4 | using MediaBrowser.Model.Services; 5 | using System.IO; 6 | using System.Linq; 7 | 8 | namespace EmbyIcons.Services 9 | { 10 | [Route(ApiRoutes.ValidatePath, "GET", Summary = "Validates if a given path exists on the server")] 11 | public class ValidatePathRequest : IReturn 12 | { 13 | [ApiMember(Name = "Path", Description = "The path to validate.", IsRequired = true, DataType = "string", ParameterType = "query")] 14 | public string Path { get; set; } = string.Empty; 15 | } 16 | 17 | public class ValidatePathResponse 18 | { 19 | public bool Exists { get; set; } 20 | public bool HasImages { get; set; } 21 | } 22 | 23 | public class ValidationService : IService 24 | { 25 | private readonly IFileSystem _fileSystem; 26 | 27 | public ValidationService(IFileSystem fileSystem) 28 | { 29 | _fileSystem = fileSystem; 30 | } 31 | 32 | public object Get(ValidatePathRequest request) 33 | { 34 | if (string.IsNullOrWhiteSpace(request.Path)) 35 | { 36 | return new ValidatePathResponse { Exists = false, HasImages = false }; 37 | } 38 | 39 | var path = System.Environment.ExpandEnvironmentVariables(request.Path); 40 | var exists = _fileSystem.DirectoryExists(path); 41 | var hasImages = false; 42 | 43 | if (exists) 44 | { 45 | hasImages = _fileSystem.GetFiles(path) 46 | .Any(f => 47 | { 48 | var ext = Path.GetExtension(f.FullName).ToLowerInvariant(); 49 | return Caching.IconCacheManager.SupportedCustomIconExtensions.Contains(ext); 50 | }); 51 | } 52 | 53 | return new ValidatePathResponse { Exists = exists, HasImages = hasImages }; 54 | } 55 | } 56 | } -------------------------------------------------------------------------------- /Helpers/FontHelper.cs: -------------------------------------------------------------------------------- 1 | using MediaBrowser.Model.Logging; 2 | using SkiaSharp; 3 | using System.Reflection; 4 | 5 | namespace EmbyIcons.Helpers 6 | { 7 | internal static class FontHelper 8 | { 9 | private static SKTypeface? _typeface; 10 | private static readonly object _lock = new object(); 11 | 12 | public static SKTypeface GetDefaultBold(ILogger logger) 13 | { 14 | if (_typeface != null) return _typeface; 15 | 16 | lock (_lock) 17 | { 18 | if (_typeface != null) return _typeface; 19 | 20 | try 21 | { 22 | var asm = Assembly.GetExecutingAssembly(); 23 | var name = $"{typeof(Plugin).Namespace}.Assets.Roboto-Bold.ttf"; 24 | using var stream = asm.GetManifestResourceStream(name); 25 | 26 | if (stream == null || stream.Length == 0) 27 | { 28 | logger?.Warn($"[EmbyIcons] Embedded font '{name}' not found. Falling back to system font."); 29 | _typeface = SKTypeface.FromFamilyName("sans-serif", SKFontStyle.Bold) ?? SKTypeface.CreateDefault(); 30 | } 31 | else 32 | { 33 | logger?.Info("[EmbyIcons] Successfully loaded embedded font."); 34 | _typeface = SKTypeface.FromStream(stream); 35 | } 36 | } 37 | catch (System.Exception ex) 38 | { 39 | logger?.ErrorException("[EmbyIcons] CRITICAL: Failed to load embedded font, falling back to absolute default.", ex); 40 | _typeface = SKTypeface.CreateDefault(); 41 | } 42 | 43 | return _typeface; 44 | } 45 | } 46 | 47 | public static void Dispose() 48 | { 49 | lock (_lock) 50 | { 51 | _typeface?.Dispose(); 52 | _typeface = null; 53 | } 54 | } 55 | } 56 | } -------------------------------------------------------------------------------- /Services/ScanProgressService.cs: -------------------------------------------------------------------------------- 1 | using EmbyIcons.Api; 2 | using MediaBrowser.Model.Services; 3 | using System.Collections.Concurrent; 4 | 5 | namespace EmbyIcons.Services 6 | { 7 | [Route(ApiRoutes.ScanProgress, "GET", Summary = "Gets the progress of a long-running scan")] 8 | public class GetScanProgress : IReturn 9 | { 10 | [ApiMember(Name = "ScanType", Description = "The type of scan to get progress for (e.g., 'IconManager', 'FullSeriesScan').", IsRequired = true, DataType = "string", ParameterType = "query")] 11 | public string ScanType { get; set; } = string.Empty; 12 | } 13 | 14 | public class ScanProgress 15 | { 16 | public int Current { get; set; } 17 | public int Total { get; set; } 18 | public string Message { get; set; } = string.Empty; 19 | public bool IsComplete { get; set; } 20 | } 21 | 22 | public class ScanProgressService : IService 23 | { 24 | private static readonly ConcurrentDictionary _progressCache = new(System.StringComparer.OrdinalIgnoreCase); 25 | 26 | public static void UpdateProgress(string scanType, int current, int total, string message) 27 | { 28 | var progress = new ScanProgress { Current = current, Total = total, Message = message, IsComplete = current >= total }; 29 | _progressCache.AddOrUpdate(scanType, progress, (key, old) => progress); 30 | } 31 | 32 | public static void ClearProgress(string scanType) 33 | { 34 | _progressCache.TryRemove(scanType, out _); 35 | } 36 | 37 | public object Get(GetScanProgress request) 38 | { 39 | if (string.IsNullOrEmpty(request.ScanType)) 40 | { 41 | return new ScanProgress { IsComplete = true, Message = "Invalid scan type." }; 42 | } 43 | 44 | if (_progressCache.TryGetValue(request.ScanType, out var progress)) 45 | { 46 | return progress; 47 | } 48 | 49 | return new ScanProgress { IsComplete = true, Message = "Scan has not started." }; 50 | } 51 | } 52 | } -------------------------------------------------------------------------------- /Helpers/Trie.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | 4 | namespace EmbyIcons.Helpers 5 | { 6 | public class Trie 7 | { 8 | private readonly TrieNode _root = new TrieNode(); 9 | 10 | private class TrieNode 11 | { 12 | public T? Value { get; set; } 13 | public bool IsTerminal { get; set; } 14 | public Dictionary Children { get; } = new Dictionary(); 15 | } 16 | 17 | public void Insert(string key, T value) 18 | { 19 | if (string.IsNullOrEmpty(key)) return; 20 | 21 | var node = _root; 22 | var lowerKey = key.ToLowerInvariant(); 23 | 24 | foreach (char c in lowerKey) 25 | { 26 | if (!node.Children.TryGetValue(c, out var child)) 27 | { 28 | child = new TrieNode(); 29 | node.Children[c] = child; 30 | } 31 | node = child; 32 | } 33 | node.IsTerminal = true; 34 | node.Value = value; 35 | } 36 | 37 | public T? FindLongestPrefix(string query) 38 | { 39 | if (string.IsNullOrEmpty(query)) return default; 40 | 41 | var node = _root; 42 | T? longestPrefixValue = default; 43 | 44 | if (node.IsTerminal) 45 | { 46 | longestPrefixValue = node.Value; 47 | } 48 | 49 | var lowerQuery = query.ToLowerInvariant(); 50 | foreach (char c in lowerQuery) 51 | { 52 | if (node.Children.TryGetValue(c, out var child)) 53 | { 54 | node = child; 55 | if (node.IsTerminal) 56 | { 57 | longestPrefixValue = node.Value; 58 | } 59 | } 60 | else 61 | { 62 | break; 63 | } 64 | } 65 | 66 | return longestPrefixValue; 67 | } 68 | } 69 | } -------------------------------------------------------------------------------- /Services/AspectRatioService.cs: -------------------------------------------------------------------------------- 1 | using EmbyIcons.Api; 2 | using EmbyIcons.Configuration; 3 | using EmbyIcons.Helpers; 4 | using MediaBrowser.Model.Services; 5 | using System; 6 | 7 | namespace EmbyIcons.Services 8 | { 9 | [Route(ApiRoutes.AspectRatio, "GET", Summary = "Calculates aspect ratio information for given dimensions")] 10 | public class GetAspectRatio : IReturn 11 | { 12 | [ApiMember(Name = "Width", Description = "The width of the video.", IsRequired = true, DataType = "int", ParameterType = "query")] 13 | public int Width { get; set; } 14 | 15 | [ApiMember(Name = "Height", Description = "The height of the video.", IsRequired = true, DataType = "int", ParameterType = "query")] 16 | public int Height { get; set; } 17 | } 18 | 19 | public class AspectRatioResponse 20 | { 21 | public double DecimalRatio { get; set; } 22 | public string SnappedName { get; set; } = string.Empty; 23 | public string PreciseName { get; set; } = string.Empty; 24 | } 25 | 26 | public class AspectRatioService : IService 27 | { 28 | private static ulong Gcd(ulong a, ulong b) 29 | { 30 | while (b != 0) 31 | { 32 | ulong temp = b; 33 | b = a % b; 34 | a = temp; 35 | } 36 | return a; 37 | } 38 | 39 | public object Get(GetAspectRatio request) 40 | { 41 | if (request.Width <= 0 || request.Height <= 0) 42 | { 43 | return new AspectRatioResponse(); 44 | } 45 | 46 | var snappedName = MediaStreamHelper.GetAspectRatioIconName(request.Width, request.Height, snapToCommon: true); 47 | 48 | var divisor = Gcd((ulong)request.Width, (ulong)request.Height); 49 | 50 | var preciseName = $"{(ulong)request.Width / divisor}x{(ulong)request.Height / divisor}"; 51 | 52 | return new AspectRatioResponse 53 | { 54 | DecimalRatio = (double)request.Width / request.Height, 55 | SnappedName = snappedName ?? "unknown", 56 | PreciseName = preciseName 57 | }; 58 | } 59 | } 60 | } -------------------------------------------------------------------------------- /ImageProcessing/ImageProcessingCapabilities.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using MediaBrowser.Model.Logging; 3 | 4 | namespace EmbyIcons.ImageProcessing 5 | { 6 | public static class ImageProcessingCapabilities 7 | { 8 | private static bool? _skiaSharpAvailable; 9 | private static readonly object _lock = new object(); 10 | public static bool IsSkiaSharpAvailable(ILogger logger) 11 | { 12 | var config = EmbyIcons.Plugin.Instance?.Configuration; 13 | if (config?.ForceDisableSkiaSharp == true) 14 | { 15 | logger?.Info("[EmbyIcons] SkiaSharp is forcibly disabled via configuration (ForceDisableSkiaSharp=true)."); 16 | return false; 17 | } 18 | 19 | if (_skiaSharpAvailable.HasValue) 20 | { 21 | return _skiaSharpAvailable.Value; 22 | } 23 | 24 | lock (_lock) 25 | { 26 | if (_skiaSharpAvailable.HasValue) 27 | { 28 | return _skiaSharpAvailable.Value; 29 | } 30 | 31 | try 32 | { 33 | using (var testBitmap = new SkiaSharp.SKBitmap(1, 1)) 34 | { 35 | if (testBitmap != null) 36 | { 37 | logger?.Info("[EmbyIcons] SkiaSharp is available and functional."); 38 | _skiaSharpAvailable = true; 39 | return true; 40 | } 41 | } 42 | } 43 | catch (Exception ex) 44 | { 45 | logger?.Warn($"[EmbyIcons] SkiaSharp is not available on this system: {ex.Message}"); 46 | logger?.Warn("[EmbyIcons] Icon overlays will be disabled. To enable this feature, ensure SkiaSharp libraries are properly installed."); 47 | } 48 | 49 | _skiaSharpAvailable = false; 50 | return false; 51 | } 52 | } 53 | public static void Reset() 54 | { 55 | lock (_lock) 56 | { 57 | _skiaSharpAvailable = null; 58 | } 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Helpers/FileUtils.cs: -------------------------------------------------------------------------------- 1 | using MediaBrowser.Model.IO; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.IO; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | 8 | namespace EmbyIcons.Helpers 9 | { 10 | internal static class FileUtils 11 | { 12 | public static async Task SafeCopyAsync(string inputFile, string outputFile, IFileSystem fileSystem, CancellationToken cancellationToken) 13 | { 14 | Directory.CreateDirectory(Path.GetDirectoryName(outputFile) ?? "."); 15 | 16 | var tempOutput = outputFile + "." + Guid.NewGuid().ToString("N") + ".tmp"; 17 | try 18 | { 19 | await using (var fsIn = fileSystem.GetFileStream(inputFile, FileOpenMode.Open, FileAccessMode.Read, FileShareMode.Read, true)) 20 | await using (var fsOut = new FileStream(tempOutput, FileMode.Create, FileAccess.Write, FileShare.None, 262144, useAsync: true)) 21 | { 22 | await fsIn.CopyToAsync(fsOut, 262144, cancellationToken); 23 | } 24 | 25 | if (File.Exists(outputFile)) 26 | { 27 | try { File.Replace(tempOutput, outputFile, null); } 28 | catch (System.IO.IOException) 29 | { 30 | try 31 | { 32 | if (File.Exists(outputFile)) File.Copy(tempOutput, outputFile, overwrite: true); 33 | else File.Move(tempOutput, outputFile); 34 | if (File.Exists(tempOutput)) File.Delete(tempOutput); 35 | } 36 | catch 37 | { 38 | throw; 39 | } 40 | } 41 | } 42 | else 43 | { 44 | File.Move(tempOutput, outputFile); 45 | } 46 | } 47 | catch 48 | { 49 | try 50 | { 51 | if (File.Exists(tempOutput)) 52 | File.Delete(tempOutput); 53 | } 54 | catch (Exception cleanupEx) 55 | { 56 | System.Diagnostics.Debug.WriteLine($"[EmbyIcons] Failed to clean up temp file '{tempOutput}': {cleanupEx.Message}"); 57 | } 58 | throw; 59 | } 60 | } 61 | } 62 | } -------------------------------------------------------------------------------- /Services/CacheManagerService.cs: -------------------------------------------------------------------------------- 1 | using EmbyIcons.Api; 2 | using MediaBrowser.Controller.Net; 3 | using MediaBrowser.Model.Logging; 4 | using MediaBrowser.Model.Services; 5 | using System; 6 | using System.Threading; 7 | using System.Threading.Tasks; 8 | 9 | namespace EmbyIcons.Services 10 | { 11 | [Route(ApiRoutes.RefreshCache, "POST", Summary = "Forces the icon cache to be cleared and refreshed")] 12 | public class RefreshCacheRequest : IReturnVoid 13 | { 14 | } 15 | 16 | public class CacheManagerService : IService 17 | { 18 | private readonly ILogger _logger; 19 | private readonly EmbyIconsEnhancer _enhancer; 20 | 21 | public CacheManagerService(ILogManager logManager) 22 | { 23 | _logger = logManager.GetLogger(nameof(CacheManagerService)); 24 | _enhancer = Plugin.Instance?.Enhancer ?? throw new InvalidOperationException("Enhancer is not available."); 25 | } 26 | 27 | public Task Post(RefreshCacheRequest request) 28 | { 29 | _logger.Info("[EmbyIcons] Received request to clear all icon and data caches from the settings page."); 30 | IconManagerService.InvalidateCache(); 31 | 32 | var plugin = Plugin.Instance; 33 | if (plugin == null) 34 | { 35 | _logger.Warn("[EmbyIcons] Plugin instance not available."); 36 | return Task.CompletedTask; 37 | } 38 | 39 | var config = plugin.Configuration; 40 | var iconsFolder = config.IconsFolder; 41 | var cts = new CancellationTokenSource(); 42 | 43 | var cacheRefreshTask = Task.Run(async () => 44 | { 45 | try 46 | { 47 | _logger.Info("[EmbyIcons] Starting background cache refresh."); 48 | await _enhancer.ForceCacheRefreshAsync(iconsFolder, cts.Token); 49 | _logger.Info("[EmbyIcons] Background cache refresh completed successfully."); 50 | } 51 | catch (Exception ex) 52 | { 53 | _logger.ErrorException("[EmbyIcons] Error during background cache refresh.", ex); 54 | } 55 | }); 56 | 57 | _ = cacheRefreshTask.ContinueWith(t => 58 | { 59 | if (t.IsFaulted && t.Exception != null) 60 | { 61 | _logger.ErrorException("[EmbyIcons] Unhandled exception in cache refresh background task.", t.Exception); 62 | } 63 | try { cts?.Dispose(); } catch { } 64 | }, TaskContinuationOptions.OnlyOnFaulted); 65 | 66 | _ = cacheRefreshTask.ContinueWith(t => 67 | { 68 | try { cts?.Dispose(); } catch { } 69 | }, TaskContinuationOptions.OnlyOnRanToCompletion); 70 | 71 | config.PersistedVersion = Guid.NewGuid().ToString("N"); 72 | plugin.SaveCurrentConfiguration(); 73 | _logger.Info($"[EmbyIcons] Cache clear requested. New cache-busting version is '{config.PersistedVersion}'. Cache refresh running in background."); 74 | return Task.CompletedTask; 75 | } 76 | } 77 | } -------------------------------------------------------------------------------- /Helpers/IconDrawer.cs: -------------------------------------------------------------------------------- 1 | using EmbyIcons.Configuration; 2 | using MediaBrowser.Controller.Entities; 3 | using MediaBrowser.Model.Entities; 4 | using SkiaSharp; 5 | using System.Collections.Generic; 6 | using System.Linq; 7 | using static System.Net.Mime.MediaTypeNames; 8 | using System; 9 | 10 | namespace EmbyIcons.Helpers 11 | { 12 | internal static class IconDrawer 13 | { 14 | public static void DrawIcons(SKCanvas canvas, 15 | List icons, 16 | int size, 17 | int interIconPadding, 18 | int edgePadding, 19 | int width, 20 | int height, 21 | IconAlignment alignment, 22 | SKPaint paint, 23 | bool horizontal = true, 24 | int horizontalOffset = 0, 25 | int verticalOffset = 0) 26 | { 27 | int count = icons.Count; 28 | if (count == 0) return; 29 | 30 | int GetIconWidth(SKImage i) 31 | { 32 | if (i.Height == 0) return size; 33 | return (int)Math.Round(size * ((float)i.Width / i.Height)); 34 | } 35 | 36 | int totalWidth = horizontal ? icons.Sum(GetIconWidth) + (count - 1) * interIconPadding : icons.Select(GetIconWidth).DefaultIfEmpty(0).Max(); 37 | int totalHeight = horizontal ? size : (count * size) + (count - 1) * interIconPadding; 38 | 39 | bool isRight = alignment == IconAlignment.TopRight || alignment == IconAlignment.BottomRight; 40 | bool isBottom = alignment == IconAlignment.BottomLeft || alignment == IconAlignment.BottomRight; 41 | 42 | float startX = isRight ? width - totalWidth - edgePadding - horizontalOffset : edgePadding + horizontalOffset; 43 | float startY = isBottom ? height - totalHeight - edgePadding - verticalOffset : edgePadding + verticalOffset; 44 | 45 | float currentX = startX; 46 | float currentY = startY; 47 | 48 | foreach (var img in icons) 49 | { 50 | if (img == null) continue; 51 | 52 | var iconWidth = GetIconWidth(img); 53 | var iconHeight = size; 54 | 55 | float xPos, yPos; 56 | 57 | if (horizontal) 58 | { 59 | xPos = currentX; 60 | yPos = isBottom ? height - size - edgePadding - verticalOffset : edgePadding + verticalOffset; 61 | canvas.DrawImage(img, new SKRect(xPos, yPos, xPos + iconWidth, yPos + iconHeight), paint); 62 | currentX += iconWidth + interIconPadding; 63 | } 64 | else 65 | { 66 | xPos = isRight ? width - iconWidth - edgePadding - horizontalOffset : edgePadding + horizontalOffset; 67 | yPos = currentY; 68 | canvas.DrawImage(img, new SKRect(xPos, yPos, xPos + iconWidth, yPos + iconHeight), paint); 69 | currentY += iconHeight + interIconPadding; 70 | } 71 | } 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /ImageProcessing/EmbyNativeImageProcessor.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using MediaBrowser.Model.Logging; 4 | 5 | namespace EmbyIcons.ImageProcessing 6 | { 7 | public class EmbyNativeImageProcessor : IImageProcessor 8 | { 9 | private readonly ILogger _logger; 10 | private bool _disposed; 11 | 12 | public string Name => "BasicFallback"; 13 | 14 | public bool IsAvailable 15 | { 16 | get 17 | { 18 | return true; 19 | } 20 | } 21 | 22 | public EmbyNativeImageProcessor(ILogger logger) 23 | { 24 | _logger = logger ?? throw new ArgumentNullException(nameof(logger)); 25 | _logger.Warn("[EmbyIcons] Using BasicFallback image processor. Icon overlays will not be applied. Please ensure SkiaSharp is available for full functionality."); 26 | } 27 | 28 | public object DecodeImage(Stream inputStream) 29 | { 30 | if (inputStream == null) 31 | throw new ArgumentNullException(nameof(inputStream)); 32 | 33 | var ms = new MemoryStream(); 34 | inputStream.CopyTo(ms); 35 | ms.Position = 0; 36 | return ms; 37 | } 38 | 39 | public void GetImageDimensions(object image, out int width, out int height) 40 | { 41 | _logger?.Debug("[EmbyIcons] GetImageDimensions not supported in BasicFallback processor."); 42 | width = 0; 43 | height = 0; 44 | } 45 | 46 | public object CreateBlankImage(int width, int height) 47 | { 48 | _logger?.Debug("[EmbyIcons] CreateBlankImage not supported in BasicFallback processor."); 49 | return new MemoryStream(); 50 | } 51 | 52 | public void DrawImage(object targetImage, object sourceImage, int x, int y, int width, int height, bool enableSmoothing) 53 | { 54 | _logger?.Debug("[EmbyIcons] DrawImage not supported in BasicFallback processor - SkiaSharp required."); 55 | } 56 | 57 | public void DrawText(object image, string text, int x, int y, float fontSize, string color, string fontFamily, bool enableSmoothing) 58 | { 59 | _logger?.Debug("[EmbyIcons] DrawText not supported in BasicFallback processor - SkiaSharp required."); 60 | } 61 | 62 | public void EncodeImage(object image, Stream outputStream, string format, int quality) 63 | { 64 | var stream = image as Stream ?? throw new ArgumentException("Image must be a Stream", nameof(image)); 65 | 66 | try 67 | { 68 | stream.Position = 0; 69 | stream.CopyTo(outputStream); 70 | } 71 | catch (Exception ex) 72 | { 73 | _logger?.Error($"[EmbyIcons] Error encoding image: {ex.Message}"); 74 | throw; 75 | } 76 | } 77 | 78 | public void DisposeImage(object image) 79 | { 80 | if (image is IDisposable disposable) 81 | { 82 | disposable.Dispose(); 83 | } 84 | } 85 | 86 | public void Dispose() 87 | { 88 | if (!_disposed) 89 | { 90 | _disposed = true; 91 | } 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /ImageProcessing/ImageProcessorFactory.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using MediaBrowser.Model.Logging; 5 | 6 | namespace EmbyIcons.ImageProcessing 7 | { 8 | public static class ImageProcessorFactory 9 | { 10 | private static IImageProcessor? _cachedProcessor; 11 | private static readonly object _lock = new object(); 12 | public static IImageProcessor GetImageProcessor(ILogger logger) 13 | { 14 | if (_cachedProcessor != null && !_cachedProcessor.IsAvailable) 15 | { 16 | lock (_lock) 17 | { 18 | _cachedProcessor?.Dispose(); 19 | _cachedProcessor = null; 20 | } 21 | } 22 | 23 | if (_cachedProcessor != null) 24 | { 25 | return _cachedProcessor; 26 | } 27 | 28 | lock (_lock) 29 | { 30 | if (_cachedProcessor != null) 31 | { 32 | return _cachedProcessor; 33 | } 34 | 35 | var processors = new List> 36 | { 37 | () => new SkiaSharpImageProcessor(logger), 38 | 39 | () => new EmbyNativeImageProcessor(logger) 40 | }; 41 | 42 | foreach (var processorFactory in processors) 43 | { 44 | try 45 | { 46 | var processor = processorFactory(); 47 | if (processor.IsAvailable) 48 | { 49 | logger?.Info($"[EmbyIcons] Using image processor: {processor.Name}"); 50 | _cachedProcessor = processor; 51 | return processor; 52 | } 53 | else 54 | { 55 | logger?.Debug($"[EmbyIcons] Image processor {processor.Name} is not available"); 56 | processor.Dispose(); 57 | } 58 | } 59 | catch (Exception ex) 60 | { 61 | logger?.Debug($"[EmbyIcons] Failed to initialize image processor: {ex.Message}"); 62 | } 63 | } 64 | 65 | logger?.Warn("[EmbyIcons] No preferred image processor available, using BasicFallback. Icon overlays will not be applied."); 66 | if (logger == null) 67 | throw new InvalidOperationException("Logger is required but was null"); 68 | _cachedProcessor = new EmbyNativeImageProcessor(logger); 69 | return _cachedProcessor; 70 | } 71 | } 72 | public static void Reset() 73 | { 74 | lock (_lock) 75 | { 76 | _cachedProcessor?.Dispose(); 77 | _cachedProcessor = null; 78 | } 79 | } 80 | public static bool IsSkiaSharpAvailable(ILogger logger) 81 | { 82 | try 83 | { 84 | var processor = new SkiaSharpImageProcessor(logger); 85 | bool available = processor.IsAvailable; 86 | processor.Dispose(); 87 | return available; 88 | } 89 | catch 90 | { 91 | return false; 92 | } 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /Caching/EpisodeIconCache.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Concurrent; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using Microsoft.Extensions.Caching.Memory; 6 | 7 | namespace EmbyIcons 8 | { 9 | public partial class EmbyIconsEnhancer 10 | { 11 | internal static MemoryCache? _episodeIconCache; 12 | private static readonly object _episodeCacheInitLock = new object(); 13 | 14 | private static int MaxEpisodeCacheSize => Plugin.Instance?.Configuration.MaxEpisodeCacheSize ?? 2000; 15 | internal static int EpisodeCacheSlidingExpirationHours => Plugin.Instance?.Configuration.EpisodeCacheSlidingExpirationHours ?? 6; 16 | internal static void EnsureEpisodeCacheInitialized() 17 | { 18 | if (_episodeIconCache == null) 19 | { 20 | lock (_episodeCacheInitLock) 21 | { 22 | if (_episodeIconCache == null) 23 | { 24 | _episodeIconCache = new MemoryCache(new MemoryCacheOptions 25 | { 26 | SizeLimit = MaxEpisodeCacheSize 27 | }); 28 | } 29 | } 30 | } 31 | } 32 | 33 | public record EpisodeIconInfo 34 | { 35 | public HashSet AudioLangs { get; init; } = new(StringComparer.OrdinalIgnoreCase); 36 | public HashSet SubtitleLangs { get; init; } = new(StringComparer.OrdinalIgnoreCase); 37 | public HashSet AudioCodecs { get; init; } = new(StringComparer.OrdinalIgnoreCase); 38 | public HashSet VideoCodecs { get; init; } = new(StringComparer.OrdinalIgnoreCase); 39 | public HashSet Tags { get; init; } = new(StringComparer.OrdinalIgnoreCase); 40 | public HashSet SourceIcons { get; init; } = new(StringComparer.OrdinalIgnoreCase); 41 | public string? ChannelIconName { get; init; } 42 | public string? VideoFormatIconName { get; init; } 43 | public string? ResolutionIconName { get; init; } 44 | public string? AspectRatioIconName { get; init; } 45 | public string? ParentalRatingIconName { get; init; } 46 | public float? RottenTomatoesRating { get; init; } 47 | public long DateModifiedTicks { get; init; } 48 | } 49 | 50 | public void ClearEpisodeIconCache(Guid episodeId) 51 | { 52 | if (episodeId == Guid.Empty) return; 53 | 54 | EnsureEpisodeCacheInitialized(); 55 | _episodeIconCache?.Remove(episodeId); 56 | if (Plugin.Instance?.Configuration.EnableDebugLogging ?? false) 57 | { 58 | _logger.Debug($"[EmbyIcons] Event handler cleared icon info cache for item ID: {episodeId}"); 59 | } 60 | } 61 | 62 | public void ClearAllEpisodeCaches() 63 | { 64 | var oldCache = _episodeIconCache; 65 | _episodeIconCache = new MemoryCache(new MemoryCacheOptions 66 | { 67 | SizeLimit = MaxEpisodeCacheSize 68 | }); 69 | 70 | if (oldCache != null) 71 | { 72 | try { oldCache.Dispose(); } 73 | catch (Exception ex) 74 | { 75 | if (Helpers.PluginHelper.IsDebugLoggingEnabled) 76 | Plugin.Instance?.Logger.Debug($"[EmbyIcons] Error disposing old episode cache: {ex.Message}"); 77 | } 78 | } 79 | } 80 | 81 | } 82 | } -------------------------------------------------------------------------------- /Configuration/EmbyIconsConfiguration.DomCache.js: -------------------------------------------------------------------------------- 1 | define([], function () { 2 | 'use strict'; 3 | 4 | function getDomElements(view) { 5 | return { 6 | view: view, 7 | forms: view.querySelectorAll('.embyIconsForm'), 8 | allConfigInputs: view.querySelectorAll('[data-config-key]'), 9 | allProfileInputs: view.querySelectorAll('[data-profile-key]'), 10 | allProfileSelects: view.querySelectorAll('select[is="emby-select"][data-profile-key]'), 11 | navButtons: view.querySelectorAll('.localnav .nav-button'), 12 | profileSelector: view.querySelector('#selActiveProfile'), 13 | btnAddProfile: view.querySelector('#btnAddProfile'), 14 | btnRenameProfile: view.querySelector('#btnRenameProfile'), 15 | btnDeleteProfile: view.querySelector('#btnDeleteProfile'), 16 | btnExportProfile: view.querySelector('#btnExportProfile'), 17 | btnExportAllProfiles: view.querySelector('#btnExportAllProfiles'), 18 | btnImportProfile: view.querySelector('#btnImportProfile'), 19 | btnSelectIconsFolder: view.querySelector('#btnSelectIconsFolder'), 20 | txtIconsFolder: view.querySelector('#txtIconsFolder'), 21 | folderWarningIcon: view.querySelector('#folderWarningIcon'), 22 | btnClearCache: view.querySelector('#btnClearCache'), 23 | btnRunIconScan: view.querySelector('#btnRunIconScan'), 24 | librarySelectionContainer: view.querySelector('#librarySelectionContainer'), 25 | opacitySlider: view.querySelector('[data-profile-key="CommunityScoreBackgroundOpacity"]'), 26 | opacityValue: view.querySelector('.valCommunityScoreBackgroundOpacity'), 27 | ratingAppearanceControls: view.querySelector('#ratingAppearanceControls'), 28 | pages: view.querySelectorAll('#settingsPage, #advancedPage, #readmePage, #iconManagerPage, #troubleshooterPage'), 29 | previewImage: view.querySelector('.previewImage'), 30 | iconManagerReportContainer: view.querySelector('#iconManagerReportContainer'), 31 | alignmentGrid: view.querySelector('.alignment-grid'), 32 | prioritySelects: view.querySelectorAll('[data-profile-key$="Priority"]'), 33 | txtSeriesSearch: view.querySelector('#txtSeriesSearch'), 34 | seriesSearchResults: view.querySelector('#seriesSearchResults'), 35 | btnRunSeriesScan: view.querySelector('#btnRunSeriesScan'), 36 | btnRunFullSeriesScan: view.querySelector('#btnRunFullSeriesScan'), 37 | seriesReportContainer: view.querySelector('#seriesReportContainer'), 38 | troubleshooterChecks: view.querySelectorAll('#troubleshooterChecksContainer input[type=checkbox]'), 39 | btnRefreshMemoryUsage: view.querySelector('#btnRefreshMemoryUsage'), 40 | memoryUsageReport: view.querySelector('#memoryUsageReport'), 41 | btnCalculateAspectRatio: view.querySelector('#btnCalculateAspectRatio'), 42 | txtAspectRatioWidth: view.querySelector('#txtAspectRatioWidth'), 43 | txtAspectRatioHeight: view.querySelector('#txtAspectRatioHeight'), 44 | aspectRatioResultContainer: view.querySelector('#aspectRatioResultContainer'), 45 | aspectDecimalValue: view.querySelector('#aspectDecimalValue'), 46 | aspectSnappedIconName: view.querySelector('#aspectSnappedIconName'), 47 | aspectPreciseIconName: view.querySelector('#aspectPreciseIconName'), 48 | filenameMappingsContainer: view.querySelector('#filenameMappingsContainer'), 49 | btnAddFilenameMapping: view.querySelector('#btnAddFilenameMapping'), 50 | }; 51 | } 52 | 53 | return { 54 | getDomElements: getDomElements 55 | }; 56 | }); 57 | -------------------------------------------------------------------------------- /Services/MemoryUsageService.cs: -------------------------------------------------------------------------------- 1 | using MediaBrowser.Model.Services; 2 | using MediaBrowser.Model.Logging; 3 | using EmbyIcons.Api; 4 | using System; 5 | using System.Diagnostics; 6 | using System.Runtime; 7 | using System.Threading.Tasks; 8 | 9 | namespace EmbyIcons.Services 10 | { 11 | [Route(ApiRoutes.MemoryUsage, "GET", Summary = "Returns memory usage statistics for the plugin and process")] 12 | public class MemoryUsageRequest : IReturn 13 | { 14 | } 15 | 16 | public class MemoryUsageResult 17 | { 18 | public long ProcessWorkingSetBytes { get; set; } 19 | public long ProcessPrivateBytes { get; set; } 20 | public long ManagedHeapBytes { get; set; } 21 | public long IconCacheEstimatedBytes { get; set; } 22 | public string TimestampUtc { get; set; } = DateTime.UtcNow.ToString("o"); 23 | } 24 | 25 | public class MemoryUsageService : IService 26 | { 27 | private readonly ILogger _logger; 28 | 29 | public MemoryUsageService(ILogManager logManager) 30 | { 31 | _logger = logManager.GetLogger(nameof(MemoryUsageService)); 32 | } 33 | 34 | public Task Get(MemoryUsageRequest request) 35 | { 36 | var proc = Process.GetCurrentProcess(); 37 | 38 | long workingSet = proc.WorkingSet64; 39 | long privateBytes = 0; 40 | try 41 | { 42 | privateBytes = proc.PrivateMemorySize64; 43 | } 44 | catch (Exception ex) 45 | { 46 | _logger.Debug($"[EmbyIcons] Unable to read PrivateMemorySize64: {ex.Message}"); 47 | } 48 | 49 | long managed = GC.GetTotalMemory(forceFullCollection: false); 50 | 51 | long iconCacheEstimate = 0; 52 | try 53 | { 54 | var plugin = EmbyIcons.Plugin.Instance; 55 | if (plugin != null) 56 | { 57 | var enhancer = plugin.Enhancer; 58 | var field = enhancer.GetType().GetField("_iconCacheManager", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public); 59 | var icm = field?.GetValue(enhancer); 60 | if (icm != null) 61 | { 62 | var cacheField = icm.GetType().GetField("_iconImageCache", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); 63 | var cache = cacheField?.GetValue(icm) as Microsoft.Extensions.Caching.Memory.MemoryCache; 64 | if (cache != null) 65 | { 66 | iconCacheEstimate = 0; 67 | 68 | if (Helpers.PluginHelper.IsDebugLoggingEnabled) 69 | { 70 | _logger.Debug("[EmbyIcons] Icon cache memory estimation not yet implemented."); 71 | } 72 | } 73 | } 74 | } 75 | } 76 | catch (Exception ex) 77 | { 78 | _logger.ErrorException("[EmbyIcons] Error while estimating icon cache size.", ex); 79 | } 80 | 81 | var result = new MemoryUsageResult 82 | { 83 | ProcessWorkingSetBytes = workingSet, 84 | ProcessPrivateBytes = privateBytes, 85 | ManagedHeapBytes = managed, 86 | IconCacheEstimatedBytes = iconCacheEstimate, 87 | TimestampUtc = DateTime.UtcNow.ToString("o") 88 | }; 89 | 90 | return Task.FromResult(result); 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /Configuration/EmbyIconsConfiguration.ProfileUI.js: -------------------------------------------------------------------------------- 1 | define(['configurationpage?name=EmbyIconsConfigurationProfile'], function (profileModule) { 2 | 'use strict'; 3 | 4 | function populateProfileSelector(instance) { 5 | const select = instance.dom.profileSelector; 6 | select.innerHTML = instance.pluginConfiguration.Profiles.map(p => ``).join(''); 7 | instance.currentProfileId = select.value; 8 | if (select.embyselect) select.embyselect.refresh(); 9 | } 10 | 11 | function onProfileSelected(instance, e) { 12 | loadProfileSettings(instance, e.target.value); 13 | } 14 | 15 | function loadProfileSettings(instance, profileId) { 16 | instance.currentProfileId = profileId; 17 | const profile = instance.pluginConfiguration.Profiles.find(p => p.Id === profileId); 18 | if (!profile) return; 19 | 20 | renderProfileSettings(instance, profile.Settings); 21 | populateLibraryAssignments(instance, profileId); 22 | profileModule.loadFilenameMappings(instance, profile); 23 | require(['configurationpage?name=EmbyIconsConfigurationUIHandlers'], (uiHandlers) => { 24 | uiHandlers.triggerPreviewUpdate(instance); 25 | uiHandlers.updateAllPriorityGroups(instance); 26 | }); 27 | } 28 | 29 | function renderProfileSettings(instance, settings) { 30 | instance.dom.allProfileInputs.forEach(el => { 31 | const key = el.getAttribute('data-profile-key'); 32 | const value = settings[key]; 33 | if (el.type === 'checkbox') { 34 | el.checked = value; 35 | } else { 36 | el.value = value ?? ''; 37 | } 38 | }); 39 | instance.dom.allProfileSelects.forEach(s => { 40 | if (s.embyselect) s.embyselect.refresh(); 41 | }); 42 | 43 | if (instance.dom.opacitySlider && instance.dom.opacityValue) { 44 | instance.dom.opacityValue.textContent = instance.dom.opacitySlider.value + '%'; 45 | } 46 | 47 | require(['configurationpage?name=EmbyIconsConfigurationUIHandlers'], (uiHandlers) => { 48 | uiHandlers.toggleRatingAppearanceControls(instance); 49 | }); 50 | } 51 | 52 | async function populateLibraryAssignments(instance, profileId) { 53 | const container = instance.dom.librarySelectionContainer; 54 | if (!instance.allLibraries) { 55 | const virtualFolders = await ApiClient.getVirtualFolders(); 56 | const ignoredLibraryTypes = ['music', 'collections', 'playlists', 'boxsets']; 57 | instance.allLibraries = virtualFolders.Items.filter(lib => !lib.CollectionType || !ignoredLibraryTypes.includes(lib.CollectionType.toLowerCase())); 58 | } 59 | 60 | const libraryToProfileMap = new Map(instance.pluginConfiguration.LibraryProfileMappings.map(m => [m.LibraryId, m.ProfileId])); 61 | 62 | let html = ''; 63 | for (const library of instance.allLibraries) { 64 | const assignedProfileId = libraryToProfileMap.get(library.Id); 65 | const isAssignedToCurrent = assignedProfileId === profileId; 66 | const isAssignedToOther = assignedProfileId && !isAssignedToCurrent; 67 | 68 | const isChecked = isAssignedToCurrent; 69 | const isDisabled = isAssignedToOther; 70 | 71 | let title = ''; 72 | if (isDisabled) { 73 | const otherProfileName = instance.profileMap.get(assignedProfileId) || 'another profile'; 74 | title = ` title="This library is managed by the '${otherProfileName}' profile."`; 75 | } 76 | 77 | html += `
78 | 82 |
`; 83 | } 84 | container.innerHTML = html; 85 | } 86 | 87 | return { 88 | populateProfileSelector: populateProfileSelector, 89 | onProfileSelected: onProfileSelected, 90 | loadProfileSettings: loadProfileSettings, 91 | renderProfileSettings: renderProfileSettings 92 | }; 93 | }); 94 | -------------------------------------------------------------------------------- /Configuration/EmbyIconsConfiguration.Events.js: -------------------------------------------------------------------------------- 1 | define(['configurationpage?name=EmbyIconsConfigurationUtils'], function (utils) { 2 | 'use strict'; 3 | 4 | function bindEvents(instance) { 5 | const dom = instance.dom; 6 | const transparentPixel = utils.transparentPixel; 7 | 8 | if (dom.forms) { 9 | dom.forms.forEach(form => { 10 | form.addEventListener('change', instance.onFormChange.bind(instance)); 11 | form.addEventListener('submit', (e) => { 12 | e.preventDefault(); 13 | instance.saveData(); 14 | return false; 15 | }); 16 | }); 17 | } 18 | 19 | if (dom.opacitySlider) { 20 | dom.opacitySlider.addEventListener('input', instance.onFormChange.bind(instance)); 21 | } 22 | 23 | if (dom.navButtons && dom.navButtons.length) { 24 | dom.navButtons.forEach(navButton => { 25 | navButton.addEventListener('click', instance.onTabChange.bind(instance)); 26 | }); 27 | } 28 | 29 | if (dom.previewImage) { 30 | dom.previewImage.addEventListener('error', () => { 31 | dom.previewImage.src = transparentPixel; 32 | require(['toast'], (toast) => { 33 | toast({ type: 'error', text: 'Preview generation failed.' }); 34 | }); 35 | }); 36 | } 37 | 38 | if (dom.profileSelector) dom.profileSelector.addEventListener('change', instance.onProfileSelected.bind(instance)); 39 | if (dom.btnAddProfile) dom.btnAddProfile.addEventListener('click', instance.addProfile.bind(instance)); 40 | if (dom.btnRenameProfile) dom.btnRenameProfile.addEventListener('click', instance.renameProfile.bind(instance)); 41 | if (dom.btnDeleteProfile) dom.btnDeleteProfile.addEventListener('click', instance.deleteProfile.bind(instance)); 42 | if (dom.btnExportProfile) dom.btnExportProfile.addEventListener('click', instance.exportCurrentProfile.bind(instance)); 43 | if (dom.btnExportAllProfiles) dom.btnExportAllProfiles.addEventListener('click', instance.exportAllProfiles.bind(instance)); 44 | if (dom.btnImportProfile) dom.btnImportProfile.addEventListener('click', instance.importProfiles.bind(instance)); 45 | 46 | if (dom.btnSelectIconsFolder) dom.btnSelectIconsFolder.addEventListener('click', instance.selectIconsFolder.bind(instance)); 47 | if (dom.txtIconsFolder) dom.txtIconsFolder.addEventListener('input', utils.debounce(instance.validateIconsFolder.bind(instance), 500)); 48 | 49 | if (dom.btnClearCache) dom.btnClearCache.addEventListener('click', instance.clearCache.bind(instance)); 50 | if (dom.btnRunIconScan) dom.btnRunIconScan.addEventListener('click', instance.runIconScan.bind(instance)); 51 | 52 | if (dom.prioritySelects && dom.prioritySelects.length) { 53 | dom.prioritySelects.forEach(select => { 54 | select.addEventListener('change', instance.onPriorityChange.bind(instance)); 55 | }); 56 | } 57 | 58 | if (dom.txtSeriesSearch) dom.txtSeriesSearch.addEventListener('input', utils.debounce(instance.searchForSeries.bind(instance), 300)); 59 | if (dom.seriesSearchResults) dom.seriesSearchResults.addEventListener('click', instance.onSeriesSearchResultClick.bind(instance)); 60 | if (dom.btnRunSeriesScan) dom.btnRunSeriesScan.addEventListener('click', instance.runSeriesScan.bind(instance)); 61 | if (dom.btnRunFullSeriesScan) dom.btnRunFullSeriesScan.addEventListener('click', instance.runFullSeriesScan.bind(instance)); 62 | if (dom.seriesReportContainer) dom.seriesReportContainer.addEventListener('click', instance.onSeriesReportHeaderClick.bind(instance)); 63 | 64 | if (dom.btnRefreshMemoryUsage) dom.btnRefreshMemoryUsage.addEventListener('click', instance.refreshMemoryUsage.bind(instance)); 65 | 66 | if (dom.btnCalculateAspectRatio) dom.btnCalculateAspectRatio.addEventListener('click', instance.calculateAspectRatio.bind(instance)); 67 | 68 | if (dom.btnAddFilenameMapping) dom.btnAddFilenameMapping.addEventListener('click', instance.addFilenameMappingRow.bind(instance, null)); 69 | if (dom.filenameMappingsContainer) dom.filenameMappingsContainer.addEventListener('click', instance.onFilenameMappingButtonClick.bind(instance)); 70 | 71 | instance.documentClickHandler = (e) => { 72 | if (dom.seriesSearchResults && dom.txtSeriesSearch && !dom.seriesSearchResults.contains(e.target) && !dom.txtSeriesSearch.contains(e.target)) { 73 | dom.seriesSearchResults.style.display = 'none'; 74 | } 75 | }; 76 | document.addEventListener('click', instance.documentClickHandler); 77 | } 78 | 79 | return { 80 | bindEvents: bindEvents 81 | }; 82 | }); 83 | -------------------------------------------------------------------------------- /Configuration/EmbyIconsConfiguration.Api.js: -------------------------------------------------------------------------------- 1 | define(['loading', 'toast'], function (loading, toast) { 2 | 'use strict'; 3 | 4 | async function fetchApiRoutes(instance) { 5 | if (instance.apiRoutes) return; 6 | instance.apiRoutes = await ApiClient.ajax({ type: "GET", url: ApiClient.getUrl("EmbyIcons/ApiRoutes"), dataType: "json" }); 7 | } 8 | 9 | async function refreshMemoryUsage(instance) { 10 | if (!instance.apiRoutes) await fetchApiRoutes(instance); 11 | if (!instance.dom.memoryUsageReport) return; 12 | try { 13 | instance.dom.memoryUsageReport.textContent = 'Loading...'; 14 | const stats = await ApiClient.ajax({ type: 'GET', url: ApiClient.getUrl(instance.apiRoutes.MemoryUsage), dataType: 'json' }); 15 | const fmt = (v) => (v / 1024 / 1024).toFixed(2) + ' MB'; 16 | const lines = []; 17 | lines.push('Working Set: ' + fmt(stats.ProcessWorkingSetBytes)); 18 | lines.push('Private Bytes: ' + fmt(stats.ProcessPrivateBytes)); 19 | lines.push('Managed Heap: ' + fmt(stats.ManagedHeapBytes)); 20 | if (stats.IconCacheEstimatedBytes && stats.IconCacheEstimatedBytes > 0) lines.push('Icon Cache (est): ' + fmt(stats.IconCacheEstimatedBytes)); 21 | lines.push('As of: ' + (stats.TimestampUtc || '')); 22 | instance.dom.memoryUsageReport.innerHTML = lines.map(l => '
' + l + '
').join(''); 23 | } catch (err) { 24 | console.error('Failed to fetch memory usage', err); 25 | instance.dom.memoryUsageReport.textContent = 'Error fetching memory usage.'; 26 | } 27 | } 28 | 29 | async function validateIconsFolder(instance) { 30 | const path = instance.dom.txtIconsFolder.value; 31 | if (!path) { 32 | if (instance.dom.folderWarningIcon) instance.dom.folderWarningIcon.style.display = 'none'; 33 | return; 34 | } 35 | 36 | try { 37 | const result = await ApiClient.ajax({ type: 'GET', url: ApiClient.getUrl(instance.apiRoutes.ValidatePath, { Path: path }), dataType: 'json' }); 38 | if (!result.Exists) { 39 | instance.dom.folderWarningIcon.style.display = 'block'; 40 | instance.dom.folderWarningIcon.title = 'The specified folder does not exist on the server.'; 41 | } else if (!result.HasImages) { 42 | instance.dom.folderWarningIcon.style.display = 'block'; 43 | instance.dom.folderWarningIcon.title = 'The specified folder exists, but no supported image files were found inside.'; 44 | } else { 45 | instance.dom.folderWarningIcon.style.display = 'none'; 46 | } 47 | } catch (err) { 48 | console.error('Error validating path', err); 49 | if (instance.dom.folderWarningIcon) instance.dom.folderWarningIcon.style.display = 'none'; 50 | } 51 | } 52 | 53 | async function clearCache(instance) { 54 | loading.show(); 55 | try { 56 | await ApiClient.ajax({ type: "POST", url: ApiClient.getUrl(instance.apiRoutes.RefreshCache) }); 57 | toast('Cache cleared, icons will be redrawn.'); 58 | } catch (error) { 59 | console.error('Error clearing EmbyIcons cache', error); 60 | toast({ type: 'error', text: 'Error clearing icon cache.' }); 61 | } finally { 62 | loading.hide(); 63 | } 64 | } 65 | 66 | async function calculateAspectRatio(instance) { 67 | const width = parseInt(instance.dom.txtAspectRatioWidth.value, 10); 68 | const height = parseInt(instance.dom.txtAspectRatioHeight.value, 10); 69 | 70 | if (!width || !height || width <= 0 || height <= 0) { 71 | toast({ type: 'error', text: 'Please enter valid width and height values.' }); 72 | instance.dom.aspectRatioResultContainer.style.display = 'none'; 73 | return; 74 | } 75 | 76 | try { 77 | const result = await ApiClient.ajax({ type: "GET", url: ApiClient.getUrl(instance.apiRoutes.AspectRatio, { Width: width, Height: height }), dataType: "json" }); 78 | instance.dom.aspectDecimalValue.textContent = result.DecimalRatio.toFixed(4); 79 | instance.dom.aspectSnappedIconName.textContent = result.SnappedName; 80 | instance.dom.aspectPreciseIconName.textContent = result.PreciseName; 81 | instance.dom.aspectRatioResultContainer.style.display = 'block'; 82 | } catch (err) { 83 | toast({ type: 'error', text: 'Error calculating aspect ratio.' }); 84 | instance.dom.aspectRatioResultContainer.style.display = 'none'; 85 | } 86 | } 87 | 88 | return { 89 | fetchApiRoutes, 90 | refreshMemoryUsage, 91 | validateIconsFolder, 92 | clearCache, 93 | calculateAspectRatio 94 | }; 95 | }); 96 | -------------------------------------------------------------------------------- /Configuration/EmbyIconsConfiguration.DataLoader.js: -------------------------------------------------------------------------------- 1 | define([], function () { 2 | 'use strict'; 3 | 4 | const pluginId = "b8d0f5a4-3e96-4c0f-a6e2-9f0c2ecb5c5f"; 5 | 6 | async function loadPagePartials() { 7 | const parts = [ 8 | { id: 'settingsPage', page: 'EmbyIconsConfigurationSettings' }, 9 | { id: 'advancedPage', page: 'EmbyIconsConfigurationAdvanced' }, 10 | { id: 'iconManagerPage', page: 'EmbyIconsConfigurationIconManager' }, 11 | { id: 'troubleshooterPage', page: 'EmbyIconsConfigurationTroubleshooter' }, 12 | { id: 'readmePage', page: 'EmbyIconsConfigurationReadme' }, 13 | { id: 'addProfileDialogTemplate', page: 'EmbyIconsConfigurationAddProfileTemplate' }, 14 | { id: 'renameProfileDialogTemplate', page: 'EmbyIconsConfigurationRenameProfileTemplate' } 15 | ]; 16 | 17 | const promises = parts.map(p => 18 | fetch('/web/configurationpage?name=' + p.page) 19 | .then(r => r.ok ? r.text() : '') 20 | .then(html => { 21 | if (html) { 22 | const el = document.getElementById(p.id); 23 | if (el) el.innerHTML = html; 24 | } 25 | }) 26 | .catch(err => console.error('Failed to load page part: ' + p.page, err)) 27 | ); 28 | 29 | await Promise.all(promises); 30 | } 31 | 32 | async function loadData(instance) { 33 | try { 34 | const [config, virtualFolders, user] = await Promise.all([ 35 | ApiClient.getPluginConfiguration(pluginId), 36 | ApiClient.getVirtualFolders(), 37 | ApiClient.getCurrentUser() 38 | ]); 39 | 40 | instance.pluginConfiguration = config; 41 | instance.currentUser = user; 42 | loadGlobalSettings(instance, config); 43 | 44 | const ignoredLibraryTypes = ['music', 'collections', 'playlists', 'boxsets']; 45 | instance.allLibraries = virtualFolders.Items.filter(lib => !lib.CollectionType || !ignoredLibraryTypes.includes(lib.CollectionType.toLowerCase())); 46 | instance.libraryMap = new Map(instance.allLibraries.map(lib => [lib.Id, lib.Name])); 47 | instance.profileMap = new Map(instance.pluginConfiguration.Profiles.map(p => [p.Id, p.Name])); 48 | 49 | instance.populateProfileSelector(); 50 | instance.loadProfileSettings(instance.dom.profileSelector.value); 51 | instance.validateIconsFolder(); 52 | instance.refreshMemoryUsage().catch(err => { /* ignore */ }); 53 | } catch (error) { 54 | console.error('Failed to load EmbyIcons configuration', error); 55 | require(['toast'], (toast) => { 56 | toast({ type: 'error', text: 'Error loading configuration.' }); 57 | }); 58 | throw error; 59 | } 60 | } 61 | 62 | function loadGlobalSettings(instance, config) { 63 | instance.dom.allConfigInputs.forEach(el => { 64 | const key = el.getAttribute('data-config-key'); 65 | const value = config[key]; 66 | if (el.type === 'checkbox') { 67 | el.checked = value; 68 | } else { 69 | el.value = value ?? ''; 70 | } 71 | }); 72 | } 73 | 74 | async function saveData(instance) { 75 | if (!instance.pluginConfiguration.Profiles || !instance.pluginConfiguration.Profiles.length) { 76 | require(['toast'], (toast) => { 77 | toast({ type: 'error', text: 'Cannot save settings. You must create at least one profile.' }); 78 | }); 79 | return; 80 | } 81 | 82 | require(['loading'], (loading) => { 83 | loading.show(); 84 | }); 85 | 86 | clearTimeout(instance.configSaveTimer); 87 | instance.saveCurrentProfileSettings(); 88 | 89 | instance.dom.allConfigInputs.forEach(el => { 90 | const key = el.getAttribute('data-config-key'); 91 | if (el.type === 'checkbox') { 92 | instance.pluginConfiguration[key] = el.checked; 93 | } else { 94 | instance.pluginConfiguration[key] = el.value; 95 | } 96 | }); 97 | 98 | try { 99 | const result = await ApiClient.updatePluginConfiguration(pluginId, instance.pluginConfiguration); 100 | Dashboard.processPluginConfigurationUpdateResult(result); 101 | } catch (error) { 102 | console.error('Error saving EmbyIcons settings', error); 103 | require(['toast'], (toast) => { 104 | toast({ type: 'error', text: 'Error saving settings.' }); 105 | }); 106 | } finally { 107 | require(['loading'], (loading) => { 108 | loading.hide(); 109 | }); 110 | } 111 | } 112 | 113 | return { 114 | loadPagePartials: loadPagePartials, 115 | loadData: loadData, 116 | saveData: saveData 117 | }; 118 | }); 119 | -------------------------------------------------------------------------------- /ImageProcessing/SkiaSharpImageProcessor.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using MediaBrowser.Model.Logging; 4 | using SkiaSharp; 5 | 6 | namespace EmbyIcons.ImageProcessing 7 | { 8 | public class SkiaSharpImageProcessor : IImageProcessor 9 | { 10 | private readonly ILogger _logger; 11 | private bool _disposed; 12 | 13 | public string Name => "SkiaSharp"; 14 | 15 | public bool IsAvailable 16 | { 17 | get 18 | { 19 | try 20 | { 21 | using (var testBitmap = new SKBitmap(1, 1)) 22 | { 23 | return testBitmap != null; 24 | } 25 | } 26 | catch (Exception ex) 27 | { 28 | _logger?.Debug($"[EmbyIcons] SkiaSharp is not available: {ex.Message}"); 29 | return false; 30 | } 31 | } 32 | } 33 | 34 | public SkiaSharpImageProcessor(ILogger logger) 35 | { 36 | _logger = logger ?? throw new ArgumentNullException(nameof(logger)); 37 | } 38 | 39 | public object DecodeImage(Stream inputStream) 40 | { 41 | if (inputStream == null) 42 | throw new ArgumentNullException(nameof(inputStream)); 43 | 44 | return SKBitmap.Decode(inputStream); 45 | } 46 | 47 | public void GetImageDimensions(object image, out int width, out int height) 48 | { 49 | var bitmap = image as SKBitmap ?? throw new ArgumentException("Image must be an SKBitmap", nameof(image)); 50 | width = bitmap.Width; 51 | height = bitmap.Height; 52 | } 53 | 54 | public object CreateBlankImage(int width, int height) 55 | { 56 | var bitmap = new SKBitmap(width, height, SKColorType.Rgba8888, SKAlphaType.Premul); 57 | using (var canvas = new SKCanvas(bitmap)) 58 | { 59 | canvas.Clear(SKColors.Transparent); 60 | } 61 | return bitmap; 62 | } 63 | 64 | public void DrawImage(object targetImage, object sourceImage, int x, int y, int width, int height, bool enableSmoothing) 65 | { 66 | var targetBitmap = targetImage as SKBitmap ?? throw new ArgumentException("Target must be an SKBitmap", nameof(targetImage)); 67 | 68 | SKImage? sourceSkImage = null; 69 | try 70 | { 71 | if (sourceImage is SKImage skImage) 72 | { 73 | sourceSkImage = skImage; 74 | } 75 | else if (sourceImage is SKBitmap sourceBitmap) 76 | { 77 | sourceSkImage = SKImage.FromBitmap(sourceBitmap); 78 | } 79 | else 80 | { 81 | throw new ArgumentException("Source must be an SKImage or SKBitmap", nameof(sourceImage)); 82 | } 83 | 84 | using (var canvas = new SKCanvas(targetBitmap)) 85 | using (var paint = new SKPaint 86 | { 87 | IsAntialias = enableSmoothing, 88 | FilterQuality = enableSmoothing ? SKFilterQuality.Medium : SKFilterQuality.None 89 | }) 90 | { 91 | var destRect = new SKRect(x, y, x + width, y + height); 92 | canvas.DrawImage(sourceSkImage, destRect, paint); 93 | } 94 | } 95 | finally 96 | { 97 | if (sourceImage is SKBitmap && sourceSkImage != null) 98 | { 99 | sourceSkImage.Dispose(); 100 | } 101 | } 102 | } 103 | 104 | public void DrawText(object image, string text, int x, int y, float fontSize, string color, string fontFamily, bool enableSmoothing) 105 | { 106 | var bitmap = image as SKBitmap ?? throw new ArgumentException("Image must be an SKBitmap", nameof(image)); 107 | 108 | using (var canvas = new SKCanvas(bitmap)) 109 | using (var paint = new SKPaint()) 110 | using (var typeface = SKTypeface.FromFamilyName(fontFamily)) 111 | { 112 | paint.Color = SKColor.Parse(color); 113 | paint.TextSize = fontSize; 114 | paint.IsAntialias = enableSmoothing; 115 | paint.Typeface = typeface; 116 | 117 | canvas.DrawText(text, x, y, paint); 118 | } 119 | } 120 | 121 | public void EncodeImage(object image, Stream outputStream, string format, int quality) 122 | { 123 | var bitmap = image as SKBitmap ?? throw new ArgumentException("Image must be an SKBitmap", nameof(image)); 124 | 125 | SKEncodedImageFormat skFormat = format?.ToLowerInvariant() switch 126 | { 127 | "png" => SKEncodedImageFormat.Png, 128 | "jpeg" or "jpg" => SKEncodedImageFormat.Jpeg, 129 | "webp" => SKEncodedImageFormat.Webp, 130 | _ => SKEncodedImageFormat.Png 131 | }; 132 | 133 | using (var image2 = SKImage.FromBitmap(bitmap)) 134 | using (var data = image2.Encode(skFormat, quality)) 135 | { 136 | data.SaveTo(outputStream); 137 | } 138 | } 139 | 140 | public void DisposeImage(object image) 141 | { 142 | if (image is IDisposable disposable) 143 | { 144 | disposable.Dispose(); 145 | } 146 | } 147 | 148 | public void Dispose() 149 | { 150 | if (!_disposed) 151 | { 152 | _disposed = true; 153 | } 154 | } 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /Services/PreviewService.cs: -------------------------------------------------------------------------------- 1 | using EmbyIcons.Api; 2 | using EmbyIcons.Caching; 3 | using EmbyIcons.Configuration; 4 | using EmbyIcons.Helpers; 5 | using EmbyIcons.Models; 6 | using EmbyIcons.Services; 7 | using MediaBrowser.Controller.Net; 8 | using MediaBrowser.Model.Services; 9 | using SkiaSharp; 10 | using System; 11 | using System.Collections.Generic; 12 | using System.IO; 13 | using System.Linq; 14 | using System.Reflection; 15 | using System.Text.Json; 16 | using System.Text.Json.Serialization; 17 | using System.Threading; 18 | using System.Threading.Tasks; 19 | 20 | namespace EmbyIcons 21 | { 22 | [Unauthenticated] 23 | [Route(ApiRoutes.Preview, "GET", Summary = "Generates a live preview image based on current settings")] 24 | public class GetIconPreview : IReturn 25 | { 26 | public string? OptionsJson { get; set; } 27 | } 28 | 29 | public class PreviewService : IService 30 | { 31 | private readonly ImageOverlayService _imageOverlayService; 32 | 33 | public PreviewService() 34 | { 35 | var enhancer = Plugin.Instance?.Enhancer ?? throw new InvalidOperationException("Enhancer is not initialized."); 36 | _imageOverlayService = new ImageOverlayService(enhancer.Logger, enhancer._iconCacheManager); 37 | } 38 | 39 | public async Task Get(GetIconPreview request) 40 | { 41 | var plugin = Plugin.Instance ?? throw new InvalidOperationException("Plugin instance is not initialized."); 42 | if (string.IsNullOrEmpty(request.OptionsJson)) 43 | { 44 | plugin.Logger.Warn("[EmbyIcons] Preview request received with empty options."); 45 | return new MemoryStream(); 46 | } 47 | 48 | var profileSettings = JsonSerializer.Deserialize(request.OptionsJson, new JsonSerializerOptions 49 | { 50 | PropertyNameCaseInsensitive = true, 51 | Converters = { new JsonStringEnumConverter() } 52 | }) ?? throw new ArgumentException("Could not deserialize profile settings from JSON."); 53 | 54 | var globalOptions = plugin.GetConfiguredOptions(); 55 | 56 | using var originalBitmap = SKBitmap.Decode(Assembly.GetExecutingAssembly().GetManifestResourceStream("EmbyIcons.Images.preview.png")) 57 | ?? throw new InvalidOperationException("Failed to decode the preview background image."); 58 | 59 | var cacheManager = plugin.Enhancer._iconCacheManager; 60 | 61 | var customIcons = cacheManager.GetAllAvailableIconKeys(globalOptions.IconsFolder); 62 | var embeddedIcons = cacheManager.GetAllAvailableEmbeddedIconKeys(); 63 | var masterIconList = new Dictionary>(); 64 | 65 | foreach (IconCacheManager.IconType type in Enum.GetValues(typeof(IconCacheManager.IconType))) 66 | { 67 | var custom = customIcons.GetValueOrDefault(type, new List()); 68 | var embedded = embeddedIcons.GetValueOrDefault(type, new List()); 69 | 70 | masterIconList[type] = globalOptions.IconLoadingMode switch 71 | { 72 | IconLoadingMode.CustomOnly => custom, 73 | IconLoadingMode.BuiltInOnly => embedded, 74 | _ => custom.Union(embedded, StringComparer.OrdinalIgnoreCase).ToList() 75 | }; 76 | } 77 | 78 | var random = new Random(); 79 | string GetRandom(IconCacheManager.IconType type, string fallback) 80 | { 81 | var list = masterIconList.GetValueOrDefault(type, new List()); 82 | return list.Any() ? list[random.Next(list.Count)] : fallback; 83 | } 84 | 85 | var previewData = new OverlayData 86 | { 87 | AudioLanguages = new HashSet { GetRandom(IconCacheManager.IconType.Language, "english") }, 88 | SubtitleLanguages = new HashSet { GetRandom(IconCacheManager.IconType.Subtitle, "english") }, 89 | AudioCodecs = new HashSet { GetRandom(IconCacheManager.IconType.AudioCodec, "dts"), GetRandom(IconCacheManager.IconType.AudioCodec, "aac") }, 90 | VideoCodecs = new HashSet { GetRandom(IconCacheManager.IconType.VideoCodec, "h264") }, 91 | Tags = new HashSet { "placeholder_for_preview" }, 92 | ChannelIconName = GetRandom(IconCacheManager.IconType.Channel, "5.1"), 93 | VideoFormatIconName = GetRandom(IconCacheManager.IconType.VideoFormat, "hdr"), 94 | ResolutionIconName = GetRandom(IconCacheManager.IconType.Resolution, "1080p"), 95 | CommunityRating = 6.9f, 96 | RottenTomatoesRating = 88f, 97 | AspectRatioIconName = GetRandom(IconCacheManager.IconType.AspectRatio, "16x9"), 98 | ParentalRatingIconName = GetRandom(IconCacheManager.IconType.ParentalRating, "pg-13") 99 | }; 100 | 101 | var injectedIcons = new Dictionary>(); 102 | var asm = Assembly.GetExecutingAssembly(); 103 | var resourceName = $"{GetType().Namespace}.Images.tag.png"; 104 | await using (var stream = asm.GetManifestResourceStream(resourceName)) 105 | { 106 | if (stream != null && stream.Length > 0) 107 | { 108 | using var data = SKData.Create(stream); 109 | var tagIcon = SKImage.FromEncodedData(data); 110 | if (tagIcon != null) 111 | { 112 | injectedIcons[IconCacheManager.IconType.Tag] = new List { tagIcon }; 113 | } 114 | } 115 | } 116 | 117 | var resultStream = new MemoryStream(); 118 | try 119 | { 120 | await _imageOverlayService.ApplyOverlaysToStreamAsync(originalBitmap, previewData, profileSettings, globalOptions, resultStream, CancellationToken.None, injectedIcons); 121 | resultStream.Position = 0; 122 | return resultStream; 123 | } 124 | catch 125 | { 126 | try { resultStream?.Dispose(); } catch { } 127 | 128 | if (injectedIcons != null) 129 | { 130 | foreach (var iconList in injectedIcons.Values) 131 | { 132 | foreach (var icon in iconList) 133 | { 134 | try { icon?.Dispose(); } catch { } 135 | } 136 | } 137 | } 138 | throw; 139 | } 140 | } 141 | } 142 | } -------------------------------------------------------------------------------- /Configuration/EmbyIconsConfiguration.UIHandlers.js: -------------------------------------------------------------------------------- 1 | define(['configurationpage?name=EmbyIconsConfigurationUtils'], function (utils) { 2 | 'use strict'; 3 | 4 | function onFormChange(instance, event) { 5 | triggerConfigSave(instance); 6 | triggerPreviewUpdate(instance); 7 | 8 | if (event.target.matches('[data-profile-key="CommunityScoreBackgroundOpacity"]')) { 9 | instance.dom.opacityValue.textContent = event.target.value + '%'; 10 | } 11 | if (event.target.matches('[data-profile-key="CommunityScoreIconAlignment"]')) { 12 | toggleRatingAppearanceControls(instance); 13 | } 14 | if (event.target.matches('[data-profile-key="UseSeriesLiteMode"]')) { 15 | toggleDependentSetting(instance, 'UseSeriesLiteMode', 'ShowSeriesIconsIfAllEpisodesHaveLanguage', 'This setting is ignored when Lite Mode is enabled, as Lite Mode only scans one episode.'); 16 | } 17 | if (event.target.matches('[data-profile-key="UseCollectionLiteMode"]')) { 18 | toggleDependentSetting(instance, 'UseCollectionLiteMode', 'ShowCollectionIconsIfAllChildrenHaveLanguage', 'This setting is ignored when Lite Mode is enabled, as Lite Mode only scans one item.'); 19 | } 20 | } 21 | 22 | function onPriorityChange(instance, event) { 23 | const changedSelect = event.target; 24 | const cornerGroup = changedSelect.closest('[data-corner-group]'); 25 | if (!cornerGroup) return; 26 | 27 | const groupName = cornerGroup.getAttribute('data-corner-group'); 28 | updatePriorityOptionsForGroup(instance, groupName); 29 | } 30 | 31 | function updatePriorityOptionsForGroup(instance, groupName) { 32 | const groupSelects = instance.dom.alignmentGrid.querySelectorAll(`[data-corner-group="${groupName}"] [data-profile-key$="Priority"]`); 33 | const selectedPriorities = new Set(); 34 | 35 | groupSelects.forEach(select => { 36 | if (select.value !== '0') { 37 | selectedPriorities.add(select.value); 38 | } 39 | }); 40 | 41 | groupSelects.forEach(select => { 42 | const ownValue = select.value; 43 | select.querySelectorAll('option').forEach(option => { 44 | if (option.value !== '0' && option.value !== ownValue) { 45 | option.disabled = selectedPriorities.has(option.value); 46 | } else { 47 | option.disabled = false; 48 | } 49 | }); 50 | }); 51 | } 52 | 53 | function updateAllPriorityGroups(instance) { 54 | const groupNames = new Set(); 55 | instance.dom.prioritySelects.forEach(select => { 56 | const cornerGroup = select.closest('[data-corner-group]'); 57 | if (cornerGroup) groupNames.add(cornerGroup.getAttribute('data-corner-group')); 58 | }); 59 | groupNames.forEach(name => updatePriorityOptionsForGroup(instance, name)); 60 | } 61 | 62 | function toggleDependentSetting(instance, controllerKey, dependentKey, dependentMessage) { 63 | const controllerCheckbox = instance.dom.view.querySelector(`[data-profile-key="${controllerKey}"]`); 64 | const dependentCheckbox = instance.dom.view.querySelector(`[data-profile-key="${dependentKey}"]`); 65 | if (controllerCheckbox && dependentCheckbox) { 66 | const container = dependentCheckbox.closest('.checkboxContainer'); 67 | const isDisabled = controllerCheckbox.checked; 68 | dependentCheckbox.disabled = isDisabled; 69 | if (container) { 70 | container.style.opacity = isDisabled ? '0.6' : '1'; 71 | container.style.pointerEvents = isDisabled ? 'none' : 'auto'; 72 | container.title = isDisabled ? dependentMessage : ''; 73 | } 74 | } 75 | } 76 | 77 | function toggleRatingAppearanceControls(instance) { 78 | if (!instance.dom.ratingAppearanceControls) return; 79 | 80 | const ratingAlignmentSelect = instance.dom.view.querySelector('[data-profile-key="CommunityScoreIconAlignment"]'); 81 | if (ratingAlignmentSelect) { 82 | instance.dom.ratingAppearanceControls.style.display = ratingAlignmentSelect.value === 'Disabled' ? 'none' : 'block'; 83 | } 84 | } 85 | 86 | function onTabChange(instance, e) { 87 | const currentTarget = e.currentTarget; 88 | instance.dom.view.querySelector('.localnav .ui-btn-active')?.classList.remove('ui-btn-active'); 89 | currentTarget.classList.add('ui-btn-active'); 90 | const targetId = currentTarget.getAttribute('data-target'); 91 | instance.dom.pages.forEach(page => { 92 | page.classList.toggle('hide', page.id !== targetId); 93 | }); 94 | } 95 | 96 | function triggerConfigSave(instance) { 97 | clearTimeout(instance.configSaveTimer); 98 | instance.configSaveTimer = setTimeout(() => { 99 | if (instance.dom.view) { 100 | instance.saveCurrentProfileSettings(); 101 | } 102 | }, 400); 103 | } 104 | 105 | function triggerPreviewUpdate(instance) { 106 | clearTimeout(instance.previewUpdateTimer); 107 | instance.previewUpdateTimer = setTimeout(() => { 108 | if (instance.dom.view) updatePreview(instance); 109 | }, 300); 110 | } 111 | 112 | function updatePreview(instance) { 113 | const currentSettings = instance.getCurrentProfileSettingsFromForm(); 114 | if (!currentSettings) return; 115 | 116 | instance.dom.previewImage.src = ApiClient.getUrl(instance.apiRoutes.Preview, { 117 | OptionsJson: JSON.stringify(currentSettings), 118 | v: new Date().getTime() 119 | }); 120 | } 121 | 122 | function selectIconsFolder(instance) { 123 | require(['directorybrowser'], (directorybrowser) => { 124 | const browser = new directorybrowser(); 125 | browser.show({ 126 | header: 'Select Icons Folder', 127 | path: instance.dom.txtIconsFolder.value, 128 | callback: (path) => { 129 | if (path) { 130 | instance.dom.txtIconsFolder.value = path; 131 | onFormChange(instance, { target: instance.dom.txtIconsFolder }); 132 | instance.validateIconsFolder(); 133 | } 134 | browser.close(); 135 | } 136 | }); 137 | }); 138 | } 139 | 140 | return { 141 | onFormChange: onFormChange, 142 | onPriorityChange: onPriorityChange, 143 | updateAllPriorityGroups: updateAllPriorityGroups, 144 | toggleRatingAppearanceControls: toggleRatingAppearanceControls, 145 | onTabChange: onTabChange, 146 | triggerPreviewUpdate: triggerPreviewUpdate, 147 | selectIconsFolder: selectIconsFolder 148 | }; 149 | }); 150 | -------------------------------------------------------------------------------- /Configuration/EmbyIconsConfiguration.Troubleshooter.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |

Diagnostics & Maintenance

4 |
5 |

Cache Management

6 |

7 | If you add, remove, or change any icon files, clear the cache to apply the changes. Posters will refresh as you browse. This also clears all internal data caches. 8 |

9 |
10 | 13 |
14 |
15 |

Plugin Memory Usage

16 |
Loading...
17 |
18 |
19 |
20 | 21 |

Series Troubleshooter

22 |

23 | Many icons (e.g., audio language, resolution) appear on a TV show poster only if all episodes share the same property. This tool scans a series and reports inconsistencies. 24 |

25 | 26 |
27 |

Checks to Perform

28 |

Uncheck properties you don’t care about to speed up scans and reduce report noise.

29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 | 41 |
42 |

Scan a Specific TV Show

43 |
44 | 45 | 46 |
47 |
48 | 51 |
52 |
53 | 54 |
55 |

Scan All TV Shows

56 |

57 | Scan your entire library to find all TV shows with inconsistencies. This may take several minutes on a large library. 58 |

59 |
60 | 63 |
64 |
65 | 66 |
67 | 68 |
69 | 70 |

Aspect Ratio Calculator

71 |

72 | Enter your video's resolution to find the correct icon name for its aspect ratio. 73 |

74 |
75 |
76 |
77 | 78 |
79 |
80 | 81 |
82 | 85 |
86 | 104 |
105 | 106 |
-------------------------------------------------------------------------------- /Configuration/EmbyIconsConfiguration.Readme.html: -------------------------------------------------------------------------------- 1 |
2 |

EmbyIcons Plugin Guide

3 | 4 |

Introduction

5 |

This plugin overlays icons for language, audio channels, video format, resolution, aspect ratio, parental rating, and community ratings onto your media posters. Provide your own icon images in a designated folder and name them according to the conventions below.

6 |

Supported image formats for icons are .png, .jpg, .webp, .bmp, and .gif.

7 | 8 |

Icon Naming Conventions

9 |

Icons use a strict prefix-based naming scheme: prefix.name.png. The prefix indicates the category; the name is matched against media properties. Files that don’t follow this convention are ignored.

10 | 11 |

Language & Subtitle Icons

12 |

13 | Audio Language (prefix: lang): lang.{name}.png
14 | {name} must be the full display language name (exactly as Emby shows it), lowercased.
15 | Example: lang.english.png, lang.french.png 16 |

17 | 18 |

19 | Subtitle Language (prefix: sub): sub.{name}.png
20 | {name} must be the full display language name, lowercased.
21 | Example: sub.english.png, sub.japanese.png 22 |

23 | 24 |

Dynamic Detection

25 |

Most categories are detected directly from media stream metadata (no filename hacks required). You can create icons for any codec/layout Emby recognizes.

26 | 27 |

28 | Parental Rating (prefix: pr): pr.{rating}.png
29 | {rating} should match the official rating reported by Emby (e.g., pg-13, tv-ma) in lowercase.
30 | Example: pr.pg-13.png, pr.tv-ma.png 31 |

32 | 33 |

34 | Audio Codec (prefix: ac): ac.{codec_name}.png
35 | {codec_name} should match the codec reported by Emby (e.g., eac3, dts-hdma).
36 | Example: ac.eac3.png, ac.dts-hdma.png 37 |

38 | 39 |

40 | Video Codec (prefix: vc): vc.{codec_name}.png
41 | {codec_name} should match the codec reported by Emby (e.g., hevc, av1).
42 | Example: vc.hevc.png, vc.av1.png 43 |

44 | 45 |

46 | Audio Channels (prefix: ch): ch.{layout_name}.png
47 | {layout_name} should match the audio layout reported by Emby (e.g., 5.1, 7.1, stereo).
48 | Example: ch.5.1.png, ch.7.1.png, ch.stereo.png 49 |

50 | 51 |

52 | Resolution (prefix: res): res.{resolution_name}.png
53 | Resolution is derived from the video stream (e.g., 4k, 1080p, 1080i) and matched against available icon keys.
54 | Example: res.4k.png, res.1080p.png, res.1080i.png 55 |

56 | 57 |

58 | Aspect Ratio (prefix: ar): ar.{ratio}.png
59 | Uses video stream aspect ratio with the colon replaced by x (e.g., 16x9, 4x3).
60 | Example: ar.16x9.png, ar.4x3.png 61 |

62 | 63 |

64 | Video Format (prefix: hdr): hdr.{format_name}.png
65 | Detects HDR/Dolby Vision from stream metadata; supported names include hdr and dv. You can also add others you provide icons for.
66 | Example: hdr.dv.png, hdr.hdr.png 67 |

68 | 69 |

Other Categories

70 | 71 |

72 | Custom Tags (prefix: tag): tag.{name}.png
73 | Matches against media item tags (case-insensitive). Tags are normalized: lowercased, whitespace collapsed to hyphens, and leading/trailing hyphens removed.
74 | Examples: Behind the Scenestag.behind-the-scenes.png, 3Dtag.3d.png 75 |

76 | 77 |

78 | Community Rating (prefix: rating): rating.{name}.png
79 | The "Community Rating" icon is for IMDb and always uses the key `imdb`. A built-in icon is provided.
80 | Custom icon file: rating.imdb.png 81 |

82 | 83 |

84 | Rotten Tomatoes Rating (no prefix):
85 | The "Rotten Tomatoes" icon is separate and displays based on the critic score. These icons do not use the `rating.` prefix for custom files. The naming convention is based on a 60% threshold: 86 |

    87 |
  • Score < 60%: t.splat.png
  • 88 |
  • Score ≥ 60%: t.tomato.png
  • 89 |
90 |

91 | 92 |

Notes & Tips

93 |
    94 |
  • Language and subtitle icons use the display language shown by Emby (not ISO codes).
  • 95 |
  • For collections and series, language icons require all children to share the same display language unless Lite Mode is used.
  • 96 |
  • If icons don’t appear as expected, use the Troubleshooter tab to find inconsistencies.
  • 97 |
98 | 99 |
100 |
101 | 102 | 103 | 104 |
105 |

Thank you to all supporters and everyone who helped test this, especially Neminem from the Emby forum.

106 |

If you like this project and want to support it you can donate a coffee. Add your name and we’ll list it here!

107 |

Thank you for the donations to:

108 |

Alexander Hürter.
John Spiegel.

109 |
110 |
111 |
112 |
-------------------------------------------------------------------------------- /EmbyIcons.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | Library 6 | enable 7 | latest 8 | 9 | 5.42.2 10 | 5.42.2.0 11 | 5.42.2.0 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | -------------------------------------------------------------------------------- /Configuration/EmbyIconsConfiguration.Advanced.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Advanced Settings

4 |
5 |

6 | Warning: These are advanced settings that affect performance and memory usage. Only modify these if you understand the implications. Default values are recommended for most users. 7 |

8 | 9 |
10 |

Cache Sizes

11 |

Larger caches use more memory but improve performance by reducing processing overhead.

12 | 13 |
14 | 15 |
Maximum number of episode icon data entries to cache. Default: 2000.
16 |
17 | 18 |
19 | 20 |
Maximum number of series aggregation results to cache. Default: 500.
21 |
22 | 23 |
24 | 25 |
Maximum number of item-to-profile mappings to cache. Default: 20000.
26 |
27 | 28 |
29 | 30 |
Maximum number of collection-to-profile mappings to cache. Default: 5000.
31 |
32 |
33 | 34 |
35 |

Cache Timing

36 |

Adjust how long data stays cached and how often caches are cleaned up.

37 | 38 |
39 | 40 |
How long to keep episode icon data cached before expiring. Default: 6 hours.
41 |
42 | 43 |
44 | 45 |
How often to remove old series aggregation cache entries. Default: 6 hours.
46 |
47 | 48 |
49 | 50 |
How often to compact memory caches. Default: 1 hour.
51 |
52 |
53 | 54 |
55 |

Performance

56 |

Control how the plugin utilizes system resources during image processing.

57 | 58 |
59 | 60 |
61 | Multiplier for processor count to determine max concurrent operations. Default: 0.75. 62 |
63 |
64 |
65 | 66 |
67 |

Feature Toggles

68 |

Enable or disable performance-related features.

69 | 70 |
71 | 75 |
Only generate overlays when posters are viewed, not for entire libraries at once. Reduces server load during library scans.
76 |
77 |
78 | 82 |
Pre-render common icon combinations (e.g., "4K + DTS + HDR") to improve performance and reduce CPU usage.
83 |
84 |
85 | 86 |
87 |

Testing & Diagnostics

88 |

Options for testing and troubleshooting image processing.

89 | 90 |
91 | 95 |
Logs additional information to the Emby server log (useful for troubleshooting).
96 |
97 |
98 | 102 |
103 | Testing Only: Disables SkiaSharp even if available, forcing the plugin to use the fallback processor. 104 |
105 |
106 |
107 | 108 |
109 |

Rating Display Customization

110 |

Customize how rating scores appear on your media posters.

111 | 112 |
113 | 114 |
Controls the size of rating text relative to icon size. Default: 0.75 (75% of icon height).
115 |
116 | 117 |
118 | 119 |
Customize or remove the "%" symbol for percentage ratings. Leave blank to hide. Default: %
120 |
121 | 122 |
123 | 124 |
Fine-tune the vertical alignment of rating text. Negative values move text up, positive values move it down. Default: 0
125 |
126 |
127 | 128 |
129 | 132 |
133 |
-------------------------------------------------------------------------------- /EmbyIconsConfiguration.js: -------------------------------------------------------------------------------- 1 | define([ 2 | 'baseView', 3 | 'loading', 4 | 'dialogHelper', 5 | 'toast', 6 | 'emby-input', 7 | 'emby-button', 8 | 'emby-checkbox', 9 | 'emby-select', 10 | 'configurationpage?name=EmbyIconsConfigurationUtils', 11 | 'configurationpage?name=EmbyIconsConfigurationDom', 12 | 'configurationpage?name=EmbyIconsConfigurationDomCache', 13 | 'configurationpage?name=EmbyIconsConfigurationEvents', 14 | 'configurationpage?name=EmbyIconsConfigurationDataLoader', 15 | 'configurationpage?name=EmbyIconsConfigurationUIHandlers', 16 | 'configurationpage?name=EmbyIconsConfigurationProfile', 17 | 'configurationpage?name=EmbyIconsConfigurationProfileUI', 18 | 'configurationpage?name=EmbyIconsConfigurationScans', 19 | 'configurationpage?name=EmbyIconsConfigurationApi' 20 | ], function ( 21 | BaseView, 22 | loading, 23 | dialogHelper, 24 | toast, 25 | embyInput, 26 | embyButton, 27 | embyCheckbox, 28 | embySelect, 29 | utils, 30 | domHelpers, 31 | domCache, 32 | eventsModule, 33 | dataLoader, 34 | uiHandlers, 35 | profileModule, 36 | profileUI, 37 | scansModule, 38 | apiModule 39 | ) { 40 | 'use strict'; 41 | 42 | const pluginId = "b8d0f5a4-3e96-4c0f-a6e2-9f0c2ecb5c5f"; 43 | 44 | class EmbyIconsConfigurationView extends BaseView { 45 | constructor(view, params) { 46 | super(view, params); 47 | 48 | this.pluginConfiguration = {}; 49 | this.allLibraries = []; 50 | this.currentProfileId = null; 51 | this.previewUpdateTimer = null; 52 | this.configSaveTimer = null; 53 | this.folderValidationTimer = null; 54 | this.selectedSeriesId = null; 55 | this.libraryMap = new Map(); 56 | this.profileMap = new Map(); 57 | this.apiRoutes = null; 58 | this.progressPollInterval = null; 59 | 60 | this.dom = null; 61 | } 62 | 63 | async onResume(options) { 64 | super.onResume(options); 65 | loading.show(); 66 | try { 67 | await dataLoader.loadPagePartials(); 68 | this.dom = domCache.getDomElements(this.view); 69 | eventsModule.bindEvents(this); 70 | 71 | await this.fetchApiRoutes(); 72 | await dataLoader.loadData(this); 73 | } catch (error) { 74 | console.error('Failed to initialize EmbyIcons configuration page', error); 75 | toast({ type: 'error', text: 'Error loading page. Please refresh.' }); 76 | } finally { 77 | loading.hide(); 78 | } 79 | } 80 | 81 | onPause() { 82 | super.onPause(); 83 | 84 | if (this.previewUpdateTimer) { 85 | clearTimeout(this.previewUpdateTimer); 86 | this.previewUpdateTimer = null; 87 | } 88 | if (this.configSaveTimer) { 89 | clearTimeout(this.configSaveTimer); 90 | this.configSaveTimer = null; 91 | } 92 | if (this.folderValidationTimer) { 93 | clearTimeout(this.folderValidationTimer); 94 | this.folderValidationTimer = null; 95 | } 96 | if (this.progressPollInterval) { 97 | clearInterval(this.progressPollInterval); 98 | this.progressPollInterval = null; 99 | } 100 | if (this.documentClickHandler) { 101 | document.removeEventListener('click', this.documentClickHandler); 102 | this.documentClickHandler = null; 103 | } 104 | } 105 | 106 | async fetchApiRoutes() { 107 | return apiModule.fetchApiRoutes(this); 108 | } 109 | 110 | async validateIconsFolder() { 111 | return apiModule.validateIconsFolder(this); 112 | } 113 | 114 | async clearCache() { 115 | return apiModule.clearCache(this); 116 | } 117 | 118 | async refreshMemoryUsage() { 119 | return apiModule.refreshMemoryUsage(this); 120 | } 121 | 122 | async calculateAspectRatio() { 123 | return apiModule.calculateAspectRatio(this); 124 | } 125 | 126 | async saveData() { 127 | return dataLoader.saveData(this); 128 | } 129 | 130 | onFormChange(event) { 131 | return uiHandlers.onFormChange(this, event); 132 | } 133 | 134 | onPriorityChange(event) { 135 | return uiHandlers.onPriorityChange(this, event); 136 | } 137 | 138 | updateAllPriorityGroups() { 139 | return uiHandlers.updateAllPriorityGroups(this); 140 | } 141 | 142 | onTabChange(e) { 143 | return uiHandlers.onTabChange(this, e); 144 | } 145 | 146 | selectIconsFolder() { 147 | return uiHandlers.selectIconsFolder(this); 148 | } 149 | 150 | populateProfileSelector() { 151 | return profileUI.populateProfileSelector(this); 152 | } 153 | 154 | onProfileSelected(e) { 155 | return profileUI.onProfileSelected(this, e); 156 | } 157 | 158 | loadProfileSettings(profileId) { 159 | return profileUI.loadProfileSettings(this, profileId); 160 | } 161 | 162 | getCurrentProfileSettingsFromForm() { 163 | return profileModule.getCurrentProfileSettingsFromForm(this); 164 | } 165 | 166 | saveCurrentProfileSettings() { 167 | return profileModule.saveCurrentProfileSettings(this); 168 | } 169 | 170 | addProfile() { 171 | return profileModule.addProfile(this); 172 | } 173 | 174 | renameProfile() { 175 | return profileModule.renameProfile(this); 176 | } 177 | 178 | deleteProfile() { 179 | return profileModule.deleteProfile(this); 180 | } 181 | 182 | async exportCurrentProfile() { 183 | return profileModule.exportCurrentProfile(this); 184 | } 185 | 186 | async exportAllProfiles() { 187 | return profileModule.exportAllProfiles(this); 188 | } 189 | 190 | async importProfiles() { 191 | return profileModule.importProfiles(this); 192 | } 193 | 194 | loadFilenameMappings(profile) { 195 | return profileModule.loadFilenameMappings(this, profile); 196 | } 197 | 198 | saveFilenameMappings(profile) { 199 | return profileModule.saveFilenameMappings(this, profile); 200 | } 201 | 202 | addFilenameMappingRow(mapping) { 203 | const keyword = mapping ? mapping.Keyword : ''; 204 | const iconName = mapping ? mapping.IconName : ''; 205 | const newRow = domHelpers.createFilenameMappingRow(keyword, iconName); 206 | this.dom.filenameMappingsContainer.appendChild(newRow); 207 | } 208 | 209 | onFilenameMappingButtonClick(e) { 210 | const deleteButton = e.target.closest('.btnDeleteFilenameMapping'); 211 | if (deleteButton) { 212 | deleteButton.closest('.filenameMappingRow').remove(); 213 | uiHandlers.onFormChange(this, { target: deleteButton }); 214 | } 215 | } 216 | 217 | async runIconScan() { 218 | return scansModule.runIconScan(this); 219 | } 220 | 221 | renderIconManagerReport(report) { 222 | return scansModule.renderIconManagerReport(this, report); 223 | } 224 | 225 | async searchForSeries() { 226 | return scansModule.searchForSeries(this); 227 | } 228 | 229 | onSeriesSearchResultClick(e) { 230 | return scansModule.onSeriesSearchResultClick(this, e); 231 | } 232 | 233 | getTroubleshooterChecks() { 234 | return Array.from(this.dom.troubleshooterChecks) 235 | .filter(cb => cb.checked) 236 | .map(cb => cb.getAttribute('data-check-name')) 237 | .join(','); 238 | } 239 | 240 | async runSeriesScan() { 241 | return scansModule.runSeriesScan(this); 242 | } 243 | 244 | async runFullSeriesScan() { 245 | return scansModule.runFullSeriesScan(this); 246 | } 247 | 248 | renderSeriesReport(reports) { 249 | return scansModule.renderSeriesReport(this, reports); 250 | } 251 | 252 | onSeriesReportHeaderClick(e) { 253 | const header = e.target.closest('.collapsible-header'); 254 | if (!header) return; 255 | 256 | const content = header.nextElementSibling; 257 | const indicator = header.querySelector('.collapsible-indicator'); 258 | 259 | if (content && content.classList.contains('collapsible-content')) { 260 | const isVisible = content.style.display !== 'none'; 261 | content.style.display = isVisible ? 'none' : 'block'; 262 | 263 | if (indicator) { 264 | indicator.style.transform = isVisible ? '' : 'rotate(-180deg)'; 265 | } 266 | } 267 | } 268 | 269 | pollScanProgress(scanType, button, container) { 270 | return scansModule.pollScanProgress(this, scanType, button, container); 271 | } 272 | 273 | showDialog(templateId, dialogOptions) { 274 | const template = this.dom.view.querySelector(templateId); 275 | const dlg = dialogHelper.createDialog(dialogOptions); 276 | dlg.innerHTML = ''; 277 | dlg.appendChild(template.content.cloneNode(true)); 278 | dialogHelper.open(dlg); 279 | return dlg; 280 | } 281 | 282 | downloadJson(jsonString, filename) { 283 | return profileModule.downloadJson(jsonString, filename); 284 | } 285 | } 286 | 287 | return EmbyIconsConfigurationView; 288 | }); 289 | -------------------------------------------------------------------------------- /Services/ProfileManagerService.cs: -------------------------------------------------------------------------------- 1 | using EmbyIcons.Configuration; 2 | using EmbyIcons.Helpers; 3 | using MediaBrowser.Controller.Entities; 4 | using MediaBrowser.Controller.Entities.TV; 5 | using MediaBrowser.Controller.Library; 6 | using MediaBrowser.Model.Logging; 7 | using MediaBrowser.Model.Querying; 8 | using Microsoft.Extensions.Caching.Memory; 9 | using System; 10 | using System.Linq; 11 | using System.Threading; 12 | 13 | namespace EmbyIcons.Services 14 | { 15 | public class ProfileManagerService : IDisposable 16 | { 17 | private readonly ILibraryManager _libraryManager; 18 | private readonly ILogger _logger; 19 | private readonly PluginOptions _configuration; 20 | 21 | private Lazy> _libraryPathTrieLazy; 22 | 23 | private MemoryCache _itemToProfileIdCache; 24 | private MemoryCache _collectionToProfileIdCache; 25 | private Timer? _cacheMaintenanceTimer; 26 | 27 | public ProfileManagerService(ILibraryManager libraryManager, ILogger logger, PluginOptions configuration) 28 | { 29 | _libraryManager = libraryManager; 30 | _logger = logger; 31 | _configuration = configuration; 32 | _libraryPathTrieLazy = new Lazy>(CreateLibraryPathTrie); 33 | 34 | var maxItemCacheSize = Math.Max(1000, configuration.MaxItemToProfileCacheSize); 35 | var maxCollectionCacheSize = Math.Max(100, configuration.MaxCollectionToProfileCacheSize); 36 | _itemToProfileIdCache = new(new MemoryCacheOptions { SizeLimit = maxItemCacheSize }); 37 | _collectionToProfileIdCache = new(new MemoryCacheOptions { SizeLimit = maxCollectionCacheSize }); 38 | 39 | var maintenanceInterval = TimeSpan.FromHours(Math.Max(0.5, configuration.CacheMaintenanceIntervalHours)); 40 | _cacheMaintenanceTimer = new Timer(_ => CompactCaches(), null, maintenanceInterval, maintenanceInterval); 41 | } 42 | 43 | public void InvalidateLibraryCache() 44 | { 45 | _libraryPathTrieLazy = new Lazy>(CreateLibraryPathTrie); 46 | 47 | var maxItemCacheSize = Math.Max(1000, _configuration.MaxItemToProfileCacheSize); 48 | var maxCollectionCacheSize = Math.Max(100, _configuration.MaxCollectionToProfileCacheSize); 49 | 50 | var oldCache = Interlocked.Exchange(ref _itemToProfileIdCache, new MemoryCache(new MemoryCacheOptions { SizeLimit = maxItemCacheSize })); 51 | try { oldCache?.Dispose(); } catch (Exception ex) { _logger.Debug($"[EmbyIcons] Error disposing old item cache: {ex.Message}"); } 52 | 53 | var oldCollectionCache = Interlocked.Exchange(ref _collectionToProfileIdCache, new MemoryCache(new MemoryCacheOptions { SizeLimit = maxCollectionCacheSize })); 54 | try { oldCollectionCache?.Dispose(); } catch (Exception ex) { _logger.Debug($"[EmbyIcons] Error disposing old collection cache: {ex.Message}"); } 55 | 56 | _logger.Info("[EmbyIcons] Library path and item profile caches have been invalidated."); 57 | } 58 | 59 | public void Dispose() 60 | { 61 | try 62 | { 63 | _itemToProfileIdCache?.Dispose(); 64 | } 65 | catch (Exception ex) 66 | { 67 | _logger.Debug($"[EmbyIcons] Error disposing item profile cache: {ex.Message}"); 68 | } 69 | 70 | try 71 | { 72 | _collectionToProfileIdCache?.Dispose(); 73 | } 74 | catch (Exception ex) 75 | { 76 | _logger.Debug($"[EmbyIcons] Error disposing collection profile cache: {ex.Message}"); 77 | } 78 | 79 | try { _cacheMaintenanceTimer?.Dispose(); } catch (Exception ex) { _logger.Debug($"[EmbyIcons] Error disposing cache maintenance timer: {ex.Message}"); } 80 | } 81 | 82 | private void CompactCaches() 83 | { 84 | try 85 | { 86 | _collectionToProfileIdCache?.Compact(0.1); 87 | _itemToProfileIdCache?.Compact(0.05); 88 | if (Helpers.PluginHelper.IsDebugLoggingEnabled) 89 | _logger.Debug("[EmbyIcons] Performed cache compaction for profile/collection caches."); 90 | } 91 | catch (Exception ex) 92 | { 93 | _logger.ErrorException("[EmbyIcons] Error during cache compaction.", ex); 94 | } 95 | } 96 | 97 | private Trie CreateLibraryPathTrie() 98 | { 99 | _logger.Debug("[EmbyIcons] Populating library path cache using Trie."); 100 | var newTrie = new Trie(); 101 | try 102 | { 103 | var libraries = _libraryManager.GetVirtualFolders() 104 | .Where(lib => lib != null && lib.Locations != null) 105 | .SelectMany(lib => lib.Locations!.Select(loc => (Path: loc, lib.Name, lib.Id))) 106 | .Where(libInfo => !string.IsNullOrEmpty(libInfo.Path)) 107 | .OrderByDescending(libInfo => libInfo.Path.Length); 108 | 109 | foreach (var libInfo in libraries) 110 | { 111 | newTrie.Insert(libInfo.Path, libInfo.Id.ToString()); 112 | } 113 | } 114 | catch (Exception ex) 115 | { 116 | _logger.ErrorException("[EmbyIcons] CRITICAL: Failed to get virtual folders from LibraryManager.", ex); 117 | } 118 | 119 | return newTrie; 120 | } 121 | 122 | private IconProfile? GetProfileForPath(string? path) 123 | { 124 | if (string.IsNullOrEmpty(path)) 125 | { 126 | return null; 127 | } 128 | 129 | var currentLibraryTrie = _libraryPathTrieLazy.Value; 130 | 131 | if (currentLibraryTrie == null) 132 | { 133 | _logger.Warn("[EmbyIcons] Library path Trie is null, cannot check library restrictions."); 134 | return null; 135 | } 136 | 137 | var libraryId = currentLibraryTrie.FindLongestPrefix(path); 138 | 139 | if (libraryId != null) 140 | { 141 | var mapping = _configuration.LibraryProfileMappings.FirstOrDefault(m => m.LibraryId == libraryId); 142 | if (mapping != null) 143 | { 144 | return _configuration.Profiles?.FirstOrDefault(p => p.Id == mapping.ProfileId); 145 | } 146 | } 147 | 148 | return null; 149 | } 150 | 151 | public IconProfile? GetProfileForItem(BaseItem item) 152 | { 153 | if (item.Id != Guid.Empty && _itemToProfileIdCache.TryGetValue(item.Id, out Guid cachedProfileId)) 154 | { 155 | if (cachedProfileId == Guid.Empty) return null; 156 | return _configuration.Profiles?.FirstOrDefault(p => p.Id == cachedProfileId); 157 | } 158 | 159 | IconProfile? foundProfile; 160 | 161 | if (item is BoxSet boxSet) 162 | { 163 | var mapping = _configuration.LibraryProfileMappings.FirstOrDefault(m => m.LibraryId == boxSet.ParentId.ToString()); 164 | if (mapping != null) 165 | { 166 | foundProfile = _configuration.Profiles?.FirstOrDefault(p => p.Id == mapping.ProfileId); 167 | } 168 | else if (_configuration.EnableCollectionProfileLookup) 169 | { 170 | if (_collectionToProfileIdCache.TryGetValue(boxSet.InternalId, out Guid cachedCollectionProfileId)) 171 | { 172 | foundProfile = cachedCollectionProfileId == Guid.Empty ? null : _configuration.Profiles?.FirstOrDefault(p => p.Id == cachedCollectionProfileId); 173 | } 174 | else 175 | { 176 | var firstChild = _libraryManager.GetItemList(new InternalItemsQuery 177 | { 178 | CollectionIds = new[] { boxSet.InternalId }, 179 | Limit = 1, 180 | Recursive = true, 181 | IncludeItemTypes = new[] { "Movie", "Episode" } 182 | }).FirstOrDefault(); 183 | 184 | if (firstChild != null) 185 | { 186 | foundProfile = GetProfileForPath(firstChild.Path); 187 | } 188 | else 189 | { 190 | if (_configuration.EnableDebugLogging) 191 | _logger.Warn($"[EmbyIcons] Collection '{boxSet.Name}' (ID: {boxSet.Id}) is empty. Cannot determine library profile."); 192 | foundProfile = null; 193 | } 194 | 195 | var profileIdToCache = foundProfile?.Id ?? Guid.Empty; 196 | var cacheEntryOptions = new MemoryCacheEntryOptions() 197 | .SetSize(1) 198 | .SetSlidingExpiration(TimeSpan.FromHours(6)); 199 | _collectionToProfileIdCache.Set(boxSet.InternalId, profileIdToCache, cacheEntryOptions); 200 | } 201 | } 202 | else 203 | { 204 | foundProfile = null; 205 | } 206 | } 207 | else 208 | { 209 | foundProfile = GetProfileForPath(item.Path); 210 | } 211 | 212 | if (item.Id != Guid.Empty) 213 | { 214 | var profileIdToCache = foundProfile?.Id ?? Guid.Empty; 215 | var cacheEntryOptions = new MemoryCacheEntryOptions() 216 | .SetSize(1) 217 | .SetSlidingExpiration(TimeSpan.FromDays(1)); 218 | 219 | _itemToProfileIdCache.Set(item.Id, profileIdToCache, cacheEntryOptions); 220 | } 221 | 222 | return foundProfile; 223 | } 224 | } 225 | } -------------------------------------------------------------------------------- /Configuration/PluginOptions.cs: -------------------------------------------------------------------------------- 1 | using EmbyIcons.Configuration; 2 | using MediaBrowser.Model.Plugins; 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Text.Json.Serialization; 6 | 7 | namespace EmbyIcons.Configuration 8 | { 9 | public class LibraryMapping 10 | { 11 | public string LibraryId { get; set; } = string.Empty; 12 | public Guid ProfileId { get; set; } 13 | } 14 | 15 | public enum OutputFormat 16 | { 17 | Jpeg, 18 | Png, 19 | Auto 20 | } 21 | 22 | public class FilenameIconMapping 23 | { 24 | public string Keyword { get; set; } = string.Empty; 25 | public string IconName { get; set; } = string.Empty; 26 | } 27 | 28 | public class PluginOptions : BasePluginConfiguration 29 | { 30 | public string PersistedVersion { get; set; } = "1.0.0"; 31 | public string IconsFolder { get; set; } = GetDefaultIconsFolder(); 32 | 33 | private static string GetDefaultIconsFolder() 34 | { 35 | if (Environment.OSVersion.Platform == PlatformID.Win32NT) 36 | return @"C:\"; 37 | else 38 | return "/"; 39 | } 40 | 41 | public IconLoadingMode IconLoadingMode { get; set; } = IconLoadingMode.Hybrid; 42 | public bool EnableDebugLogging { get; set; } = false; 43 | public bool EnableCollectionProfileLookup { get; set; } = true; 44 | public bool EnableLazyIconLoading { get; set; } = true; 45 | public bool EnableIconTemplateCaching { get; set; } = true; 46 | 47 | public OutputFormat OutputFormat { get; set; } = OutputFormat.Auto; 48 | 49 | public int JpegQuality { get; set; } = 75; 50 | public bool EnableImageSmoothing { get; set; } = false; 51 | 52 | public List Profiles { get; set; } = new List(); 53 | public List LibraryProfileMappings { get; set; } = new List(); 54 | 55 | #region Advanced Settings 56 | public int MaxEpisodeCacheSize { get; set; } = 2000; 57 | public int MaxSeriesCacheSize { get; set; } = 500; 58 | public int MaxItemToProfileCacheSize { get; set; } = 20000; 59 | public int MaxCollectionToProfileCacheSize { get; set; } = 5000; 60 | public int EpisodeCacheSlidingExpirationHours { get; set; } = 6; 61 | public int CachePruningIntervalHours { get; set; } = 6; 62 | public int CacheMaintenanceIntervalHours { get; set; } = 1; 63 | public double GlobalConcurrencyMultiplier { get; set; } = 0.75; // Multiplied by processor count 64 | public bool ForceDisableSkiaSharp { get; set; } = false; 65 | 66 | #endregion 67 | 68 | #region Obsolete properties for migration 69 | [Obsolete] 70 | public string SelectedLibraries { get; set; } = string.Empty; 71 | [Obsolete] 72 | public bool ShowAudioIcons { get; set; } = true; 73 | [Obsolete] 74 | public bool ShowSubtitleIcons { get; set; } = true; 75 | [Obsolete] 76 | public bool ShowOverlaysForEpisodes { get; set; } = true; 77 | [Obsolete] 78 | public bool ShowSeriesIconsIfAllEpisodesHaveLanguage { get; set; } = true; 79 | [Obsolete] 80 | public bool ShowAudioChannelIcons { get; set; } = false; 81 | [Obsolete] 82 | public bool ShowAudioCodecIcons { get; set; } = false; 83 | [Obsolete] 84 | public bool ShowVideoFormatIcons { get; set; } = false; 85 | [Obsolete] 86 | public bool ShowVideoCodecIcons { get; set; } = false; 87 | [Obsolete] 88 | public bool ShowTagIcons { get; set; } = false; 89 | [Obsolete] 90 | public bool ShowResolutionIcons { get; set; } = false; 91 | [Obsolete] 92 | public bool ShowCommunityScoreIcon { get; set; } = false; 93 | [Obsolete] 94 | public bool ShowAspectRatioIcons { get; set; } = false; 95 | [Obsolete] 96 | public IconAlignment AudioIconAlignment { get; set; } = IconAlignment.TopLeft; 97 | [Obsolete] 98 | public bool AudioOverlayHorizontal { get; set; } = true; 99 | [Obsolete] 100 | public IconAlignment SubtitleIconAlignment { get; set; } = IconAlignment.BottomLeft; 101 | [Obsolete] 102 | public bool SubtitleOverlayHorizontal { get; set; } = true; 103 | [Obsolete] 104 | public IconAlignment ChannelIconAlignment { get; set; } = IconAlignment.TopLeft; 105 | [Obsolete] 106 | public bool ChannelOverlayHorizontal { get; set; } = true; 107 | [Obsolete] 108 | public IconAlignment AudioCodecIconAlignment { get; set; } = IconAlignment.TopLeft; 109 | [Obsolete] 110 | public bool AudioCodecOverlayHorizontal { get; set; } = true; 111 | [Obsolete] 112 | public IconAlignment VideoFormatIconAlignment { get; set; } = IconAlignment.TopRight; 113 | [Obsolete] 114 | public bool VideoFormatOverlayHorizontal { get; set; } = true; 115 | [Obsolete] 116 | public IconAlignment VideoCodecIconAlignment { get; set; } = IconAlignment.TopRight; 117 | [Obsolete] 118 | public bool VideoCodecOverlayHorizontal { get; set; } = true; 119 | [Obsolete] 120 | public IconAlignment TagIconAlignment { get; set; } = IconAlignment.BottomLeft; 121 | [Obsolete] 122 | public bool TagOverlayHorizontal { get; set; } = false; 123 | [Obsolete] 124 | public IconAlignment ResolutionIconAlignment { get; set; } = IconAlignment.BottomRight; 125 | [Obsolete] 126 | public bool ResolutionOverlayHorizontal { get; set; } = true; 127 | [Obsolete] 128 | public IconAlignment CommunityScoreIconAlignment { get; set; } = IconAlignment.TopRight; 129 | [Obsolete] 130 | public bool CommunityScoreOverlayHorizontal { get; set; } = true; 131 | [Obsolete] 132 | public IconAlignment AspectRatioIconAlignment { get; set; } = IconAlignment.BottomRight; 133 | [Obsolete] 134 | public bool AspectRatioOverlayHorizontal { get; set; } = true; 135 | [Obsolete] 136 | public ScoreBackgroundShape CommunityScoreBackgroundShape { get; set; } = ScoreBackgroundShape.None; 137 | [Obsolete] 138 | public string CommunityScoreBackgroundColor { get; set; } = "#404040"; 139 | [Obsolete] 140 | public int CommunityScoreBackgroundOpacity { get; set; } = 80; 141 | [Obsolete] 142 | public int IconSize { get; set; } = 10; 143 | [Obsolete] 144 | public bool UseSeriesLiteMode { get; set; } = true; 145 | #endregion 146 | } 147 | 148 | public class IconProfile 149 | { 150 | public Guid Id { get; set; } 151 | public string Name { get; set; } 152 | public ProfileSettings Settings { get; set; } 153 | 154 | public IconProfile() 155 | { 156 | Id = Guid.NewGuid(); 157 | Name = "New Profile"; 158 | Settings = new ProfileSettings(); 159 | } 160 | } 161 | 162 | public class ProfileSettings 163 | { 164 | public bool EnableForPosters { get; set; } = true; 165 | public bool EnableForThumbs { get; set; } = false; 166 | public bool EnableForBanners { get; set; } = false; 167 | 168 | public bool ShowOverlaysForEpisodes { get; set; } = true; 169 | public bool ShowOverlaysForSeasons { get; set; } = false; 170 | public bool ShowSeriesIconsIfAllEpisodesHaveLanguage { get; set; } = true; 171 | public bool ExcludeSpecialsFromSeriesAggregation { get; set; } = false; 172 | public bool ShowCollectionIconsIfAllChildrenHaveLanguage { get; set; } = true; 173 | public bool UseCollectionLiteMode { get; set; } = true; 174 | 175 | public IconAlignment AudioIconAlignment { get; set; } = IconAlignment.TopLeft; 176 | public bool AudioOverlayHorizontal { get; set; } = true; 177 | public int AudioIconPriority { get; set; } = 1; 178 | 179 | public IconAlignment SubtitleIconAlignment { get; set; } = IconAlignment.BottomLeft; 180 | public bool SubtitleOverlayHorizontal { get; set; } = true; 181 | public int SubtitleIconPriority { get; set; } = 2; 182 | 183 | public IconAlignment ChannelIconAlignment { get; set; } = IconAlignment.Disabled; 184 | public bool ChannelOverlayHorizontal { get; set; } = true; 185 | public int ChannelIconPriority { get; set; } = 7; 186 | 187 | public IconAlignment AudioCodecIconAlignment { get; set; } = IconAlignment.Disabled; 188 | public bool AudioCodecOverlayHorizontal { get; set; } = true; 189 | public int AudioCodecIconPriority { get; set; } = 8; 190 | 191 | public IconAlignment VideoFormatIconAlignment { get; set; } = IconAlignment.Disabled; 192 | public bool VideoFormatOverlayHorizontal { get; set; } = true; 193 | public int VideoFormatIconPriority { get; set; } = 4; 194 | 195 | public IconAlignment VideoCodecIconAlignment { get; set; } = IconAlignment.Disabled; 196 | public bool VideoCodecOverlayHorizontal { get; set; } = true; 197 | public int VideoCodecIconPriority { get; set; } = 5; 198 | 199 | public IconAlignment TagIconAlignment { get; set; } = IconAlignment.Disabled; 200 | public bool TagOverlayHorizontal { get; set; } = false; 201 | public int TagIconPriority { get; set; } = 6; 202 | 203 | public IconAlignment ResolutionIconAlignment { get; set; } = IconAlignment.Disabled; 204 | public bool ResolutionOverlayHorizontal { get; set; } = true; 205 | public int ResolutionIconPriority { get; set; } = 3; 206 | 207 | public IconAlignment CommunityScoreIconAlignment { get; set; } = IconAlignment.Disabled; 208 | public bool CommunityScoreOverlayHorizontal { get; set; } = true; 209 | public int CommunityScoreIconPriority { get; set; } = 9; 210 | public IconAlignment RottenTomatoesScoreIconAlignment { get; set; } = IconAlignment.Disabled; 211 | public bool RottenTomatoesScoreOverlayHorizontal { get; set; } = true; 212 | public int RottenTomatoesScoreIconPriority { get; set; } = 9; 213 | public ScoreBackgroundShape RottenTomatoesScoreBackgroundShape { get; set; } = ScoreBackgroundShape.None; 214 | public string RottenTomatoesScoreBackgroundColor { get; set; } = "#404040"; 215 | public int RottenTomatoesScoreBackgroundOpacity { get; set; } = 80; 216 | 217 | public IconAlignment AspectRatioIconAlignment { get; set; } = IconAlignment.Disabled; 218 | public bool AspectRatioOverlayHorizontal { get; set; } = true; 219 | public int AspectRatioIconPriority { get; set; } = 10; 220 | public bool SnapAspectRatioToCommon { get; set; } = true; 221 | 222 | public IconAlignment ParentalRatingIconAlignment { get; set; } = IconAlignment.Disabled; 223 | public bool ParentalRatingOverlayHorizontal { get; set; } = true; 224 | public int ParentalRatingIconPriority { get; set; } = 11; 225 | 226 | public IconAlignment SourceIconAlignment { get; set; } = IconAlignment.Disabled; 227 | public bool SourceOverlayHorizontal { get; set; } = true; 228 | public int SourceIconPriority { get; set; } = 12; 229 | 230 | public ScoreBackgroundShape CommunityScoreBackgroundShape { get; set; } = ScoreBackgroundShape.None; 231 | public string CommunityScoreBackgroundColor { get; set; } = "#404040"; 232 | public int CommunityScoreBackgroundOpacity { get; set; } = 80; 233 | 234 | public int IconSize { get; set; } = 10; 235 | public float RatingFontSizeMultiplier { get; set; } = 0.75f; 236 | public string RatingPercentageSuffix { get; set; } = "%"; 237 | public float RatingTextVerticalOffset { get; set; } = 0f; 238 | 239 | public bool UseSeriesLiteMode { get; set; } = true; 240 | public List FilenameBasedIcons { get; set; } = new List(); 241 | } 242 | 243 | public enum IconLoadingMode 244 | { 245 | CustomOnly, 246 | Hybrid, 247 | BuiltInOnly 248 | } 249 | 250 | public enum IconAlignment 251 | { 252 | Disabled, 253 | TopLeft, 254 | TopRight, 255 | BottomLeft, 256 | BottomRight 257 | } 258 | 259 | public enum ScoreBackgroundShape 260 | { 261 | None, 262 | Circle, 263 | Square 264 | } 265 | } -------------------------------------------------------------------------------- /Services/SeriesTroubleshooterService.cs: -------------------------------------------------------------------------------- 1 | using EmbyIcons.Api; 2 | using EmbyIcons.Caching; 3 | using EmbyIcons.Configuration; 4 | using EmbyIcons.Helpers; 5 | using MediaBrowser.Controller.Entities; 6 | using MediaBrowser.Controller.Entities.TV; 7 | using MediaBrowser.Controller.Library; 8 | using MediaBrowser.Model.Entities; 9 | using MediaBrowser.Model.Querying; 10 | using MediaBrowser.Model.Services; 11 | using System; 12 | using System.Collections.Generic; 13 | using System.Linq; 14 | 15 | namespace EmbyIcons.Services 16 | { 17 | [Route(ApiRoutes.SeriesTroubleshooter, "GET", Summary = "Finds inconsistencies in episodes of a series")] 18 | public class GetSeriesTroubleshooterReport : IReturn> 19 | { 20 | [ApiMember(Name = "SeriesId", Description = "The ID of the series to check. If omitted, all series will be checked.", IsRequired = false, DataType = "string", ParameterType = "query")] 21 | public string? SeriesId { get; set; } 22 | 23 | [ApiMember(Name = "ChecksToRun", Description = "A comma-separated list of checks to perform (e.g., AudioLanguage,Resolution). If omitted, all checks are run.", IsRequired = false, DataType = "string", ParameterType = "query")] 24 | public string? ChecksToRun { get; set; } 25 | } 26 | 27 | #region Report Models 28 | public class SeriesTroubleshooterReport 29 | { 30 | public string SeriesName { get; set; } = string.Empty; 31 | public string SeriesId { get; set; } = string.Empty; 32 | public int TotalEpisodes { get; set; } 33 | public List Checks { get; set; } = new List(); 34 | } 35 | 36 | public class CheckResult 37 | { 38 | public string CheckName { get; set; } = string.Empty; 39 | public string Status { get; set; } = string.Empty; 40 | public string Message { get; set; } = string.Empty; 41 | public List DominantValues { get; set; } = new List(); 42 | public List MismatchedEpisodes { get; set; } = new List(); 43 | } 44 | 45 | public class MismatchedEpisodeInfo 46 | { 47 | public string EpisodeName { get; set; } = string.Empty; 48 | public string EpisodeId { get; set; } = string.Empty; 49 | public List Actual { get; set; } = new List(); 50 | } 51 | #endregion 52 | 53 | public class SeriesTroubleshooterService : IService 54 | { 55 | private readonly ILibraryManager _libraryManager; 56 | private readonly IconCacheManager _iconCacheManager; 57 | 58 | private static class CheckNames 59 | { 60 | public const string AudioLanguage = "AudioLanguage"; 61 | public const string Subtitles = "Subtitles"; 62 | public const string AudioCodec = "AudioCodec"; 63 | public const string VideoCodec = "VideoCodec"; 64 | public const string AudioChannels = "AudioChannels"; 65 | public const string Resolution = "Resolution"; 66 | public const string AspectRatio = "AspectRatio"; 67 | public const string VideoFormat = "VideoFormat"; 68 | } 69 | 70 | public SeriesTroubleshooterService(ILibraryManager libraryManager) 71 | { 72 | _libraryManager = libraryManager; 73 | _iconCacheManager = Plugin.Instance?.Enhancer._iconCacheManager ?? throw new InvalidOperationException("IconCacheManager not available"); 74 | } 75 | 76 | public object Get(GetSeriesTroubleshooterReport request) 77 | { 78 | var reports = new List(); 79 | 80 | var requestedChecks = new HashSet(StringComparer.OrdinalIgnoreCase); 81 | if (!string.IsNullOrEmpty(request.ChecksToRun)) 82 | { 83 | requestedChecks.UnionWith(request.ChecksToRun.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)); 84 | } 85 | bool runAllChecks = !requestedChecks.Any(); 86 | 87 | if (!string.IsNullOrEmpty(request.SeriesId) && _libraryManager.GetItemById(request.SeriesId) is Series seriesItem) 88 | { 89 | var report = GenerateReportForSeries(seriesItem, requestedChecks, runAllChecks); 90 | if (report.Checks.Any(c => c.Status == "Mismatch")) 91 | { 92 | reports.Add(report); 93 | } 94 | return reports; 95 | } 96 | 97 | var allSeries = _libraryManager.GetItemList(new InternalItemsQuery { IncludeItemTypes = new[] { "Series" }, Recursive = true }) 98 | .OfType() 99 | .ToDictionary(s => s.InternalId); 100 | 101 | var allEpisodes = _libraryManager.GetItemList(new InternalItemsQuery { IncludeItemTypes = new[] { "Episode" }, Recursive = true }) 102 | .OfType(); 103 | 104 | var episodesBySeries = allEpisodes.GroupBy(e => e.SeriesId); 105 | 106 | foreach (var seriesGroup in episodesBySeries) 107 | { 108 | if (allSeries.TryGetValue(seriesGroup.Key, out var series)) 109 | { 110 | var report = GenerateReportForSeries(series, requestedChecks, runAllChecks, seriesGroup.ToList()); 111 | if (report.Checks.Any(c => c.Status == "Mismatch")) 112 | { 113 | reports.Add(report); 114 | } 115 | } 116 | } 117 | 118 | return reports.OrderBy(r => r.SeriesName, StringComparer.CurrentCulture).ToList(); 119 | } 120 | 121 | private SeriesTroubleshooterReport GenerateReportForSeries(Series series, HashSet requestedChecks, bool runAllChecks, List? episodes = null) 122 | { 123 | var report = new SeriesTroubleshooterReport 124 | { 125 | SeriesName = series.Name, 126 | SeriesId = series.Id.ToString() 127 | }; 128 | 129 | episodes ??= _libraryManager.GetItemList(new InternalItemsQuery 130 | { 131 | Parent = series, 132 | Recursive = true, 133 | IncludeItemTypes = new[] { "Episode" } 134 | }).OfType().ToList(); 135 | 136 | report.TotalEpisodes = episodes.Count; 137 | if (episodes.Count == 0) return report; 138 | 139 | var config = Plugin.Instance?.Configuration ?? new PluginOptions(); 140 | var knownResolutions = _iconCacheManager.GetAllAvailableIconKeys(config.IconsFolder) 141 | .GetValueOrDefault(IconCacheManager.IconType.Resolution, new List()); 142 | 143 | var baseItems = episodes.Cast().ToList(); 144 | 145 | if (runAllChecks || requestedChecks.Contains(CheckNames.AudioLanguage)) 146 | report.Checks.Add(CheckProperty(baseItems, "Audio Language", ep => ep.GetMediaStreams().Where(s => s.Type == MediaStreamType.Audio && !string.IsNullOrEmpty(s.DisplayLanguage)).Select(s => LanguageHelper.NormalizeLangCode(s.DisplayLanguage)).ToList())); 147 | 148 | if (runAllChecks || requestedChecks.Contains(CheckNames.Subtitles)) 149 | report.Checks.Add(CheckProperty(baseItems, "Subtitles", ep => ep.GetMediaStreams().Where(s => s.Type == MediaStreamType.Subtitle && !string.IsNullOrEmpty(s.DisplayLanguage)).Select(s => LanguageHelper.NormalizeLangCode(s.DisplayLanguage)).ToList())); 150 | 151 | if (runAllChecks || requestedChecks.Contains(CheckNames.AudioCodec)) 152 | report.Checks.Add(CheckProperty(baseItems, "Audio Codec", ep => ep.GetMediaStreams().Where(s => s.Type == MediaStreamType.Audio).Select(MediaStreamHelper.GetAudioCodecIconName).Where(c => c != null).Select(c => c!).Distinct().ToList())); 153 | 154 | if (runAllChecks || requestedChecks.Contains(CheckNames.VideoCodec)) 155 | report.Checks.Add(CheckProperty(baseItems, "Video Codec", ep => ep.GetMediaStreams().Where(s => s.Type == MediaStreamType.Video).Select(MediaStreamHelper.GetVideoCodecIconName).Where(c => c != null).Select(c => c!).Distinct().ToList())); 156 | 157 | if (runAllChecks || requestedChecks.Contains(CheckNames.AudioChannels)) 158 | report.Checks.Add(CheckProperty(baseItems, "Audio Channels", ep => { 159 | var stream = ep.GetMediaStreams().Where(s => s.Type == MediaStreamType.Audio).OrderByDescending(s => s.Channels).FirstOrDefault(); 160 | var channelName = stream != null ? MediaStreamHelper.GetChannelIconName(stream) : null; 161 | return channelName != null ? new List { channelName } : new List(); 162 | })); 163 | 164 | if (runAllChecks || requestedChecks.Contains(CheckNames.Resolution)) 165 | report.Checks.Add(CheckProperty(baseItems, "Resolution", ep => { 166 | var stream = ep.GetMediaStreams().FirstOrDefault(s => s.Type == MediaStreamType.Video); 167 | var resName = stream != null ? MediaStreamHelper.GetResolutionIconNameFromStream(stream, knownResolutions) : null; 168 | return resName != null ? new List { resName } : new List(); 169 | })); 170 | 171 | if (runAllChecks || requestedChecks.Contains(CheckNames.AspectRatio)) 172 | report.Checks.Add(CheckProperty(baseItems, "Aspect Ratio", ep => { 173 | var stream = ep.GetMediaStreams().FirstOrDefault(s => s.Type == MediaStreamType.Video); 174 | var arName = stream != null ? MediaStreamHelper.GetAspectRatioIconName(stream, true) : null; 175 | return arName != null ? new List { arName } : new List(); 176 | })); 177 | 178 | if (runAllChecks || requestedChecks.Contains(CheckNames.VideoFormat)) 179 | report.Checks.Add(CheckProperty(baseItems, "Video Format (HDR)", ep => { 180 | var formatName = MediaStreamHelper.GetVideoFormatIconName(ep, ep.GetMediaStreams()); 181 | return formatName != null ? new List { formatName } : new List(); 182 | })); 183 | 184 | return report; 185 | } 186 | 187 | private CheckResult CheckProperty(List episodes, string checkName, Func> valueExtractor) 188 | { 189 | var checkResult = new CheckResult { CheckName = checkName }; 190 | 191 | var valuesByEpisode = episodes 192 | .Select(ep => new { Episode = ep, Values = valueExtractor(ep)?.Where(v => v != null).Select(v => v.ToLowerInvariant()).OrderBy(v => v).ToList() ?? new List() }) 193 | .ToList(); 194 | 195 | if (!valuesByEpisode.Any()) 196 | { 197 | checkResult.Status = "OK"; 198 | checkResult.Message = "No episodes found to check."; 199 | return checkResult; 200 | } 201 | 202 | var valueGroups = valuesByEpisode 203 | .GroupBy(x => string.Join(",", x.Values)) 204 | .OrderByDescending(g => g.Count()) 205 | .ToList(); 206 | 207 | if (valueGroups.Count <= 1) 208 | { 209 | checkResult.Status = "OK"; 210 | checkResult.DominantValues = valueGroups.FirstOrDefault()?.Key.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).ToList() ?? new List(); 211 | checkResult.Message = $"All {episodes.Count} episodes are consistent."; 212 | return checkResult; 213 | } 214 | 215 | var dominantGroup = valueGroups.First(); 216 | checkResult.DominantValues = dominantGroup.Key.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).ToList(); 217 | 218 | var mismatchedEpisodes = valuesByEpisode.Except(dominantGroup).ToList(); 219 | 220 | checkResult.Status = "Mismatch"; 221 | checkResult.Message = $"{dominantGroup.Count()} of {episodes.Count} episodes have the dominant value(s); {mismatchedEpisodes.Count} are different."; 222 | checkResult.MismatchedEpisodes = mismatchedEpisodes.Select(me => 223 | { 224 | var episodeItem = me.Episode as Episode; 225 | return new MismatchedEpisodeInfo 226 | { 227 | EpisodeId = me.Episode.Id.ToString(), 228 | EpisodeName = episodeItem != null ? GetEpisodeName(episodeItem) : "Unknown Episode", 229 | Actual = me.Values 230 | }; 231 | }).ToList(); 232 | 233 | return checkResult; 234 | } 235 | 236 | private string GetEpisodeName(Episode ep) 237 | { 238 | var season = ep.Parent as Season; 239 | string name = ""; 240 | 241 | if (ep.IndexNumber.HasValue && season?.IndexNumber.HasValue == true) 242 | { 243 | name = $"S{season.IndexNumber:D2}E{ep.IndexNumber:D2}"; 244 | } 245 | else if (ep.IndexNumber.HasValue) 246 | { 247 | name = $"Episode {ep.IndexNumber}"; 248 | } 249 | 250 | if (!string.IsNullOrEmpty(ep.Name) && ep.Name != name) 251 | { 252 | name = name != "" ? $"{name} - {ep.Name}" : ep.Name; 253 | } 254 | 255 | return string.IsNullOrEmpty(name) ? $"Item ID: {ep.Id}" : name; 256 | } 257 | } 258 | } -------------------------------------------------------------------------------- /Services/IconManagerService.cs: -------------------------------------------------------------------------------- 1 | using EmbyIcons.Api; 2 | using EmbyIcons.Caching; 3 | using EmbyIcons.Configuration; 4 | using EmbyIcons.Helpers; 5 | using MediaBrowser.Controller.Entities; 6 | using MediaBrowser.Controller.Library; 7 | using MediaBrowser.Controller.Net; 8 | using MediaBrowser.Model.Entities; 9 | using MediaBrowser.Model.Querying; 10 | using MediaBrowser.Model.Services; 11 | using System; 12 | using System.Collections.Concurrent; 13 | using System.Collections.Generic; 14 | using System.Linq; 15 | using System.Threading.Tasks; 16 | 17 | namespace EmbyIcons.Services 18 | { 19 | [Route(ApiRoutes.IconManagerReport, "GET", Summary = "Generates a report of used, missing, and unused icons")] 20 | public class GetIconManagerReport : IReturn { } 21 | 22 | public class IconManagerReport 23 | { 24 | public Dictionary Groups { get; set; } = new Dictionary(); 25 | public DateTime ReportDate { get; set; } 26 | } 27 | 28 | public class IconGroupReport 29 | { 30 | public List FoundInLibrary { get; set; } = new List(); 31 | public List FoundInFolder { get; set; } = new List(); 32 | } 33 | 34 | public class IconManagerService : IService 35 | { 36 | private readonly ILibraryManager _libraryManager; 37 | private readonly IconCacheManager _iconCacheManager; 38 | 39 | private static IconManagerReport? _cachedReport; 40 | private static readonly object _cacheLock = new object(); 41 | 42 | public IconManagerService(ILibraryManager libraryManager) 43 | { 44 | _libraryManager = libraryManager; 45 | _iconCacheManager = Plugin.Instance?.Enhancer._iconCacheManager ?? throw new InvalidOperationException("IconCacheManager not available"); 46 | } 47 | 48 | public object Get(GetIconManagerReport request) 49 | { 50 | lock (_cacheLock) 51 | { 52 | if (_cachedReport != null) 53 | { 54 | return _cachedReport; 55 | } 56 | } 57 | 58 | ScanProgressService.ClearProgress("IconManager"); 59 | var report = GenerateReport(); 60 | 61 | lock (_cacheLock) 62 | { 63 | _cachedReport = report; 64 | } 65 | 66 | ScanProgressService.ClearProgress("IconManager"); 67 | return report; 68 | } 69 | 70 | public static void InvalidateCache() 71 | { 72 | lock (_cacheLock) 73 | { 74 | _cachedReport = null; 75 | Plugin.Instance?.Logger.Info("[EmbyIcons] Icon Manager report cache invalidated."); 76 | } 77 | } 78 | 79 | private class LocalItemReport 80 | { 81 | public HashSet Languages { get; } = new(StringComparer.OrdinalIgnoreCase); 82 | public HashSet Subtitles { get; } = new(StringComparer.OrdinalIgnoreCase); 83 | public HashSet Channels { get; } = new(StringComparer.OrdinalIgnoreCase); 84 | public HashSet AudioCodecs { get; } = new(StringComparer.OrdinalIgnoreCase); 85 | public HashSet VideoCodecs { get; } = new(StringComparer.OrdinalIgnoreCase); 86 | public HashSet VideoFormats { get; } = new(StringComparer.OrdinalIgnoreCase); 87 | public HashSet Resolutions { get; } = new(StringComparer.OrdinalIgnoreCase); 88 | public HashSet AspectRatios { get; } = new(StringComparer.OrdinalIgnoreCase); 89 | public HashSet Tags { get; } = new(StringComparer.OrdinalIgnoreCase); 90 | public HashSet ParentalRatings { get; } = new(StringComparer.OrdinalIgnoreCase); 91 | } 92 | 93 | private IconManagerReport GenerateReport() 94 | { 95 | var pluginInstance = Plugin.Instance; 96 | if (pluginInstance == null) 97 | { 98 | return new IconManagerReport { ReportDate = DateTime.UtcNow }; 99 | } 100 | 101 | pluginInstance.Logger.Info("[EmbyIcons] Generating new Icon Manager report..."); 102 | var options = pluginInstance.GetConfiguredOptions(); 103 | 104 | var allItems = _libraryManager.GetItemList(new InternalItemsQuery 105 | { 106 | IncludeItemTypes = new[] { "Movie", Constants.Episode, "Series" }, 107 | IsVirtualItem = false, 108 | Recursive = true 109 | }); 110 | 111 | int totalItems = allItems.Count(); 112 | int processedCount = 0; 113 | 114 | var customIcons = _iconCacheManager.GetAllAvailableIconKeys(options.IconsFolder); 115 | customIcons.TryGetValue(IconCacheManager.IconType.Resolution, out var knownResolutions); 116 | knownResolutions ??= new List(); 117 | 118 | var finalReportData = allItems 119 | .AsParallel() 120 | .WithDegreeOfParallelism(Environment.ProcessorCount) 121 | .Select(item => 122 | { 123 | var localReport = new LocalItemReport(); 124 | var streams = item.GetMediaStreams() ?? new List(); 125 | 126 | var rating = MediaStreamHelper.GetParentalRatingIconName(item.OfficialRating); 127 | if (rating != null) localReport.ParentalRatings.Add(rating); 128 | 129 | if (item.Tags != null) 130 | { 131 | foreach (var tag in item.Tags) localReport.Tags.Add(tag); 132 | } 133 | 134 | if (!streams.Any()) return localReport; 135 | 136 | var format = MediaStreamHelper.GetVideoFormatIconName(item, streams); 137 | if (format != null) localReport.VideoFormats.Add(format); 138 | 139 | var primaryAudio = streams.Where(s => s.Type == MediaStreamType.Audio).OrderByDescending(s => s.Channels).FirstOrDefault(); 140 | if (primaryAudio != null) 141 | { 142 | var ch = MediaStreamHelper.GetChannelIconName(primaryAudio); 143 | if (ch != null) localReport.Channels.Add(ch); 144 | } 145 | 146 | foreach (var stream in streams) 147 | { 148 | switch (stream.Type) 149 | { 150 | case MediaStreamType.Audio: 151 | if (!string.IsNullOrEmpty(stream.DisplayLanguage)) localReport.Languages.Add(LanguageHelper.NormalizeLangCode(stream.DisplayLanguage)); 152 | var audioCodec = MediaStreamHelper.GetAudioCodecIconName(stream); 153 | if (audioCodec != null) localReport.AudioCodecs.Add(audioCodec); 154 | break; 155 | case MediaStreamType.Subtitle: 156 | if (!string.IsNullOrEmpty(stream.DisplayLanguage)) localReport.Subtitles.Add(LanguageHelper.NormalizeLangCode(stream.DisplayLanguage)); 157 | break; 158 | case MediaStreamType.Video: 159 | var videoCodec = MediaStreamHelper.GetVideoCodecIconName(stream); 160 | if (videoCodec != null) localReport.VideoCodecs.Add(videoCodec); 161 | var res = MediaStreamHelper.GetResolutionIconNameFromStream(stream, knownResolutions); 162 | if (res != null) localReport.Resolutions.Add(res); 163 | var ar = MediaStreamHelper.GetAspectRatioIconName(stream, true); 164 | if (ar != null) localReport.AspectRatios.Add(ar); 165 | break; 166 | } 167 | } 168 | 169 | var newCount = System.Threading.Interlocked.Increment(ref processedCount); 170 | if (newCount % 200 == 0) // Update progress every 200 items 171 | { 172 | ScanProgressService.UpdateProgress("IconManager", newCount, totalItems, $"Scanning item {newCount} of {totalItems}..."); 173 | } 174 | 175 | return localReport; 176 | }) 177 | .Aggregate( 178 | seedFactory: () => new LocalItemReport(), 179 | updateAccumulatorFunc: (threadReport, itemReport) => 180 | { 181 | threadReport.Languages.UnionWith(itemReport.Languages); 182 | threadReport.Subtitles.UnionWith(itemReport.Subtitles); 183 | threadReport.Channels.UnionWith(itemReport.Channels); 184 | threadReport.AudioCodecs.UnionWith(itemReport.AudioCodecs); 185 | threadReport.VideoCodecs.UnionWith(itemReport.VideoCodecs); 186 | threadReport.VideoFormats.UnionWith(itemReport.VideoFormats); 187 | threadReport.Resolutions.UnionWith(itemReport.Resolutions); 188 | threadReport.AspectRatios.UnionWith(itemReport.AspectRatios); 189 | threadReport.Tags.UnionWith(itemReport.Tags); 190 | threadReport.ParentalRatings.UnionWith(itemReport.ParentalRatings); 191 | return threadReport; 192 | }, 193 | combineAccumulatorsFunc: (mainReport, threadReport) => 194 | { 195 | mainReport.Languages.UnionWith(threadReport.Languages); 196 | mainReport.Subtitles.UnionWith(threadReport.Subtitles); 197 | mainReport.Channels.UnionWith(threadReport.Channels); 198 | mainReport.AudioCodecs.UnionWith(threadReport.AudioCodecs); 199 | mainReport.VideoCodecs.UnionWith(threadReport.VideoCodecs); 200 | mainReport.VideoFormats.UnionWith(threadReport.VideoFormats); 201 | mainReport.Resolutions.UnionWith(threadReport.Resolutions); 202 | mainReport.AspectRatios.UnionWith(threadReport.AspectRatios); 203 | mainReport.Tags.UnionWith(threadReport.Tags); 204 | mainReport.ParentalRatings.UnionWith(threadReport.ParentalRatings); 205 | return mainReport; 206 | }, 207 | resultSelector: finalReport => finalReport); 208 | 209 | var report = new IconManagerReport { ReportDate = DateTime.UtcNow }; 210 | 211 | report.Groups[IconCacheManager.IconType.Language.ToString()] = new IconGroupReport { FoundInLibrary = finalReportData.Languages.OrderBy(p => p).ToList(), FoundInFolder = customIcons.GetValueOrDefault(IconCacheManager.IconType.Language, new List()) }; 212 | report.Groups[IconCacheManager.IconType.Subtitle.ToString()] = new IconGroupReport { FoundInLibrary = finalReportData.Subtitles.OrderBy(p => p).ToList(), FoundInFolder = customIcons.GetValueOrDefault(IconCacheManager.IconType.Subtitle, new List()) }; 213 | report.Groups[IconCacheManager.IconType.Channel.ToString()] = new IconGroupReport { FoundInLibrary = finalReportData.Channels.OrderBy(p => p).ToList(), FoundInFolder = customIcons.GetValueOrDefault(IconCacheManager.IconType.Channel, new List()) }; 214 | report.Groups[IconCacheManager.IconType.AudioCodec.ToString()] = new IconGroupReport { FoundInLibrary = finalReportData.AudioCodecs.OrderBy(p => p).ToList(), FoundInFolder = customIcons.GetValueOrDefault(IconCacheManager.IconType.AudioCodec, new List()) }; 215 | report.Groups[IconCacheManager.IconType.VideoCodec.ToString()] = new IconGroupReport { FoundInLibrary = finalReportData.VideoCodecs.OrderBy(p => p).ToList(), FoundInFolder = customIcons.GetValueOrDefault(IconCacheManager.IconType.VideoCodec, new List()) }; 216 | report.Groups[IconCacheManager.IconType.VideoFormat.ToString()] = new IconGroupReport { FoundInLibrary = finalReportData.VideoFormats.OrderBy(p => p).ToList(), FoundInFolder = customIcons.GetValueOrDefault(IconCacheManager.IconType.VideoFormat, new List()) }; 217 | report.Groups[IconCacheManager.IconType.Resolution.ToString()] = new IconGroupReport { FoundInLibrary = finalReportData.Resolutions.OrderBy(p => p).ToList(), FoundInFolder = customIcons.GetValueOrDefault(IconCacheManager.IconType.Resolution, new List()) }; 218 | report.Groups[IconCacheManager.IconType.AspectRatio.ToString()] = new IconGroupReport { FoundInLibrary = finalReportData.AspectRatios.OrderBy(p => p).ToList(), FoundInFolder = customIcons.GetValueOrDefault(IconCacheManager.IconType.AspectRatio, new List()) }; 219 | report.Groups[IconCacheManager.IconType.Tag.ToString()] = new IconGroupReport { FoundInLibrary = finalReportData.Tags.OrderBy(p => p).ToList(), FoundInFolder = customIcons.GetValueOrDefault(IconCacheManager.IconType.Tag, new List()) }; 220 | report.Groups[IconCacheManager.IconType.ParentalRating.ToString()] = new IconGroupReport { FoundInLibrary = finalReportData.ParentalRatings.OrderBy(p => p).ToList(), FoundInFolder = customIcons.GetValueOrDefault(IconCacheManager.IconType.ParentalRating, new List()) }; 221 | 222 | pluginInstance.Logger.Info("[EmbyIcons] Icon Manager report generation complete."); 223 | return report; 224 | } 225 | } 226 | } --------------------------------------------------------------------------------