├── .github └── workflows │ └── build.yml ├── .gitignore ├── LICENSE ├── README.en.md ├── README.md ├── StrmAssistant.sln ├── StrmAssistant ├── Common │ ├── ChapterApi.cs │ ├── CommonUtility.cs │ ├── FingerprintApi.cs │ ├── LanguageUtility.cs │ ├── LibraryApi.cs │ ├── LruCache.cs │ ├── MediaInfoApi.cs │ ├── MetadataApi.cs │ ├── NotificationApi.cs │ ├── QueueManager.cs │ ├── SubtitleApi.cs │ └── VideoThumbnailApi.cs ├── IntroSkip │ ├── PlaySessionData.cs │ └── PlaySessionMonitor.cs ├── Notification │ └── CustomNotifications.cs ├── Options │ ├── AboutOptions.cs │ ├── ExperienceEnhanceOptions.cs │ ├── GeneralOptions.cs │ ├── IntroSkipOptions.cs │ ├── MediaInfoExtractOptions.cs │ ├── MetadataEnhanceOptions.cs │ ├── OptionUtility.cs │ └── PluginOptions.cs ├── Plugin.cs ├── Properties │ ├── Resources.Designer.cs │ ├── Resources.resx │ ├── Resources.zh-hant.resx │ ├── Resources.zh.resx │ ├── launchSettings.json │ └── thumb.png ├── ScheduledTask │ ├── ClearChapterMarkersTask.cs │ ├── DeletePersonTask.cs │ ├── ExtractIntroFingerprintTask.cs │ ├── ExtractMediaInfoTask.cs │ ├── ExtractVideoThumbnailTask.cs │ ├── MergeMultiVersionTask.cs │ ├── PersistMediaInfoTask.cs │ ├── RefreshEpisodeTask.cs │ ├── RefreshPersonTask.cs │ ├── ScanExternalSubtitleTask.cs │ └── UpdatePluginTask.cs ├── StrmAssistant.csproj └── Web │ ├── Api │ ├── ClearIntro.cs │ ├── CopyVirtualFolder.cs │ ├── DeleteVersion.cs │ ├── GetShortcutMenu.cs │ ├── GetStrmAssistantJs.cs │ └── LockItem.cs │ ├── Helper │ └── ShortcutMenuHelper.cs │ ├── Resources │ ├── shortcuts.js │ └── strmassistant.js │ └── Service │ ├── ChapterService.cs │ ├── ItemService.cs │ ├── LibraryService.cs │ ├── LibraryStructureService.cs │ └── ShortcutMenuService.cs └── donate.png /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build Plugin 2 | 3 | on: 4 | push: 5 | branches: 6 | - tab-ui 7 | - simple-ui 8 | - lite 9 | pull_request: 10 | branches: 11 | - tab-ui 12 | - simple-ui 13 | - lite 14 | 15 | jobs: 16 | build: 17 | runs-on: windows-latest 18 | 19 | steps: 20 | - name: Checkout code 21 | uses: actions/checkout@v3 22 | 23 | - name: Setup .NET 24 | uses: actions/setup-dotnet@v3 25 | with: 26 | dotnet-version: '6.x' 27 | 28 | - name: Restore dependencies 29 | run: dotnet restore 30 | 31 | - name: Build the project 32 | run: dotnet build --no-restore --configuration Release 33 | 34 | - name: Upload artifact 35 | uses: actions/upload-artifact@v4 36 | with: 37 | name: StrmAssistant 38 | path: C:\Users\runneradmin\AppData\Roaming\Emby-Server\programdata\plugins\StrmAssistant${{ github.ref_name == 'lite' && 'Lite' || '' }}.dll 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vs 2 | *.suo 3 | *.user 4 | *.sln.docstates 5 | 6 | [Dd]ebug/ 7 | [Rr]elease/ 8 | x64/ 9 | build/ 10 | [Bb]in/ 11 | [Oo]bj/ 12 | -------------------------------------------------------------------------------- /README.en.md: -------------------------------------------------------------------------------- 1 | # Strm Assistant 2 | 3 | ![logo](StrmAssistant/Properties/thumb.png "logo") 4 | 5 | ## Purpose 6 | 7 | 1. Improve initial playback start speed 8 | 2. Image capture and thumbnail preview enhanced 9 | 3. Playback behavior-based intro and credits detection 10 | 4. Independent external subtitle scan 11 | 12 | ## Update 13 | 14 | 1. Support concurrent tasks 15 | 2. Support non-strm media imported with ffprobe blocked 16 | 3. Include media extras 17 | 4. Process media items by release date in the descending order 18 | 5. Add plugin config page with library multi-selection 19 | 6. Image capture enhanced 20 | 7. Introduce catch-up mode 21 | 8. Playback behavior-based intro and credits detection for episodes 22 | 9. Independent external subtitle scan 23 | 24 | ## Install 25 | 26 | 1. Download `StrmAssistant.dll` to the `plugins` folder 27 | 2. Restart Emby 28 | 3. Go to the Plugins page and check the plugin version and settings 29 | 30 | **Note**: The minimum required Emby version is 4.8.5.0. 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Emby神医助手 2 | 3 | ![logo](StrmAssistant/Properties/thumb.png "logo") 4 | 5 | ## [[English]](README.en.md) 6 | 7 | ## 用途 8 | 9 | 1. 提高首次播放的起播速度 10 | 2. 视频截图预览缩略图增强 11 | 3. 片头片尾探测增强 12 | 4. 自动合并同目录视频为多版本 13 | 5. 独占模式提取媒体信息 14 | 6. 独立的外挂字幕扫描 15 | 7. 自定义刮削备选语言 16 | 8. 使用替代`TMDB`配置 17 | 9. 演职人员增强`TMDB` 18 | 10. 获取原语言海报 19 | 11. 中文搜索增强 20 | 12. 拼音首字母排序 21 | 13. 媒体信息持久化 22 | 14. 支持代理服务器 23 | 15. 支持`TMDB`剧集组刮削 24 | 25 | ## 安装与使用说明请查看 [Wiki](https://github.com/sjtuross/StrmAssistant/wiki) 26 | 27 | ## 赞赏 28 | 29 | 如果这个项目对你有帮助,不妨请我喝杯咖啡。如果你欣赏这个项目,欢迎为它点亮一颗⭐️。感谢你对开源精神的认可与支持! 30 | 31 | ![donate](donate.png "donate") 32 | 33 | ## 声明 34 | 35 | 本项目为开源项目,与 Emby LLC 没有任何关联,也未获得 Emby LLC 的授权或认可。本项目的目的是为合法购买并安装了 Emby 软件的用户提供额外的功能增强和使用便利。 36 | 37 | ### 使用须知 38 | 39 | 1. **合法使用** 40 | 本项目仅适用于合法安装和使用 Emby 软件的用户。使用本项目时,用户需自行确保遵守 Emby 软件的服务条款和使用许可协议。 41 | 42 | 2. **非商业用途** 43 | 本项目完全免费,仅限个人学习、研究和非商业用途。严禁将本项目或其衍生版本用于任何商业用途。 44 | 45 | 3. **不包含 Emby 专有组件** 46 | 本项目未包含 Emby 软件的任何专有组件(例如:DLL 文件、代码、图标或其他版权资源)。使用本项目不会直接修改或分发 Emby 软件本身。 47 | 48 | 4. **功能限制** 49 | 本项目不会绕过 Emby 的授权机制、数字版权保护 (DRM),或以任何方式解锁其付费功能。本项目仅在运行时动态注入代码,且不会篡改 Emby 软件的核心功能。 50 | 51 | 5. **用户责任** 52 | 用户在使用本项目时,需自行承担遵守相关法律法规的责任。如果用户使用本项目违反了 Emby 的服务条款或相关法律法规,本项目开发者概不负责。 53 | 54 | ### 免责声明 55 | 56 | 1. 本项目开发者不对因使用本项目而可能导致的任何直接或间接后果(包括但不限于数据丢失、软件故障或法律纠纷)负责。 57 | 2. 如果认为本项目可能侵犯相关方的合法权益,请与开发者取得联系。 58 | 59 | ### 星星数 60 | 61 | [![Star History Chart](https://api.star-history.com/svg?repos=sjtuross/strmassistant&type=Date)](https://www.star-history.com/#sjtuross/strmassistant&Date) 62 | -------------------------------------------------------------------------------- /StrmAssistant.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.8.34330.188 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "StrmAssistant", "StrmAssistant\StrmAssistant.csproj", "{E0CEFE4D-2138-42CB-A03F-11E7991BF7A9}" 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 | {E0CEFE4D-2138-42CB-A03F-11E7991BF7A9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 15 | {E0CEFE4D-2138-42CB-A03F-11E7991BF7A9}.Debug|Any CPU.Build.0 = Debug|Any CPU 16 | {E0CEFE4D-2138-42CB-A03F-11E7991BF7A9}.Release|Any CPU.ActiveCfg = Release|Any CPU 17 | {E0CEFE4D-2138-42CB-A03F-11E7991BF7A9}.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 = {B21B91C2-6AA0-44B0-9DD0-700D48445883} 24 | EndGlobalSection 25 | EndGlobal 26 | -------------------------------------------------------------------------------- /StrmAssistant/Common/CommonUtility.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics; 4 | using System.IO; 5 | using System.Linq; 6 | using System.Net; 7 | using System.Net.Http; 8 | using System.Net.Sockets; 9 | using System.Security.Cryptography; 10 | using System.Text; 11 | using System.Text.RegularExpressions; 12 | using System.Threading.Tasks; 13 | 14 | namespace StrmAssistant.Common 15 | { 16 | public static class CommonUtility 17 | { 18 | private static readonly Regex MovieDbApiKeyRegex = new Regex("^[a-fA-F0-9]{32}$", RegexOptions.Compiled); 19 | 20 | public static bool IsValidHttpUrl(string url) 21 | { 22 | if (string.IsNullOrWhiteSpace(url)) return false; 23 | 24 | if (Uri.TryCreate(url, UriKind.Absolute, out var uriResult)) 25 | { 26 | return uriResult.Scheme == Uri.UriSchemeHttp || uriResult.Scheme == Uri.UriSchemeHttps; 27 | } 28 | 29 | return false; 30 | } 31 | 32 | public static bool IsValidMovieDbApiKey(string apiKey) 33 | { 34 | return !string.IsNullOrWhiteSpace(apiKey) && MovieDbApiKeyRegex.IsMatch(apiKey); 35 | } 36 | 37 | public static bool IsValidProxyUrl(string proxyUrl) 38 | { 39 | try 40 | { 41 | var uri = new Uri(proxyUrl); 42 | return (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps) && 43 | (uri.IsDefaultPort || (uri.Port > 0 && uri.Port <= 65535)) && 44 | (string.IsNullOrEmpty(uri.UserInfo) || uri.UserInfo.Contains(":")); 45 | } 46 | catch 47 | { 48 | return false; 49 | } 50 | } 51 | 52 | public static bool TryParseProxyUrl(string proxyUrl, out string schema, out string host, out int port, out string username, out string password) 53 | { 54 | schema = string.Empty; 55 | host = string.Empty; 56 | port = 0; 57 | username = string.Empty; 58 | password = string.Empty; 59 | 60 | try 61 | { 62 | var uri = new Uri(proxyUrl); 63 | if (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps) 64 | { 65 | schema = uri.Scheme; 66 | host = uri.Host; 67 | port = uri.IsDefaultPort ? uri.Scheme == Uri.UriSchemeHttp ? 80 : 443 : uri.Port; 68 | 69 | if (!string.IsNullOrEmpty(uri.UserInfo)) 70 | { 71 | var userInfoParts = uri.UserInfo.Split(':'); 72 | username = userInfoParts[0]; 73 | password = userInfoParts.Length > 1 ? userInfoParts[1] : string.Empty; 74 | } 75 | 76 | return true; 77 | } 78 | } 79 | catch 80 | { 81 | // ignored 82 | } 83 | 84 | return false; 85 | } 86 | 87 | public static (bool isReachable, double? tcpPing) CheckProxyReachability(string host, int port) 88 | { 89 | try 90 | { 91 | using var tcpClient = new TcpClient(); 92 | var stopwatch = Stopwatch.StartNew(); 93 | if (tcpClient.ConnectAsync(host, port).Wait(999)) 94 | { 95 | stopwatch.Stop(); 96 | return (true, stopwatch.Elapsed.TotalMilliseconds); 97 | } 98 | } 99 | catch 100 | { 101 | // ignored 102 | } 103 | 104 | return (false, null); 105 | } 106 | 107 | public static (bool isReachable, double? httpPing) CheckProxyReachability(string scheme, string host, int port, 108 | string username, string password) 109 | { 110 | double? httpPing = null; 111 | 112 | try 113 | { 114 | var proxyUrl = new UriBuilder(scheme, host, port).Uri; 115 | using var handler = new HttpClientHandler(); 116 | handler.Proxy = new WebProxy(proxyUrl) 117 | { 118 | Credentials = !string.IsNullOrEmpty(username) && !string.IsNullOrEmpty(password) 119 | ? new NetworkCredential(username, password) 120 | : null 121 | }; 122 | handler.UseProxy = true; 123 | 124 | using var client = new HttpClient(handler); 125 | client.Timeout = TimeSpan.FromMilliseconds(666); 126 | 127 | var task1 = client.GetAsync("http://www.gstatic.com/generate_204"); 128 | var task2 = client.GetAsync("http://www.google.com/generate_204"); 129 | 130 | var stopwatch = Stopwatch.StartNew(); 131 | var completedTask = Task.WhenAny(task1, task2).Result; 132 | stopwatch.Stop(); 133 | 134 | if (completedTask.Status == TaskStatus.RanToCompletion && completedTask.Result.IsSuccessStatusCode && 135 | completedTask.Result.StatusCode == HttpStatusCode.NoContent) 136 | { 137 | httpPing = stopwatch.Elapsed.TotalMilliseconds; 138 | } 139 | else 140 | { 141 | var otherTask = completedTask == task1 ? task2 : task1; 142 | if (otherTask.Status == TaskStatus.RanToCompletion && otherTask.Result.IsSuccessStatusCode && 143 | otherTask.Result.StatusCode == HttpStatusCode.NoContent) 144 | { 145 | httpPing = stopwatch.Elapsed.TotalMilliseconds; 146 | } 147 | } 148 | } 149 | catch 150 | { 151 | // ignored 152 | } 153 | 154 | return (httpPing.HasValue, httpPing); 155 | } 156 | 157 | public static string GenerateFixedCode(string input, string prefix, int length) 158 | { 159 | using var md5 = MD5.Create(); 160 | var hash = md5.ComputeHash(Encoding.UTF8.GetBytes(input)); 161 | return prefix + BitConverter.ToString(hash).Replace("-", "").Substring(0, length).ToLower(); 162 | } 163 | 164 | public static long Find(long x, Dictionary parent) 165 | { 166 | if (parent[x] == x) return x; 167 | return parent[x] = Find(parent[x], parent); 168 | } 169 | 170 | public static void Union(long x, long y, Dictionary parent) 171 | { 172 | var root1 = Find(x, parent); 173 | var root2 = Find(y, parent); 174 | if (root1 != root2) parent[root1] = root2; 175 | } 176 | 177 | public static bool IsDirectoryEmpty(string directoryPath) 178 | { 179 | if (!Directory.Exists(directoryPath)) 180 | return false; 181 | 182 | var entries = Directory.EnumerateFileSystemEntries(directoryPath).Take(1); 183 | 184 | return !entries.Any(); 185 | } 186 | 187 | public static double LevenshteinDistance(string str1, string str2) 188 | { 189 | int n = str1.Length; 190 | int m = str2.Length; 191 | int[,] d = new int[n + 1, m + 1]; 192 | 193 | for (int i = 0; i <= n; d[i, 0] = i++) ; 194 | for (int j = 0; j <= m; d[0, j] = j++) ; 195 | 196 | for (int i = 1; i <= n; i++) 197 | { 198 | for (int j = 1; j <= m; j++) 199 | { 200 | int cost = (str1[i - 1] == str2[j - 1]) ? 0 : 1; 201 | d[i, j] = Math.Min(Math.Min(d[i - 1, j] + 1, d[i, j - 1] + 1), d[i - 1, j - 1] + cost); 202 | } 203 | } 204 | 205 | int levenshteinDistance = d[n, m]; 206 | double similarity = 1.0 - (levenshteinDistance / (double)Math.Max(str1.Length, str2.Length)); 207 | 208 | return similarity; 209 | } 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /StrmAssistant/Common/LanguageUtility.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.International.Converters.TraditionalChineseToSimplifiedConverter; 2 | using System.Text.RegularExpressions; 3 | 4 | namespace StrmAssistant.Common 5 | { 6 | public static class LanguageUtility 7 | { 8 | private static readonly Regex EnglishRegex = new Regex(@"^[\x00-\x7F]+$", RegexOptions.Compiled); 9 | private static readonly Regex ChineseRegex = new Regex(@"[\u4E00-\u9FFF]", RegexOptions.Compiled); 10 | private static readonly Regex JapaneseRegex = new Regex(@"[\u3040-\u30FF]", RegexOptions.Compiled); 11 | private static readonly Regex KoreanRegex = new Regex(@"[\uAC00-\uD7A3]", RegexOptions.Compiled); 12 | private static readonly Regex DefaultEnglishEpisodeNameRegex = new Regex(@"^Episode\s*\d+$", RegexOptions.Compiled); 13 | private static readonly Regex DefaultChineseEpisodeNameRegex = new Regex(@"^第\s*\d+\s*集$", RegexOptions.Compiled); 14 | private static readonly Regex DefaultJapaneseEpisodeNameRegex = new Regex(@"^第\s*\d+\s*話$", RegexOptions.Compiled); 15 | private static readonly Regex DefaultChineseCollectionNameRegex = new Regex(@"(系列)$", RegexOptions.Compiled); 16 | private static readonly Regex CleanPersonNameRegex = new Regex(@"\s+", RegexOptions.Compiled); 17 | private static readonly Regex CleanEpisodeNameRegex = 18 | new Regex(@"(\.)(?:S[0-9]+[eE][0-9]+|[sS][0-9]+[xX][0-9]+|[sS][0-9]+[-_][0-9]+|第[0-9一二三四五六七八九十百]+集)?", 19 | RegexOptions.Compiled); 20 | 21 | public static readonly string[] MovieDbFallbackLanguages = { "zh-CN", "zh-SG", "zh-HK", "zh-TW", "ja-JP" }; 22 | public static readonly string[] TvdbFallbackLanguages = { "zho", "zhtw", "yue", "jpn" }; 23 | 24 | public static bool IsEnglish(string input) => !string.IsNullOrEmpty(input) && EnglishRegex.IsMatch(input); 25 | 26 | public static bool IsChinese(string input) => !string.IsNullOrEmpty(input) && ChineseRegex.IsMatch(input) && 27 | !JapaneseRegex.IsMatch(input.Replace("\u30FB", string.Empty)); 28 | 29 | public static bool IsJapanese(string input) => !string.IsNullOrEmpty(input) && 30 | JapaneseRegex.IsMatch(input.Replace("\u30FB", string.Empty)); 31 | 32 | public static bool IsChineseJapanese(string input) => !string.IsNullOrEmpty(input) && 33 | (ChineseRegex.IsMatch(input) || 34 | JapaneseRegex.IsMatch(input.Replace("\u30FB", 35 | string.Empty))); 36 | 37 | public static bool IsKorean(string input) => !string.IsNullOrEmpty(input) && KoreanRegex.IsMatch(input); 38 | 39 | public static bool IsDefaultEnglishEpisodeName(string input) => 40 | !string.IsNullOrEmpty(input) && DefaultEnglishEpisodeNameRegex.IsMatch(input); 41 | 42 | public static bool IsDefaultChineseEpisodeName(string input) => 43 | !string.IsNullOrEmpty(input) && DefaultChineseEpisodeNameRegex.IsMatch(input); 44 | 45 | public static bool IsDefaultJapaneseEpisodeName(string input) => 46 | !string.IsNullOrEmpty(input) && DefaultJapaneseEpisodeNameRegex.IsMatch(input); 47 | 48 | public static string ConvertTraditionalToSimplified(string input) 49 | { 50 | return ChineseConverter.Convert(input, ChineseConversionDirection.TraditionalToSimplified); 51 | } 52 | 53 | public static string GetLanguageByTitle(string input) 54 | { 55 | if (string.IsNullOrEmpty(input)) return null; 56 | 57 | return IsJapanese(input) ? "ja" : IsKorean(input) ? "ko" : IsChinese(input) ? "zh" : "en"; 58 | } 59 | 60 | public static string RemoveDefaultCollectionName(string input) 61 | { 62 | return string.IsNullOrEmpty(input) ? input : DefaultChineseCollectionNameRegex.Replace(input, "").Trim(); 63 | } 64 | 65 | public static string CleanPersonName(string input) 66 | { 67 | if (string.IsNullOrEmpty(input)) return input; 68 | 69 | if (IsChineseJapanese(input) || IsKorean(input)) 70 | { 71 | return CleanPersonNameRegex.Replace(input, ""); 72 | } 73 | 74 | return input.Trim(); 75 | } 76 | 77 | public static string CleanEpisodeName(string input) 78 | { 79 | if (string.IsNullOrEmpty(input)) return input; 80 | 81 | return CleanEpisodeNameRegex.Replace(input, ""); 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /StrmAssistant/Common/LruCache.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading; 4 | 5 | namespace StrmAssistant.Common 6 | { 7 | public class LruCache 8 | { 9 | private readonly int _capacity; 10 | private readonly Dictionary>> _cacheMap; 11 | private readonly LinkedList> _orderList; 12 | private readonly ReaderWriterLockSlim _lock = new ReaderWriterLockSlim(); 13 | 14 | public LruCache(int capacity = 20) 15 | { 16 | _capacity = capacity; 17 | _cacheMap = new Dictionary>>(capacity, 18 | StringComparer.OrdinalIgnoreCase); 19 | _orderList = new LinkedList>(); 20 | } 21 | 22 | public void AddOrUpdateCache(string key, T value) 23 | { 24 | _lock.EnterWriteLock(); 25 | try 26 | { 27 | if (_cacheMap.TryGetValue(key, out var existingNode)) 28 | { 29 | _orderList.Remove(existingNode); 30 | } 31 | else if (_cacheMap.Count >= _capacity) 32 | { 33 | var leastUsed = _orderList.Last; 34 | _orderList.RemoveLast(); 35 | _cacheMap.Remove(leastUsed.Value.Key); 36 | } 37 | 38 | var newNode = 39 | new LinkedListNode>(new KeyValuePair(key, value)); 40 | _orderList.AddFirst(newNode); 41 | _cacheMap[key] = newNode; 42 | } 43 | finally 44 | { 45 | _lock.ExitWriteLock(); 46 | } 47 | } 48 | 49 | public bool TryGetFromCache(string key, out T value) where T : class 50 | { 51 | _lock.EnterWriteLock(); 52 | try 53 | { 54 | value = default; 55 | if (_cacheMap.TryGetValue(key, out var node)) 56 | { 57 | _orderList.Remove(node); 58 | _orderList.AddFirst(node); 59 | 60 | value = node.Value.Value as T; 61 | return true; 62 | } 63 | 64 | return false; 65 | } 66 | finally 67 | { 68 | _lock.ExitWriteLock(); 69 | } 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /StrmAssistant/Common/MetadataApi.cs: -------------------------------------------------------------------------------- 1 | using MediaBrowser.Common.Net; 2 | using MediaBrowser.Controller.Configuration; 3 | using MediaBrowser.Controller.Entities; 4 | using MediaBrowser.Controller.Entities.TV; 5 | using MediaBrowser.Controller.Library; 6 | using MediaBrowser.Controller.Providers; 7 | using MediaBrowser.Model.Entities; 8 | using MediaBrowser.Model.Globalization; 9 | using MediaBrowser.Model.IO; 10 | using MediaBrowser.Model.Logging; 11 | using MediaBrowser.Model.Querying; 12 | using MediaBrowser.Model.Serialization; 13 | using System; 14 | using System.Collections.Generic; 15 | using System.Linq; 16 | using System.Net; 17 | using System.Threading; 18 | using System.Threading.Tasks; 19 | using static StrmAssistant.Common.LanguageUtility; 20 | 21 | namespace StrmAssistant.Common 22 | { 23 | public class MetadataApi 24 | { 25 | private readonly ILogger _logger; 26 | private readonly ILibraryManager _libraryManager; 27 | private readonly IServerConfigurationManager _configurationManager; 28 | private readonly ILocalizationManager _localizationManager; 29 | private readonly IFileSystem _fileSystem; 30 | private readonly IJsonSerializer _jsonSerializer; 31 | private readonly IHttpClient _httpClient; 32 | 33 | private static readonly LruCache LruCache = new LruCache(20); 34 | private static long _lastRequestTicks; 35 | 36 | public const int RequestIntervalMs = 100; 37 | public static readonly TimeSpan DefaultCacheTime = TimeSpan.FromHours(6.0); 38 | 39 | public MetadataApi(ILibraryManager libraryManager, IFileSystem fileSystem, 40 | IServerConfigurationManager configurationManager, ILocalizationManager localizationManager, 41 | IJsonSerializer jsonSerializer, IHttpClient httpClient) 42 | { 43 | _logger = Plugin.Instance.Logger; 44 | _libraryManager = libraryManager; 45 | _configurationManager = configurationManager; 46 | _localizationManager = localizationManager; 47 | _fileSystem = fileSystem; 48 | _jsonSerializer = jsonSerializer; 49 | _httpClient = httpClient; 50 | } 51 | 52 | public MetadataRefreshOptions GetMetadataFullRefreshOptions() 53 | { 54 | return new MetadataRefreshOptions(new DirectoryService(_logger, _fileSystem)) 55 | { 56 | EnableRemoteContentProbe = false, 57 | MetadataRefreshMode = MetadataRefreshMode.FullRefresh, 58 | ReplaceAllMetadata = true, 59 | ImageRefreshMode = MetadataRefreshMode.FullRefresh, 60 | ReplaceAllImages = true, 61 | EnableThumbnailImageExtraction = false, 62 | EnableSubtitleDownloading = false 63 | }; 64 | } 65 | 66 | public MetadataRefreshOptions GetMetadataValidationRefreshOptions() 67 | { 68 | return new MetadataRefreshOptions(new DirectoryService(_logger, _fileSystem)) 69 | { 70 | EnableRemoteContentProbe = false, 71 | MetadataRefreshMode = MetadataRefreshMode.ValidationOnly, 72 | ReplaceAllMetadata = false, 73 | ImageRefreshMode = MetadataRefreshMode.ValidationOnly, 74 | ReplaceAllImages = false, 75 | EnableThumbnailImageExtraction = false, 76 | EnableSubtitleDownloading = false 77 | }; 78 | } 79 | 80 | public string GetPreferredMetadataLanguage(BaseItem item) 81 | { 82 | var libraryOptions = _libraryManager.GetLibraryOptions(item); 83 | 84 | var language = item.PreferredMetadataLanguage; 85 | if (string.IsNullOrEmpty(language)) 86 | { 87 | language = item.GetParents().Select(i => i.PreferredMetadataLanguage).FirstOrDefault(i => !string.IsNullOrEmpty(i)); 88 | } 89 | if (string.IsNullOrEmpty(language)) 90 | { 91 | language = libraryOptions.PreferredMetadataLanguage; 92 | } 93 | if (string.IsNullOrEmpty(language)) 94 | { 95 | language = _configurationManager.Configuration.PreferredMetadataLanguage; 96 | } 97 | 98 | return language; 99 | } 100 | 101 | public string GetServerPreferredMetadataLanguage() 102 | { 103 | return _configurationManager.Configuration.PreferredMetadataLanguage; 104 | } 105 | 106 | public async Task> GetPersonMetadataFromMovieDb(Person item, 107 | string preferredMetadataLanguage, IDirectoryService directoryService, 108 | CancellationToken cancellationToken) 109 | { 110 | var libraryOptions = _libraryManager.GetLibraryOptions(item); 111 | 112 | IHasLookupInfo lookupItem = item; 113 | var lookupInfo = lookupItem.GetLookupInfo(libraryOptions); 114 | lookupInfo.MetadataLanguage = preferredMetadataLanguage; 115 | 116 | if (GetMovieDbPersonProvider() is IRemoteMetadataProvider provider) 117 | { 118 | return await GetMetadataFromProvider(provider, directoryService, lookupInfo, cancellationToken) 119 | .ConfigureAwait(false); 120 | } 121 | 122 | return await Task.FromResult(new MetadataResult()).ConfigureAwait(false); 123 | } 124 | 125 | private IMetadataProvider GetMovieDbPersonProvider() 126 | { 127 | var metadataProviders = Plugin.Instance.ApplicationHost.GetExports().ToArray(); 128 | var movieDbPersonProvider = metadataProviders 129 | .FirstOrDefault(provider => provider.GetType().Name == "MovieDbPersonProvider"); 130 | 131 | return movieDbPersonProvider; 132 | } 133 | 134 | private Task> GetMetadataFromProvider( 135 | IRemoteMetadataProvider provider, IDirectoryService directoryService, TIdType id, 136 | CancellationToken cancellationToken) where TItemType : BaseItem, IHasLookupInfo, new() 137 | where TIdType : ItemLookupInfo, new() 138 | { 139 | if (!(provider is IRemoteMetadataProviderWithOptions providerWithOptions)) 140 | return provider.GetMetadata(id, cancellationToken); 141 | 142 | var options = new RemoteMetadataFetchOptions 143 | { 144 | SearchInfo = id, DirectoryService = directoryService 145 | }; 146 | 147 | return providerWithOptions.GetMetadata(options, cancellationToken); 148 | } 149 | 150 | public string ProcessPersonInfo(string input, bool clean) 151 | { 152 | if (IsChinese(input)) input = ConvertTraditionalToSimplified(input); 153 | 154 | if (clean) input = CleanPersonName(input); 155 | 156 | return input; 157 | } 158 | 159 | public string GetCollectionOriginalLanguage(BoxSet collection) 160 | { 161 | var children = _libraryManager.GetItemList(new InternalItemsQuery 162 | { 163 | CollectionIds = new[] { collection.InternalId } 164 | }); 165 | 166 | var concatenatedTitles = string.Join("|", children.Select(c => c.OriginalTitle)); 167 | 168 | return GetLanguageByTitle(concatenatedTitles); 169 | } 170 | 171 | public string ConvertToServerLanguage(string language) 172 | { 173 | if (string.Equals(language, "pt", StringComparison.OrdinalIgnoreCase)) 174 | return "pt-br"; 175 | if (string.Equals(language, "por", StringComparison.OrdinalIgnoreCase)) 176 | return "pt"; 177 | if (string.Equals(language, "zhtw", StringComparison.OrdinalIgnoreCase)) 178 | return "zh-tw"; 179 | if (string.Equals(language, "zho", StringComparison.OrdinalIgnoreCase)) 180 | return "zh-hk"; 181 | var languageInfo = 182 | _localizationManager.FindLanguageInfo(language.AsSpan()); 183 | return languageInfo != null ? languageInfo.TwoLetterISOLanguageName : language; 184 | } 185 | 186 | public void UpdateSeriesPeople(Series series) 187 | { 188 | if (!series.ProviderIds.ContainsKey("Tmdb")) return; 189 | 190 | var seriesPeople = _libraryManager.GetItemPeople(series); 191 | 192 | var seasonQuery = new InternalItemsQuery 193 | { 194 | IncludeItemTypes = new[] { nameof(Season) }, 195 | ParentWithPresentationUniqueKeyFromItemId = series.InternalId, 196 | MinIndexNumber = 1, 197 | OrderBy = new (string, SortOrder)[] { (ItemSortBy.IndexNumber, SortOrder.Ascending) } 198 | }; 199 | 200 | var seasons = _libraryManager.GetItemList(seasonQuery); 201 | var peopleLists = seasons 202 | .Select(s => _libraryManager.GetItemPeople(s)) 203 | .ToList(); 204 | 205 | peopleLists.Add(seriesPeople); 206 | 207 | var maxPeopleCount = peopleLists.Max(seasonPeople => seasonPeople.Count); 208 | 209 | var combinedPeople = new List(); 210 | var uniqueNames = new HashSet(); 211 | 212 | for (var i = 0; i < maxPeopleCount; i++) 213 | { 214 | foreach (var seasonPeople in peopleLists) 215 | { 216 | var person = i < seasonPeople.Count ? seasonPeople[i] : null; 217 | if (person != null && uniqueNames.Add(person.Name)) 218 | { 219 | combinedPeople.Add(person); 220 | } 221 | } 222 | } 223 | 224 | _libraryManager.UpdatePeople(series, combinedPeople); 225 | } 226 | 227 | public async Task GetMovieDbResponse(string url, string cacheKey, string cachePath, 228 | CancellationToken cancellationToken) where T : class 229 | { 230 | var result = TryGetFromCache(cacheKey, cachePath); 231 | 232 | if (result != null) return result; 233 | 234 | var num = Math.Min((RequestIntervalMs * 10000 - (DateTimeOffset.UtcNow.Ticks - _lastRequestTicks)) / 10000L, 235 | RequestIntervalMs); 236 | 237 | if (num > 0L) 238 | { 239 | _logger.Debug("Throttling Tmdb by {0} ms", num); 240 | await Task.Delay(Convert.ToInt32(num)).ConfigureAwait(false); 241 | } 242 | 243 | _lastRequestTicks = DateTimeOffset.UtcNow.Ticks; 244 | 245 | var options = new HttpRequestOptions 246 | { 247 | Url = url, 248 | CancellationToken = cancellationToken, 249 | AcceptHeader = "application/json", 250 | BufferContent = true, 251 | UserAgent = Plugin.Instance.UserAgent 252 | }; 253 | 254 | try 255 | { 256 | using var response = await _httpClient.SendAsync(options, "GET").ConfigureAwait(false); 257 | if (response.StatusCode != HttpStatusCode.OK) 258 | { 259 | _logger.Debug("Failed to get MovieDb response - " + response.StatusCode); 260 | return null; 261 | } 262 | 263 | await using var contentStream = response.Content; 264 | result = _jsonSerializer.DeserializeFromStream(contentStream); 265 | 266 | if (result is null) return null; 267 | 268 | AddOrUpdateCache(result, cacheKey, cachePath); 269 | 270 | return result; 271 | } 272 | catch (Exception e) 273 | { 274 | _logger.Debug("Failed to get MovieDb response - " + e.Message); 275 | return null; 276 | } 277 | } 278 | 279 | public async Task GetMovieDbResponse(string url, CancellationToken cancellationToken) where T : class 280 | { 281 | return await GetMovieDbResponse(url, null, null, cancellationToken); 282 | } 283 | 284 | public T TryGetFromCache(string cacheKey, string cachePath) where T : class 285 | { 286 | if (string.IsNullOrEmpty(cacheKey) || string.IsNullOrEmpty(cachePath)) return null; 287 | 288 | if (LruCache.TryGetFromCache(cacheKey, out T result)) return result; 289 | 290 | var cacheFile = _fileSystem.GetFileSystemInfo(cachePath); 291 | 292 | if (cacheFile.Exists && DateTimeOffset.UtcNow - _fileSystem.GetLastWriteTimeUtc(cacheFile) <= DefaultCacheTime) 293 | { 294 | result = _jsonSerializer.DeserializeFromFile(cachePath); 295 | LruCache.AddOrUpdateCache(cacheKey, result); 296 | 297 | return result; 298 | } 299 | 300 | return null; 301 | } 302 | 303 | public void AddOrUpdateCache(T result, string cacheKey, string cachePath) 304 | { 305 | if (result is null || string.IsNullOrEmpty(cacheKey) || string.IsNullOrEmpty(cachePath)) return; 306 | 307 | _fileSystem.CreateDirectory(_fileSystem.GetDirectoryName(cachePath)); 308 | _jsonSerializer.SerializeToFile(result, cachePath); 309 | LruCache.AddOrUpdateCache(cacheKey, result); 310 | } 311 | 312 | public Series GetSeriesByPath(string path) 313 | { 314 | var items = _libraryManager.GetItemList(new InternalItemsQuery { Path = path }); 315 | 316 | foreach (var item in items) 317 | { 318 | if (item is Episode episode) 319 | { 320 | return episode.Series; 321 | } 322 | 323 | if (item is Season season) 324 | { 325 | return season.Series; 326 | } 327 | 328 | if (item is Series series) 329 | { 330 | return series; 331 | } 332 | } 333 | 334 | return null; 335 | } 336 | } 337 | } 338 | -------------------------------------------------------------------------------- /StrmAssistant/Common/NotificationApi.cs: -------------------------------------------------------------------------------- 1 | using Emby.Notifications; 2 | using MediaBrowser.Controller.Entities; 3 | using MediaBrowser.Controller.Entities.TV; 4 | using MediaBrowser.Controller.Library; 5 | using MediaBrowser.Controller.Notifications; 6 | using MediaBrowser.Controller.Session; 7 | using MediaBrowser.Model.Logging; 8 | using MediaBrowser.Model.Session; 9 | using StrmAssistant.Properties; 10 | using System; 11 | using System.Collections.Generic; 12 | using System.Linq; 13 | using System.Threading; 14 | using System.Threading.Tasks; 15 | 16 | namespace StrmAssistant.Common 17 | { 18 | public class NotificationApi 19 | { 20 | private readonly ILogger _logger; 21 | private readonly INotificationManager _notificationManager; 22 | private readonly IUserManager _userManager; 23 | private readonly ISessionManager _sessionManager; 24 | 25 | public NotificationApi(INotificationManager notificationManager, IUserManager userManager, ISessionManager sessionManager) 26 | { 27 | _logger = Plugin.Instance.Logger; 28 | _notificationManager = notificationManager; 29 | _userManager = userManager; 30 | _sessionManager = sessionManager; 31 | } 32 | 33 | public void FavoritesUpdateSendNotification(BaseItem item) 34 | { 35 | Resources.Culture = Thread.CurrentThread.CurrentUICulture; 36 | 37 | var users = Plugin.LibraryApi.GetUsersByFavorites(item); 38 | foreach (var user in users) 39 | { 40 | var request = new NotificationRequest 41 | { 42 | Title = Resources.PluginOptions_EditorTitle_Strm_Assistant, 43 | EventId = "favorites.update", 44 | User = user, 45 | Item = item, 46 | Description = 47 | string.Format( 48 | Resources.Notification_CatchupUpdate_EventDescription.Replace("\\n", 49 | Environment.NewLine), item.Path, user) 50 | }; 51 | _notificationManager.SendNotification(request); 52 | } 53 | } 54 | 55 | public void DeepDeleteSendNotification(BaseItem item, User user, HashSet mountPaths) 56 | { 57 | Resources.Culture = Thread.CurrentThread.CurrentUICulture; 58 | 59 | var mountPathList = string.Join(Environment.NewLine, mountPaths); 60 | 61 | var request = new NotificationRequest 62 | { 63 | Title = Resources.PluginOptions_EditorTitle_Strm_Assistant + " - " + 64 | Resources.Notification_DeepDelete_EventName, 65 | EventId = "deep.delete", 66 | User = user, 67 | Item = item, 68 | Description = 69 | string.Format( 70 | Resources.Notification_DeepDelete_EventDescription.Replace("\\n", Environment.NewLine), 71 | item.Name, item.Path, mountPathList) 72 | }; 73 | 74 | _notificationManager.SendNotification(request); 75 | } 76 | 77 | public async Task IntroUpdateSendNotification(Episode episode, SessionInfo session, string introStartTime, 78 | string introEndTime) 79 | { 80 | Resources.Culture = Thread.CurrentThread.CurrentUICulture; 81 | 82 | if (CanDisplayMessage(session)) 83 | { 84 | var message = new MessageCommand 85 | { 86 | Header = Resources.PluginOptions_EditorTitle_Strm_Assistant, 87 | Text = string.Format( 88 | Resources.Notification_IntroUpdate_Message, episode.FindSeriesName(), episode.FindSeasonName()), 89 | TimeoutMs = 500 90 | }; 91 | await _sessionManager.SendMessageCommand(session.Id, session.Id, message, CancellationToken.None); 92 | } 93 | 94 | var request = new NotificationRequest 95 | { 96 | Title = 97 | Resources.PluginOptions_EditorTitle_Strm_Assistant, 98 | EventId = "introskip.update", 99 | User = _userManager.GetUserById(session.UserInternalId), 100 | Item = episode, 101 | Session = session, 102 | Description = string.Format( 103 | Resources.Notification_IntroUpdate_Description.Replace("\\n", Environment.NewLine), 104 | episode.FindSeriesName(), episode.FindSeasonName(), introStartTime, introEndTime, 105 | session.UserName) 106 | }; 107 | _notificationManager.SendNotification(request); 108 | } 109 | 110 | public async Task CreditsUpdateSendNotification(Episode episode, SessionInfo session, string creditsDuration) 111 | { 112 | Resources.Culture = Thread.CurrentThread.CurrentUICulture; 113 | 114 | if (CanDisplayMessage(session)) 115 | { 116 | var message = new MessageCommand 117 | { 118 | Header = Resources.PluginOptions_EditorTitle_Strm_Assistant, 119 | Text = string.Format( 120 | Resources.Notification_CreditsUpdate_Message, episode.FindSeriesName(), episode.FindSeasonName()), 121 | TimeoutMs = 500 122 | }; 123 | await _sessionManager.SendMessageCommand(session.Id, session.Id, message, CancellationToken.None); 124 | } 125 | 126 | var request = new NotificationRequest 127 | { 128 | Title = 129 | Resources.PluginOptions_EditorTitle_Strm_Assistant, 130 | EventId = "introskip.update", 131 | User = _userManager.GetUserById(session.UserInternalId), 132 | Item = episode, 133 | Session = session, 134 | Description = string.Format( 135 | Resources.Notification_CreditsUpdate_Description.Replace("\\n", Environment.NewLine), 136 | episode.FindSeriesName(), episode.FindSeasonName(), creditsDuration, session.UserName) 137 | 138 | }; 139 | _notificationManager.SendNotification(request); 140 | } 141 | 142 | public async Task SendMessageToAdmins(string text, long? timeout) 143 | { 144 | var message = new MessageCommand 145 | { 146 | Header = Resources.PluginOptions_EditorTitle_Strm_Assistant, 147 | Text = text, 148 | TimeoutMs = timeout 149 | }; 150 | 151 | var admins = LibraryApi.AllUsers.Where(kvp => kvp.Value).Select(kvp => kvp.Key); 152 | var sessions = _sessionManager.Sessions.Where(CanDisplayMessage) 153 | .Where(s => admins.Any(u => s.ContainsUser(u.InternalId))); 154 | 155 | foreach (var session in sessions) 156 | { 157 | await _sessionManager.SendMessageCommand(session.Id, session.Id, message, CancellationToken.None); 158 | } 159 | } 160 | 161 | private bool CanDisplayMessage(SessionInfo session) 162 | { 163 | return session.SupportedCommands.Contains("DisplayMessage"); 164 | } 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /StrmAssistant/Common/SubtitleApi.cs: -------------------------------------------------------------------------------- 1 | using MediaBrowser.Controller.Entities; 2 | using MediaBrowser.Controller.Library; 3 | using MediaBrowser.Controller.MediaEncoding; 4 | using MediaBrowser.Controller.Persistence; 5 | using MediaBrowser.Controller.Providers; 6 | using MediaBrowser.Model.Entities; 7 | using MediaBrowser.Model.Globalization; 8 | using MediaBrowser.Model.IO; 9 | using MediaBrowser.Model.Logging; 10 | using MediaBrowser.Model.MediaInfo; 11 | using System; 12 | using System.Collections.Generic; 13 | using System.IO; 14 | using System.Linq; 15 | using System.Reflection; 16 | using System.Threading; 17 | using System.Threading.Tasks; 18 | 19 | namespace StrmAssistant.Common 20 | { 21 | public class SubtitleApi 22 | { 23 | private readonly ILogger _logger; 24 | private readonly ILibraryManager _libraryManager; 25 | private readonly IItemRepository _itemRepository; 26 | private readonly IFileSystem _fileSystem; 27 | 28 | private readonly object _subtitleResolver; 29 | private readonly MethodInfo _getExternalSubtitleStreams; 30 | private readonly object _ffProbeSubtitleInfo; 31 | private readonly MethodInfo _updateExternalSubtitleStream; 32 | 33 | private static readonly HashSet ProbeExtensions = new HashSet(StringComparer.OrdinalIgnoreCase) 34 | { ".sub", ".smi", ".sami", ".mpl" }; 35 | 36 | public SubtitleApi(ILibraryManager libraryManager, IFileSystem fileSystem, IMediaProbeManager mediaProbeManager, 37 | ILocalizationManager localizationManager, IItemRepository itemRepository) 38 | { 39 | _logger = Plugin.Instance.Logger; 40 | _libraryManager = libraryManager; 41 | _itemRepository = itemRepository; 42 | _fileSystem = fileSystem; 43 | 44 | try 45 | { 46 | var embyProviders = Assembly.Load("Emby.Providers"); 47 | var subtitleResolverType = embyProviders.GetType("Emby.Providers.MediaInfo.SubtitleResolver"); 48 | var subtitleResolverConstructor = subtitleResolverType.GetConstructor(new[] 49 | { 50 | typeof(ILocalizationManager), typeof(IFileSystem), typeof(ILibraryManager) 51 | }); 52 | _subtitleResolver = subtitleResolverConstructor?.Invoke(new object[] 53 | { 54 | localizationManager, fileSystem, libraryManager 55 | }); 56 | _getExternalSubtitleStreams = subtitleResolverType.GetMethod("GetExternalSubtitleStreams"); 57 | 58 | var ffProbeSubtitleInfoType = embyProviders.GetType("Emby.Providers.MediaInfo.FFProbeSubtitleInfo"); 59 | var ffProbeSubtitleInfoConstructor = ffProbeSubtitleInfoType.GetConstructor(new[] 60 | { 61 | typeof(IMediaProbeManager) 62 | }); 63 | _ffProbeSubtitleInfo = ffProbeSubtitleInfoConstructor?.Invoke(new object[] { mediaProbeManager }); 64 | _updateExternalSubtitleStream = ffProbeSubtitleInfoType.GetMethod("UpdateExternalSubtitleStream"); 65 | } 66 | catch (Exception e) 67 | { 68 | if (Plugin.Instance.DebugMode) 69 | { 70 | _logger.Debug(e.Message); 71 | _logger.Debug(e.StackTrace); 72 | } 73 | } 74 | 75 | if (_subtitleResolver is null || _getExternalSubtitleStreams is null || 76 | _ffProbeSubtitleInfo is null || _updateExternalSubtitleStream is null) 77 | { 78 | _logger.Warn($"{nameof(SubtitleApi)} Init Failed"); 79 | } 80 | } 81 | 82 | private List GetExternalSubtitleStreams(BaseItem item, int startIndex, 83 | IDirectoryService directoryService, bool clearCache) 84 | { 85 | var namingOptions = _libraryManager.GetNamingOptions(); 86 | 87 | return (List)_getExternalSubtitleStreams.Invoke(_subtitleResolver, 88 | new object[] { item, startIndex, directoryService, namingOptions, clearCache }); 89 | } 90 | 91 | private Task UpdateExternalSubtitleStream(BaseItem item, 92 | MediaStream subtitleStream, MetadataRefreshOptions options, CancellationToken cancellationToken) 93 | { 94 | var libraryOptions = _libraryManager.GetLibraryOptions(item); 95 | 96 | return (Task)_updateExternalSubtitleStream.Invoke(_ffProbeSubtitleInfo, 97 | new object[] { item, subtitleStream, options, libraryOptions, cancellationToken }); 98 | } 99 | 100 | public MetadataRefreshOptions GetExternalSubtitleRefreshOptions() 101 | { 102 | return new MetadataRefreshOptions(new DirectoryService(_logger, _fileSystem)) 103 | { 104 | EnableRemoteContentProbe = true, 105 | MetadataRefreshMode = MetadataRefreshMode.ValidationOnly, 106 | ReplaceAllMetadata = false, 107 | ImageRefreshMode = MetadataRefreshMode.ValidationOnly, 108 | ReplaceAllImages = false, 109 | EnableThumbnailImageExtraction = false, 110 | EnableSubtitleDownloading = false 111 | }; 112 | } 113 | 114 | public bool HasExternalSubtitleChanged(BaseItem item, IDirectoryService directoryService, bool clearCache) 115 | { 116 | var currentExternalSubtitleFiles = _libraryManager.GetExternalSubtitleFiles(item.InternalId); 117 | var currentSet = new HashSet(currentExternalSubtitleFiles, StringComparer.Ordinal); 118 | 119 | try 120 | { 121 | var newExternalSubtitleFiles = GetExternalSubtitleStreams(item, 0, directoryService, clearCache) 122 | .Select(i => i.Path) 123 | .ToArray(); 124 | var newSet = new HashSet(newExternalSubtitleFiles, StringComparer.Ordinal); 125 | 126 | return !currentSet.SetEquals(newSet); 127 | } 128 | catch 129 | { 130 | // ignored 131 | } 132 | 133 | return false; 134 | } 135 | 136 | public async Task UpdateExternalSubtitles(BaseItem item, MetadataRefreshOptions refreshOptions, bool clearCache, 137 | bool persistMediaInfo) 138 | { 139 | var directoryService = refreshOptions.DirectoryService; 140 | var currentStreams = item.GetMediaStreams() 141 | .FindAll(i => 142 | !(i.IsExternal && i.Type == MediaStreamType.Subtitle && i.Protocol == MediaProtocol.File)); 143 | var startIndex = currentStreams.Count == 0 ? 0 : currentStreams.Max(i => i.Index) + 1; 144 | 145 | if (GetExternalSubtitleStreams(item, startIndex, directoryService, clearCache) is 146 | { } externalSubtitleStreams) 147 | { 148 | foreach (var subtitleStream in externalSubtitleStreams) 149 | { 150 | var extension = Path.GetExtension(subtitleStream.Path); 151 | if (!string.IsNullOrEmpty(extension) && ProbeExtensions.Contains(extension)) 152 | { 153 | var result = 154 | await UpdateExternalSubtitleStream(item, subtitleStream, refreshOptions, 155 | CancellationToken.None).ConfigureAwait(false); 156 | 157 | if (!result) 158 | _logger.Warn("No result when probing external subtitle file: {0}", subtitleStream.Path); 159 | } 160 | 161 | _logger.Info("ExternalSubtitle - Subtitle Processed: " + subtitleStream.Path); 162 | } 163 | 164 | currentStreams.AddRange(externalSubtitleStreams); 165 | _itemRepository.SaveMediaStreams(item.InternalId, currentStreams, CancellationToken.None); 166 | 167 | if (persistMediaInfo && Plugin.LibraryApi.IsLibraryInScope(item)) 168 | { 169 | _ = Plugin.MediaInfoApi.SerializeMediaInfo(item.InternalId, directoryService, true, 170 | "External Subtitle Update").ConfigureAwait(false); 171 | } 172 | } 173 | } 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /StrmAssistant/Common/VideoThumbnailApi.cs: -------------------------------------------------------------------------------- 1 | using MediaBrowser.Controller; 2 | using MediaBrowser.Controller.Entities; 3 | using MediaBrowser.Controller.Library; 4 | using MediaBrowser.Controller.MediaEncoding; 5 | using MediaBrowser.Controller.Persistence; 6 | using MediaBrowser.Controller.Providers; 7 | using MediaBrowser.Model.Configuration; 8 | using MediaBrowser.Model.Entities; 9 | using MediaBrowser.Model.IO; 10 | using MediaBrowser.Model.Logging; 11 | using StrmAssistant.Properties; 12 | using System; 13 | using System.Collections.Generic; 14 | using System.IO; 15 | using System.Linq; 16 | using System.Reflection; 17 | using System.Threading; 18 | using System.Threading.Tasks; 19 | 20 | namespace StrmAssistant.Common 21 | { 22 | public class VideoThumbnailApi 23 | { 24 | private readonly ILogger _logger; 25 | private readonly ILibraryManager _libraryManager; 26 | 27 | private readonly object _thumbnailGenerator; 28 | private readonly MethodInfo _refreshThumbnailImages; 29 | 30 | private static readonly Version AppVer = Plugin.Instance.ApplicationHost.ApplicationVersion; 31 | private static readonly Version Ver4936 = new Version("4.9.0.36"); 32 | 33 | public VideoThumbnailApi(ILibraryManager libraryManager, IFileSystem fileSystem, 34 | IImageExtractionManager imageExtractionManager, IItemRepository itemRepository, 35 | IMediaMountManager mediaMountManager, IServerApplicationPaths applicationPaths, 36 | ILibraryMonitor libraryMonitor, IFfmpegManager ffmpegManager) 37 | { 38 | _logger = Plugin.Instance.Logger; 39 | _libraryManager = libraryManager; 40 | 41 | try 42 | { 43 | var embyProviders = Assembly.Load("Emby.Providers"); 44 | var thumbnailGenerator = embyProviders.GetType("Emby.Providers.MediaInfo.ThumbnailGenerator"); 45 | var thumbnailGeneratorConstructor = thumbnailGenerator.GetConstructor( 46 | BindingFlags.Public | BindingFlags.Instance, null, 47 | new[] 48 | { 49 | typeof(IFileSystem), typeof(ILogger), typeof(IImageExtractionManager), 50 | typeof(IItemRepository), typeof(IMediaMountManager), typeof(IServerApplicationPaths), 51 | typeof(ILibraryMonitor), typeof(IFfmpegManager) 52 | }, null); 53 | _thumbnailGenerator = thumbnailGeneratorConstructor?.Invoke(new object[] 54 | { 55 | fileSystem, _logger, imageExtractionManager, itemRepository, mediaMountManager, 56 | applicationPaths, libraryMonitor, ffmpegManager 57 | }); 58 | _refreshThumbnailImages = thumbnailGenerator.GetMethod("RefreshThumbnailImages", 59 | BindingFlags.Public | BindingFlags.Instance); 60 | } 61 | catch (Exception e) 62 | { 63 | if (Plugin.Instance.DebugMode) 64 | { 65 | _logger.Debug(e.Message); 66 | _logger.Debug(e.StackTrace); 67 | } 68 | } 69 | 70 | if (_thumbnailGenerator is null || _refreshThumbnailImages is null) 71 | { 72 | _logger.Warn($"{nameof(VideoThumbnailApi)} Init Failed"); 73 | } 74 | } 75 | 76 | public Task RefreshThumbnailImages(Video item, LibraryOptions libraryOptions, 77 | IDirectoryService directoryService, List chapters, bool extractImages, bool saveChapters, 78 | CancellationToken cancellationToken) 79 | { 80 | var mediaSource = AppVer >= Ver4936 81 | ? item.GetMediaSources(false, false, libraryOptions).FirstOrDefault() 82 | : null; 83 | 84 | var parameters = AppVer >= Ver4936 85 | ? new object[] 86 | { 87 | item, mediaSource, null, libraryOptions, directoryService, chapters, extractImages, 88 | saveChapters, cancellationToken 89 | } 90 | : new object[] 91 | { 92 | item, null, libraryOptions, directoryService, chapters, extractImages, saveChapters, 93 | cancellationToken 94 | }; 95 | 96 | return (Task)_refreshThumbnailImages.Invoke(_thumbnailGenerator, parameters); 97 | } 98 | 99 | public List