├── .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 | 
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 | 
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 | 
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 | [](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
" +
94 | '' + globalize.translate('AreYouSureToContinue') + "
",
95 | title: globalize.translate('HeaderDeleteItem'),
96 | confirmText: globalize.translate('Delete'),
97 | primary: 'cancel',
98 | centerText: !1
99 | })
100 | .then(function() {
101 | deleteVersion(itemId);
102 | });
103 | } else {
104 | const locale = globalize.getCurrentLocale().toLowerCase();
105 | const deleteEpisode = (locale.startsWith('zh') || locale.startsWith('ja') || locale.startsWith('ko'))
106 | ? globalize.translate('Delete') + globalize.translate('Episode')
107 | : globalize.translate('Delete') + ' ' + globalize.translate('Episode');
108 | const deleteSeason = (locale.startsWith('zh') || locale.startsWith('ja') || locale.startsWith('ko'))
109 | ? globalize.translate('Delete') + globalize.translate('Season')
110 | : globalize.translate('Delete') + ' ' + globalize.translate('Season');
111 | dialog({
112 | text: globalize.translate('ConfirmDeleteItems') + "\n\n" +
113 | itemName + "\n\n" +
114 | globalize.translate('AreYouSureToContinue'),
115 | html: globalize.translate('ConfirmDeleteItems') +
116 | '' + itemName + "
" +
117 | '' + globalize.translate('AreYouSureToContinue') + "
",
118 | title: globalize.translate('HeaderDeleteItem'),
119 | buttons: [
120 | { name: globalize.translate("Cancel"), id: "cancel", type: "submit" },
121 | { name: deleteEpisode, id: "deleteepisode", type: "cancel" },
122 | { name: deleteSeason, id: "deleteseason", type: "cancel" }
123 | ],
124 | centerText: !1
125 | })
126 | .then(function(id) {
127 | if (id === 'deleteepisode') {
128 | deleteVersion(itemId);
129 | } else if (id === 'deleteseason') {
130 | deleteVersion(itemId, true);
131 | }
132 | });
133 | }
134 | function deleteVersion(itemId, deleteParent = false) {
135 | loading.show();
136 | let apiClient = connectionManager.currentApiClient();
137 | let deleteApi = apiClient.getUrl(`Items/${itemId}/DeleteVersion${deleteParent ? `?DeleteParent=true` : ''}`);
138 | apiClient.ajax({
139 | type: "POST",
140 | url: deleteApi,
141 | data: {},
142 | contentType: "application/json"
143 | }).finally(() => {
144 | loading.hide();
145 | const locale = globalize.getCurrentLocale().toLowerCase();
146 | const confirmMessage = (locale === 'zh-cn') ? '\u5220\u9664\u7248\u672C\u6210\u529F' :
147 | (['zh-hk', 'zh-tw'].includes(locale) ? '\u524A\u9664\u7248\u672C\u6210\u529F' : 'Delete Version Success');
148 | toast(confirmMessage);
149 | });
150 | }
151 | },
152 |
153 | lock: function (itemId, lockData) {
154 | let apiClient = connectionManager.currentApiClient();
155 | let lockApi = apiClient.getUrl(`Items/${itemId}/Lock`);
156 | let queryParams = {
157 | LockData: lockData
158 | };
159 | let queryString = new URLSearchParams(queryParams).toString();
160 |
161 | apiClient.ajax({
162 | type: "POST",
163 | url: `${lockApi}?${queryString}`,
164 | data: {},
165 | contentType: "application/json"
166 | });
167 | },
168 |
169 | clear_intro: function (itemId) {
170 | const locale = globalize.getCurrentLocale().toLowerCase();
171 | const commandName = locale === 'zh-cn' ? '\u6E05\u9664\u7247\u5934\u6807\u8BB0' :
172 | (['zh-hk', 'zh-tw'].includes(locale) ? '\u6E05\u9664\u7247\u982D\u6A19\u8A18' : 'Clear Intro Markers');
173 | confirm({
174 | text: globalize.translate('AreYouSureToContinue'),
175 | title: commandName,
176 | confirmText: globalize.translate('Clear'),
177 | primary: 'cancel'
178 | })
179 | .then(function() {
180 | loading.show();
181 | let apiClient = connectionManager.currentApiClient();
182 | let clearIntroApi = apiClient.getUrl(`Items/${itemId}/ClearIntro`);
183 | apiClient.ajax({
184 | type: "POST",
185 | url: clearIntroApi,
186 | data: {},
187 | contentType: "application/json"
188 | }).finally(() => {
189 | loading.hide();
190 | const confirmMessage = (locale === 'zh-cn') ? commandName + '\u6210\u529F' :
191 | (['zh-hk', 'zh-tw'].includes(locale) ? commandName + '\u6210\u529F' : commandName + ' Success');
192 | toast(confirmMessage);
193 | });
194 | });
195 | }
196 | };
197 | });
198 |
--------------------------------------------------------------------------------
/StrmAssistant/Web/Service/ChapterService.cs:
--------------------------------------------------------------------------------
1 | using MediaBrowser.Controller.Api;
2 | using MediaBrowser.Controller.Entities;
3 | using MediaBrowser.Controller.Entities.TV;
4 | using MediaBrowser.Controller.Library;
5 | using MediaBrowser.Model.Logging;
6 | using StrmAssistant.Web.Api;
7 | using System.Collections.Generic;
8 |
9 | namespace StrmAssistant.Web.Service
10 | {
11 | public class ChapterService : BaseApiService
12 | {
13 | private readonly ILogger _logger;
14 | private readonly ILibraryManager _libraryManager;
15 |
16 | public ChapterService(ILibraryManager libraryManager)
17 | {
18 | _logger = Plugin.Instance.Logger;
19 | _libraryManager = libraryManager;
20 | }
21 |
22 | public void Post(ClearIntro request)
23 | {
24 | var itemById = _libraryManager.GetItemById(request.Id);
25 |
26 | if (!(itemById is Series || itemById is Season)) return;
27 |
28 | var episodes = Plugin.ChapterApi.FetchClearTaskItems(new List { itemById });
29 |
30 | foreach (var item in episodes)
31 | {
32 | Plugin.ChapterApi.RemoveIntroCreditsMarkers(item);
33 | _logger.Info("IntroSkipClear - " + item.Name + " - " + item.Path);
34 | }
35 | }
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/StrmAssistant/Web/Service/ItemService.cs:
--------------------------------------------------------------------------------
1 | using MediaBrowser.Controller.Api;
2 | using MediaBrowser.Controller.Entities;
3 | using MediaBrowser.Controller.Library;
4 | using StrmAssistant.Web.Api;
5 |
6 | namespace StrmAssistant.Web.Service
7 | {
8 | public class ItemService : BaseApiService
9 | {
10 | private readonly ILibraryManager _libraryManager;
11 |
12 | public ItemService(ILibraryManager libraryManager)
13 | {
14 | _libraryManager = libraryManager;
15 | }
16 |
17 | public void Post(LockItem request)
18 | {
19 | var itemById = _libraryManager.GetItemById(request.ItemId);
20 |
21 | var items = _libraryManager.GetItemList(new InternalItemsQuery
22 | {
23 | PresentationUniqueKey = itemById.PresentationUniqueKey
24 | });
25 |
26 | foreach (var item in items)
27 | {
28 | if (item.IsLocked != request.LockData)
29 | {
30 | item.IsLocked = request.LockData;
31 | item.UpdateToRepository(ItemUpdateType.MetadataEdit);
32 | }
33 |
34 | if (item is Folder folder)
35 | {
36 | foreach (var child in folder.GetItemList(new InternalItemsQuery { Recursive = true }))
37 | {
38 | if (child.IsLocked != request.LockData)
39 | {
40 | child.IsLocked = request.LockData;
41 | child.UpdateToRepository(ItemUpdateType.MetadataEdit);
42 | }
43 | }
44 | }
45 | }
46 | }
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/StrmAssistant/Web/Service/LibraryService.cs:
--------------------------------------------------------------------------------
1 | using MediaBrowser.Controller.Api;
2 | using MediaBrowser.Controller.Entities;
3 | using MediaBrowser.Controller.Entities.Movies;
4 | using MediaBrowser.Controller.Entities.TV;
5 | using MediaBrowser.Controller.Library;
6 | using MediaBrowser.Controller.Persistence;
7 | using MediaBrowser.Model.IO;
8 | using MediaBrowser.Model.Logging;
9 | using StrmAssistant.Web.Api;
10 | using System;
11 | using System.Collections.Generic;
12 | using System.IO;
13 | using System.Linq;
14 | using static StrmAssistant.Common.CommonUtility;
15 | using static StrmAssistant.Common.LanguageUtility;
16 |
17 | namespace StrmAssistant.Web.Service
18 | {
19 | public class LibraryService : BaseApiService
20 | {
21 | private readonly ILogger _logger;
22 | private readonly ILibraryManager _libraryManager;
23 | private readonly IFileSystem _fileSystem;
24 | private readonly IItemRepository _itemRepository;
25 |
26 | public LibraryService(ILibraryManager libraryManager, IItemRepository itemRepository, IFileSystem fileSystem)
27 | {
28 | _logger = Plugin.Instance.Logger;
29 | _libraryManager = libraryManager;
30 | _itemRepository = itemRepository;
31 | _fileSystem = fileSystem;
32 | }
33 |
34 | public void Any(DeleteVersion request)
35 | {
36 | var item = _libraryManager.GetItemById(request.Id);
37 |
38 | if (!(item is Video video) || !(video is Movie || video is Episode) || !video.IsFileProtocol ||
39 | video.GetAlternateVersionIds().Count == 0)
40 | {
41 | return;
42 | }
43 |
44 | var user = GetUserForRequest(null);
45 | var collectionFolders = _libraryManager.GetCollectionFolders(item);
46 |
47 | if (user is null)
48 | {
49 | if (!item.CanDelete())
50 | {
51 | return;
52 | }
53 | }
54 | else if (!item.CanDelete(user, collectionFolders))
55 | {
56 | return;
57 | }
58 |
59 | var deleteItems = item is Episode episode && request.DeleteParent
60 | ? GetSeasonEpisodesSameVersion(episode)
61 | : new List { item };
62 |
63 | foreach (var deleteItem in deleteItems)
64 | {
65 | var proceedToDelete = true;
66 | var deletePaths = Plugin.LibraryApi.GetDeletePaths(deleteItem);
67 |
68 | foreach (var path in deletePaths)
69 | {
70 | try
71 | {
72 | if (!path.IsDirectory)
73 | {
74 | _logger.Info("DeleteVersion - Attempting to delete file: " + path.FullName);
75 | _fileSystem.DeleteFile(path.FullName, true);
76 | }
77 | }
78 | catch (Exception e)
79 | {
80 | if (e is IOException || e is UnauthorizedAccessException)
81 | {
82 | proceedToDelete = false;
83 | _logger.Error("DeleteVersion - Failed to delete file: " + path.FullName);
84 | _logger.Error(e.Message);
85 | _logger.Debug(e.StackTrace);
86 | }
87 | }
88 | }
89 |
90 | if (proceedToDelete)
91 | {
92 | _itemRepository.DeleteItems(new[] { deleteItem });
93 |
94 | try
95 | {
96 | _fileSystem.DeleteDirectory(item.GetInternalMetadataPath(), true, true);
97 | }
98 | catch
99 | {
100 | // ignored
101 | }
102 | }
103 | }
104 | }
105 |
106 | private List GetSeasonEpisodesSameVersion(Episode episode)
107 | {
108 | var seasonFolderChildren = episode.Parent.GetItemList(new InternalItemsQuery
109 | {
110 | IncludeItemTypes = new[] { nameof(Episode) },
111 | Recursive = false,
112 | GroupByPresentationUniqueKey = false,
113 | })
114 | .ToList();
115 |
116 | var seasonEpisodesCount = episode.Season.GetEpisodeIds(new InternalItemsQuery
117 | {
118 | IncludeItemTypes = new[] { nameof(Episode) }, GroupByPresentationUniqueKey = true
119 | })
120 | .Length;
121 |
122 | if (seasonFolderChildren.Count == seasonEpisodesCount)
123 | {
124 | return seasonFolderChildren;
125 | }
126 |
127 | var targetCleaned = CleanEpisodeName(episode.FileNameWithoutExtension);
128 |
129 | var allEpisodes = episode.Season
130 | .GetEpisodes(new InternalItemsQuery
131 | {
132 | GroupByPresentationUniqueKey = false, EnableTotalRecordCount = false
133 | })
134 | .Items.ToList();
135 |
136 | var similarEpisodes = new List();
137 |
138 | foreach (var ep in allEpisodes)
139 | {
140 | var cleanedName = CleanEpisodeName(ep.FileNameWithoutExtension);
141 | var similarity = LevenshteinDistance(targetCleaned, cleanedName);
142 |
143 | if (similarity > 0.92)
144 | {
145 | similarEpisodes.Add(ep);
146 | }
147 | }
148 |
149 | return similarEpisodes;
150 | }
151 | }
152 | }
153 |
--------------------------------------------------------------------------------
/StrmAssistant/Web/Service/LibraryStructureService.cs:
--------------------------------------------------------------------------------
1 | using MediaBrowser.Controller.Library;
2 | using MediaBrowser.Model.Configuration;
3 | using MediaBrowser.Model.Logging;
4 | using MediaBrowser.Model.Services;
5 | using StrmAssistant.Common;
6 | using StrmAssistant.Web.Api;
7 | using System;
8 |
9 | namespace StrmAssistant.Web.Service
10 | {
11 | public class LibraryStructureService : IService, IRequiresRequest
12 | {
13 | private readonly ILogger _logger;
14 | private readonly ILibraryManager _libraryManager;
15 |
16 | public LibraryStructureService(ILibraryManager libraryManager)
17 | {
18 | _logger = Plugin.Instance.Logger;
19 | _libraryManager = libraryManager;
20 | }
21 |
22 | public IRequest Request { get; set; }
23 |
24 | public void Post(CopyVirtualFolder request)
25 | {
26 | var sourceLibrary = _libraryManager.GetItemById(request.Id);
27 | var sourceOptions = _libraryManager.GetLibraryOptions(sourceLibrary);
28 |
29 | var targetOptions = LibraryApi.CopyLibraryOptions(sourceOptions);
30 | targetOptions.PathInfos = Array.Empty();
31 |
32 | var suffix = new Random().Next(100, 999).ToString();
33 | _libraryManager.AddVirtualFolder(sourceLibrary.Name + " #" + suffix, targetOptions, false);
34 | }
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/StrmAssistant/Web/Service/ShortcutMenuService.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using MediaBrowser.Controller.Net;
3 | using MediaBrowser.Model.Services;
4 | using StrmAssistant.Web.Api;
5 | using StrmAssistant.Web.Helper;
6 |
7 | namespace StrmAssistant.Web.Service
8 | {
9 | [Unauthenticated]
10 | public class ShortcutMenuService : IService, IRequiresRequest
11 | {
12 | private readonly IHttpResultFactory _resultFactory;
13 |
14 | public ShortcutMenuService(IHttpResultFactory resultFactory)
15 | {
16 | _resultFactory = resultFactory;
17 | }
18 |
19 | public IRequest Request { get; set; }
20 |
21 | public object Get(GetStrmAssistantJs request)
22 | {
23 | return _resultFactory.GetResult(Request,
24 | (ReadOnlyMemory)ShortcutMenuHelper.StrmAssistantJs.GetBuffer(), "application/x-javascript");
25 | }
26 |
27 | public object Get(GetShortcutMenu request)
28 | {
29 | return _resultFactory.GetResult(ShortcutMenuHelper.ModifiedShortcutsString.AsSpan(),
30 | "application/x-javascript");
31 | }
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/donate.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sjtuross/StrmAssistant/beb65cf8e4d7b19ce418c3aa32cfb6eff04acfb2/donate.png
--------------------------------------------------------------------------------