mirror of
https://github.com/jellyfin/jellyfin.git
synced 2026-02-20 19:42:24 +00:00
Compare commits
91 Commits
v10.11.0-r
...
v10.11.0-r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cae44fdf7 | ||
|
|
c3cb5fd2f9 | ||
|
|
1262ac31dc | ||
|
|
0f5bb5cf76 | ||
|
|
ce78af2ed4 | ||
|
|
4b6fb6c4bb | ||
|
|
db7465e83d | ||
|
|
803e87ca5f | ||
|
|
9e36fa4263 | ||
|
|
a52a230778 | ||
|
|
b00e381109 | ||
|
|
b8fb8bd608 | ||
|
|
34c9adef80 | ||
|
|
c8d2f43660 | ||
|
|
ef733c5ace | ||
|
|
a1eb04dc0b | ||
|
|
711e649e35 | ||
|
|
1d408a1503 | ||
|
|
6391dd9570 | ||
|
|
2007815fa6 | ||
|
|
a5b4eca804 | ||
|
|
76d498ac9d | ||
|
|
90b4345cfd | ||
|
|
317192c23d | ||
|
|
dcb12a73fb | ||
|
|
b15abddfd7 | ||
|
|
cfde5af3b0 | ||
|
|
26a6cfaf65 | ||
|
|
8a8018f0de | ||
|
|
6f49782b7b | ||
|
|
536437bbe3 | ||
|
|
ba54cda774 | ||
|
|
e86315128d | ||
|
|
dfab2fb6e2 | ||
|
|
7785b51f57 | ||
|
|
a068f75623 | ||
|
|
1ed191c5b3 | ||
|
|
0e3fbb6abd | ||
|
|
583a861b32 | ||
|
|
3bcfe13652 | ||
|
|
f5a135a1db | ||
|
|
0cea853b45 | ||
|
|
663087b155 | ||
|
|
dddeea1f7b | ||
|
|
a148a4ad02 | ||
|
|
57d077d08e | ||
|
|
774be151aa | ||
|
|
7569ac65a8 | ||
|
|
4621a99c7c | ||
|
|
1e796e0b7a | ||
|
|
4da5483ef4 | ||
|
|
eea0872980 | ||
|
|
36c90ce2ce | ||
|
|
48e93dcbce | ||
|
|
6cee66119e | ||
|
|
c62a07405e | ||
|
|
7bd08ab290 | ||
|
|
088ef0d37a | ||
|
|
ba0f61ef2d | ||
|
|
c70f6bffcf | ||
|
|
21a6d6f0d6 | ||
|
|
aa77dfb92d | ||
|
|
2ad37fe021 | ||
|
|
fd5205a6eb | ||
|
|
60cfa65cdc | ||
|
|
e5139e1004 | ||
|
|
aa1abf8b94 | ||
|
|
742b5637fa | ||
|
|
25a362345d | ||
|
|
310a54f090 | ||
|
|
e9d92bdcb0 | ||
|
|
dc39a51475 | ||
|
|
c51f3a3342 | ||
|
|
7ece959f4e | ||
|
|
c96e828002 | ||
|
|
ab56ceaa16 | ||
|
|
4645633acf | ||
|
|
d6f93759ea | ||
|
|
bf3f37e3d0 | ||
|
|
f9c4c9b345 | ||
|
|
98daf4aedb | ||
|
|
fcf56b73cb | ||
|
|
e8239a7ee2 | ||
|
|
84cebeae64 | ||
|
|
c0e2875818 | ||
|
|
411ba03bf0 | ||
|
|
b2e19c0306 | ||
|
|
a7891b3f2d | ||
|
|
e7bc86ebb8 | ||
|
|
7aa96dfc20 | ||
|
|
70d07b830d |
6
.github/workflows/ci-codeql-analysis.yml
vendored
6
.github/workflows/ci-codeql-analysis.yml
vendored
@@ -27,11 +27,11 @@ jobs:
|
||||
dotnet-version: '9.0.x'
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
|
||||
uses: github/codeql-action/init@51f77329afa6477de8c49fc9c7046c15b9a4e79d # v3.29.5
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: +security-extended
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
|
||||
uses: github/codeql-action/autobuild@51f77329afa6477de8c49fc9c7046c15b9a4e79d # v3.29.5
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
|
||||
uses: github/codeql-action/analyze@51f77329afa6477de8c49fc9c7046c15b9a4e79d # v3.29.5
|
||||
|
||||
2
.github/workflows/ci-tests.yml
vendored
2
.github/workflows/ci-tests.yml
vendored
@@ -35,7 +35,7 @@ jobs:
|
||||
--verbosity minimal
|
||||
|
||||
- name: Merge code coverage results
|
||||
uses: danielpalme/ReportGenerator-GitHub-Action@4c0f60daf67483745c34efdeadd4c4e78a19991e # v5.4.8
|
||||
uses: danielpalme/ReportGenerator-GitHub-Action@c1dd332d00304c5aa5d506aab698a5224a8fa24e # 5.4.11
|
||||
with:
|
||||
reports: "**/coverage.cobertura.xml"
|
||||
targetdir: "merged/"
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
- [ikomhoog](https://github.com/ikomhoog)
|
||||
- [iwalton3](https://github.com/iwalton3)
|
||||
- [jftuga](https://github.com/jftuga)
|
||||
- [jkhsjdhjs](https://github.com/jkhsjdhjs)
|
||||
- [jmshrv](https://github.com/jmshrv)
|
||||
- [joern-h](https://github.com/joern-h)
|
||||
- [joshuaboniface](https://github.com/joshuaboniface)
|
||||
@@ -196,9 +197,12 @@
|
||||
- [Kenneth Cochran](https://github.com/kennethcochran)
|
||||
- [benedikt257](https://github.com/benedikt257)
|
||||
- [revam](https://github.com/revam)
|
||||
- [Jxiced](https://github.com/Jxiced)
|
||||
- [allesmi](https://github.com/allesmi)
|
||||
- [ThunderClapLP](https://github.com/ThunderClapLP)
|
||||
- [Shoham Peller](https://github.com/spellr)
|
||||
- [theshoeshiner](https://github.com/theshoeshiner)
|
||||
- [TokerX](https://github.com/TokerX)
|
||||
|
||||
# Emby Contributors
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<PackageVersion Include="BlurHashSharp" Version="1.4.0-pre.1" />
|
||||
<PackageVersion Include="CommandLineParser" Version="2.9.1" />
|
||||
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageVersion Include="Diacritics" Version="4.0.14" />
|
||||
<PackageVersion Include="Diacritics" Version="4.0.17" />
|
||||
<PackageVersion Include="DiscUtils.Udf" Version="0.16.13" />
|
||||
<PackageVersion Include="DotNet.Glob" Version="3.1.3" />
|
||||
<PackageVersion Include="FsCheck.Xunit" Version="3.3.0" />
|
||||
@@ -53,7 +53,7 @@
|
||||
<PackageVersion Include="MimeTypes" Version="2.5.2" />
|
||||
<PackageVersion Include="Morestachio" Version="5.0.1.631" />
|
||||
<PackageVersion Include="Moq" Version="4.18.4" />
|
||||
<PackageVersion Include="NEbml" Version="0.12.0" />
|
||||
<PackageVersion Include="NEbml" Version="1.0.0.3" />
|
||||
<PackageVersion Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageVersion Include="PlaylistsNET" Version="1.4.1" />
|
||||
<PackageVersion Include="prometheus-net.AspNetCore" Version="8.2.1" />
|
||||
@@ -76,7 +76,7 @@
|
||||
<PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="3.116.1" />
|
||||
<PackageVersion Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" />
|
||||
<PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" />
|
||||
<PackageVersion Include="Svg.Skia" Version="3.0.3" />
|
||||
<PackageVersion Include="Svg.Skia" Version="3.0.4" />
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="6.5.0" />
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore" Version="6.2.3" />
|
||||
<PackageVersion Include="System.Globalization" Version="4.3.0" />
|
||||
@@ -85,7 +85,7 @@
|
||||
<PackageVersion Include="System.Text.Json" Version="9.0.7" />
|
||||
<PackageVersion Include="System.Threading.Tasks.Dataflow" Version="9.0.7" />
|
||||
<PackageVersion Include="TagLibSharp" Version="2.3.0" />
|
||||
<PackageVersion Include="z440.atl.core" Version="7.0.0" />
|
||||
<PackageVersion Include="z440.atl.core" Version="7.2.0" />
|
||||
<PackageVersion Include="TMDbLib" Version="2.2.0" />
|
||||
<PackageVersion Include="UTF.Unknown" Version="2.5.1" />
|
||||
<PackageVersion Include="Xunit.Priority" Version="1.1.6" />
|
||||
|
||||
@@ -572,6 +572,18 @@ namespace Emby.Naming.Common
|
||||
"trailer",
|
||||
MediaType.Video),
|
||||
|
||||
new ExtraRule(
|
||||
ExtraType.Sample,
|
||||
ExtraRuleType.Filename,
|
||||
"sample",
|
||||
MediaType.Video),
|
||||
|
||||
new ExtraRule(
|
||||
ExtraType.ThemeSong,
|
||||
ExtraRuleType.Filename,
|
||||
"theme",
|
||||
MediaType.Audio),
|
||||
|
||||
new ExtraRule(
|
||||
ExtraType.Trailer,
|
||||
ExtraRuleType.Suffix,
|
||||
@@ -593,13 +605,7 @@ namespace Emby.Naming.Common
|
||||
new ExtraRule(
|
||||
ExtraType.Trailer,
|
||||
ExtraRuleType.Suffix,
|
||||
" trailer",
|
||||
MediaType.Video),
|
||||
|
||||
new ExtraRule(
|
||||
ExtraType.Sample,
|
||||
ExtraRuleType.Filename,
|
||||
"sample",
|
||||
"- trailer",
|
||||
MediaType.Video),
|
||||
|
||||
new ExtraRule(
|
||||
@@ -623,15 +629,9 @@ namespace Emby.Naming.Common
|
||||
new ExtraRule(
|
||||
ExtraType.Sample,
|
||||
ExtraRuleType.Suffix,
|
||||
" sample",
|
||||
"- sample",
|
||||
MediaType.Video),
|
||||
|
||||
new ExtraRule(
|
||||
ExtraType.ThemeSong,
|
||||
ExtraRuleType.Filename,
|
||||
"theme",
|
||||
MediaType.Audio),
|
||||
|
||||
new ExtraRule(
|
||||
ExtraType.Scene,
|
||||
ExtraRuleType.Suffix,
|
||||
|
||||
@@ -97,14 +97,18 @@ namespace Emby.Naming.ExternalFiles
|
||||
|
||||
if (culture is not null && pathInfo.Language is null)
|
||||
{
|
||||
pathInfo.Language = culture.ThreeLetterISOLanguageName;
|
||||
pathInfo.Language = culture.Name.Contains('-', StringComparison.OrdinalIgnoreCase)
|
||||
? culture.Name
|
||||
: culture.ThreeLetterISOLanguageName;
|
||||
extraString = extraString.Replace(currentSlice, string.Empty, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
else if (culture is not null && pathInfo.Language == "hin")
|
||||
{
|
||||
// Hindi language code "hi" collides with a hearing impaired flag - use as Hindi only if no other language is set
|
||||
pathInfo.IsHearingImpaired = true;
|
||||
pathInfo.Language = culture.ThreeLetterISOLanguageName;
|
||||
pathInfo.Language = culture.Name.Contains('-', StringComparison.OrdinalIgnoreCase)
|
||||
? culture.Name
|
||||
: culture.ThreeLetterISOLanguageName;
|
||||
extraString = extraString.Replace(currentSlice, string.Empty, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
else if (_namingOptions.MediaHearingImpairedFlags.Any(s => currentSliceWithoutSeparator.Equals(s, StringComparison.OrdinalIgnoreCase)))
|
||||
|
||||
@@ -22,67 +22,45 @@ namespace Emby.Naming.Video
|
||||
/// <returns>Returns <see cref="ExtraResult"/> object.</returns>
|
||||
public static ExtraResult GetExtraInfo(string path, NamingOptions namingOptions, string? libraryRoot = "")
|
||||
{
|
||||
var result = new ExtraResult();
|
||||
ExtraResult result = new ExtraResult();
|
||||
|
||||
for (var i = 0; i < namingOptions.VideoExtraRules.Length; i++)
|
||||
bool isAudioFile = AudioFileParser.IsAudioFile(path, namingOptions);
|
||||
bool isVideoFile = VideoResolver.IsVideoFile(path, namingOptions);
|
||||
|
||||
ReadOnlySpan<char> pathSpan = path.AsSpan();
|
||||
ReadOnlySpan<char> fileName = Path.GetFileName(pathSpan);
|
||||
ReadOnlySpan<char> fileNameWithoutExtension = Path.GetFileNameWithoutExtension(pathSpan);
|
||||
// Trim the digits from the end of the filename so we can recognize things like -trailer2
|
||||
ReadOnlySpan<char> trimmedFileNameWithoutExtension = fileNameWithoutExtension.TrimEnd(_digits);
|
||||
ReadOnlySpan<char> directoryName = Path.GetFileName(Path.GetDirectoryName(pathSpan));
|
||||
string fullDirectory = Path.GetDirectoryName(pathSpan).ToString();
|
||||
|
||||
foreach (ExtraRule rule in namingOptions.VideoExtraRules)
|
||||
{
|
||||
var rule = namingOptions.VideoExtraRules[i];
|
||||
if ((rule.MediaType == MediaType.Audio && !AudioFileParser.IsAudioFile(path, namingOptions))
|
||||
|| (rule.MediaType == MediaType.Video && !VideoResolver.IsVideoFile(path, namingOptions)))
|
||||
if ((rule.MediaType == MediaType.Audio && !isAudioFile)
|
||||
|| (rule.MediaType == MediaType.Video && !isVideoFile))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var pathSpan = path.AsSpan();
|
||||
if (rule.RuleType == ExtraRuleType.Filename)
|
||||
bool isMatch = rule.RuleType switch
|
||||
{
|
||||
var filename = Path.GetFileNameWithoutExtension(pathSpan);
|
||||
ExtraRuleType.Filename => fileNameWithoutExtension.Equals(rule.Token, StringComparison.OrdinalIgnoreCase),
|
||||
ExtraRuleType.Suffix => trimmedFileNameWithoutExtension.EndsWith(rule.Token, StringComparison.OrdinalIgnoreCase),
|
||||
ExtraRuleType.Regex => Regex.IsMatch(fileName, rule.Token, RegexOptions.IgnoreCase | RegexOptions.Compiled),
|
||||
ExtraRuleType.DirectoryName => directoryName.Equals(rule.Token, StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(fullDirectory, libraryRoot, StringComparison.OrdinalIgnoreCase),
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if (filename.Equals(rule.Token, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
result.ExtraType = rule.ExtraType;
|
||||
result.Rule = rule;
|
||||
}
|
||||
}
|
||||
else if (rule.RuleType == ExtraRuleType.Suffix)
|
||||
if (!isMatch)
|
||||
{
|
||||
// Trim the digits from the end of the filename so we can recognize things like -trailer2
|
||||
var filename = Path.GetFileNameWithoutExtension(pathSpan).TrimEnd(_digits);
|
||||
|
||||
if (filename.EndsWith(rule.Token, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
result.ExtraType = rule.ExtraType;
|
||||
result.Rule = rule;
|
||||
}
|
||||
}
|
||||
else if (rule.RuleType == ExtraRuleType.Regex)
|
||||
{
|
||||
var filename = Path.GetFileName(path.AsSpan());
|
||||
|
||||
var isMatch = Regex.IsMatch(filename, rule.Token, RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
if (isMatch)
|
||||
{
|
||||
result.ExtraType = rule.ExtraType;
|
||||
result.Rule = rule;
|
||||
}
|
||||
}
|
||||
else if (rule.RuleType == ExtraRuleType.DirectoryName)
|
||||
{
|
||||
var directoryName = Path.GetFileName(Path.GetDirectoryName(pathSpan));
|
||||
string fullDirectory = Path.GetDirectoryName(pathSpan).ToString();
|
||||
if (directoryName.Equals(rule.Token, StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(fullDirectory, libraryRoot, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
result.ExtraType = rule.ExtraType;
|
||||
result.Rule = rule;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (result.ExtraType is not null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
result.ExtraType = rule.ExtraType;
|
||||
result.Rule = rule;
|
||||
return result;
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
@@ -49,7 +49,7 @@ public class PhotoProvider : ICustomMetadataProvider<Photo>, IForcedProvider, IH
|
||||
if (item.IsFileProtocol)
|
||||
{
|
||||
var file = directoryService.GetFile(item.Path);
|
||||
return file is not null && file.LastWriteTimeUtc != item.DateModified;
|
||||
return file is not null && item.HasChanged(file.LastWriteTimeUtc);
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
@@ -38,7 +38,8 @@ namespace Emby.Server.Implementations.Library
|
||||
}
|
||||
|
||||
// Don't ignore top level folders
|
||||
if (fileInfo.IsDirectory && parent is AggregateFolder)
|
||||
if (fileInfo.IsDirectory
|
||||
&& (parent is AggregateFolder || (parent?.IsTopParent ?? false)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -48,35 +49,21 @@ namespace Emby.Server.Implementations.Library
|
||||
return true;
|
||||
}
|
||||
|
||||
var filename = fileInfo.Name;
|
||||
if (parent is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (fileInfo.IsDirectory)
|
||||
{
|
||||
if (parent is not null)
|
||||
{
|
||||
// Ignore extras for unsupported types
|
||||
if (_namingOptions.AllExtrasTypesFolderNames.ContainsKey(filename)
|
||||
&& parent is not AggregateFolder
|
||||
&& parent is not UserRootFolder)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (parent is not null)
|
||||
{
|
||||
// Don't resolve theme songs
|
||||
if (Path.GetFileNameWithoutExtension(filename.AsSpan()).Equals(BaseItem.ThemeSongFileName, StringComparison.Ordinal)
|
||||
&& AudioFileParser.IsAudioFile(filename, _namingOptions))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Ignore extras for unsupported types
|
||||
return _namingOptions.AllExtrasTypesFolderNames.ContainsKey(fileInfo.Name)
|
||||
&& parent is not UserRootFolder;
|
||||
}
|
||||
|
||||
return false;
|
||||
// Don't resolve theme songs
|
||||
return Path.GetFileNameWithoutExtension(fileInfo.Name.AsSpan()).Equals(BaseItem.ThemeSongFileName, StringComparison.Ordinal)
|
||||
&& AudioFileParser.IsAudioFile(fileInfo.Name, _namingOptions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,19 @@ public class DotIgnoreIgnoreRule : IResolverIgnoreRule
|
||||
/// <returns>True if the file should be ignored.</returns>
|
||||
public static bool IsIgnored(FileSystemMetadata fileInfo, BaseItem? parent)
|
||||
{
|
||||
if (fileInfo.IsDirectory)
|
||||
{
|
||||
var dirIgnoreFile = FindIgnoreFile(new DirectoryInfo(fileInfo.FullName));
|
||||
if (dirIgnoreFile is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// ignore the directory only if the .ignore file is empty
|
||||
// evaluate individual files otherwise
|
||||
return string.IsNullOrWhiteSpace(GetFileContent(dirIgnoreFile));
|
||||
}
|
||||
|
||||
var parentDirPath = Path.GetDirectoryName(fileInfo.FullName);
|
||||
if (string.IsNullOrEmpty(parentDirPath))
|
||||
{
|
||||
@@ -55,13 +68,9 @@ public class DotIgnoreIgnoreRule : IResolverIgnoreRule
|
||||
return false;
|
||||
}
|
||||
|
||||
string ignoreFileString;
|
||||
using (var reader = ignoreFile.OpenText())
|
||||
{
|
||||
ignoreFileString = reader.ReadToEnd();
|
||||
}
|
||||
string ignoreFileString = GetFileContent(ignoreFile);
|
||||
|
||||
if (string.IsNullOrEmpty(ignoreFileString))
|
||||
if (string.IsNullOrWhiteSpace(ignoreFileString))
|
||||
{
|
||||
// Ignore directory if we just have the file
|
||||
return true;
|
||||
@@ -74,4 +83,12 @@ public class DotIgnoreIgnoreRule : IResolverIgnoreRule
|
||||
|
||||
return ignore.IsIgnored(fileInfo.FullName);
|
||||
}
|
||||
|
||||
private static string GetFileContent(FileInfo dirIgnoreFile)
|
||||
{
|
||||
using (var reader = dirIgnoreFile.OpenText())
|
||||
{
|
||||
return reader.ReadToEnd();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1954,7 +1954,7 @@ namespace Emby.Server.Implementations.Library
|
||||
|
||||
try
|
||||
{
|
||||
return _fileSystem.GetLastWriteTimeUtc(image.Path) != image.DateModified;
|
||||
return image.DateModified.Subtract(_fileSystem.GetLastWriteTimeUtc(image.Path)).Duration().TotalSeconds > 1;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -3060,6 +3060,8 @@ namespace Emby.Server.Implementations.Library
|
||||
}
|
||||
|
||||
await RunMetadataSavers(personEntity, itemUpdateType).ConfigureAwait(false);
|
||||
personEntity.DateLastSaved = DateTime.UtcNow;
|
||||
|
||||
CreateItems([personEntity], null, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,7 +379,7 @@ namespace Emby.Server.Implementations.Library
|
||||
var culture = _localizationManager.FindLanguageInfo(language);
|
||||
if (culture is not null)
|
||||
{
|
||||
return culture.ThreeLetterISOLanguageNames;
|
||||
return culture.Name.Contains('-', StringComparison.OrdinalIgnoreCase) ? [culture.Name] : culture.ThreeLetterISOLanguageNames;
|
||||
}
|
||||
|
||||
return [language];
|
||||
|
||||
@@ -135,5 +135,7 @@
|
||||
"TaskDownloadMissingLyrics": "Спампаваць зніклыя тэксты песень",
|
||||
"TaskDownloadMissingLyricsDescription": "Спампоўвае тэксты для песень",
|
||||
"TaskExtractMediaSegments": "Сканіраванне медыя-сегмента",
|
||||
"TaskMoveTrickplayImages": "Перанесці месцазнаходжанне выявы Trickplay"
|
||||
"TaskMoveTrickplayImages": "Перанесці месцазнаходжанне выявы Trickplay",
|
||||
"CleanupUserDataTask": "Задача па ачыстцы дадзеных карыстальніка",
|
||||
"CleanupUserDataTaskDescription": "Ачысьціць усе дадзеныя карыстальніка (стан прагляду, абранае і г.д.) для медыяфайлаў, што адсутнічаюць больш за 90 дзён."
|
||||
}
|
||||
|
||||
@@ -136,5 +136,7 @@
|
||||
"TaskExtractMediaSegmentsDescription": "Изважда медиини сегменти от MediaSegment плъгини.",
|
||||
"TaskMoveTrickplayImages": "Мигриране на Локацията за Trickplay изображения",
|
||||
"TaskMoveTrickplayImagesDescription": "Премества съществуващите trickplay изображения спрямо настройките на библиотеката.",
|
||||
"TaskExtractMediaSegments": "Сканиране за сегменти"
|
||||
"TaskExtractMediaSegments": "Сканиране за сегменти",
|
||||
"CleanupUserDataTask": "Задача за почистване на потребителски данни",
|
||||
"CleanupUserDataTaskDescription": "Почиства всички потребителски данни (статус на гледане, любими и т.н.) от медия, която вече не е налична от поне 90 дни."
|
||||
}
|
||||
|
||||
@@ -6,29 +6,29 @@
|
||||
"Channels": "চ্যানেলসমূহ",
|
||||
"CameraImageUploadedFrom": "{0} থেকে একটি নতুন ক্যামেরার চিত্র আপলোড করা হয়েছে",
|
||||
"Books": "পুস্তকসমূহ",
|
||||
"AuthenticationSucceededWithUserName": "{0} অনুমোদন সফল",
|
||||
"AuthenticationSucceededWithUserName": "{0} সফলভাবে অথেন্টিকেট করেছেন",
|
||||
"Artists": "শিল্পীগণ",
|
||||
"Application": "অ্যাপ্লিকেশন",
|
||||
"Albums": "অ্যালবামসমূহ",
|
||||
"HeaderFavoriteEpisodes": "প্রিব পর্বগুলো",
|
||||
"HeaderFavoriteEpisodes": "প্রিয় পর্বগুলো",
|
||||
"HeaderFavoriteArtists": "প্রিয় শিল্পীরা",
|
||||
"HeaderFavoriteAlbums": "প্রিয় এলবামগুলো",
|
||||
"HeaderContinueWatching": "দেখতে থাকুন",
|
||||
"HeaderAlbumArtists": "অ্যালবাম শিল্পীবৃন্দ",
|
||||
"Genres": "শৈলীধারাসমূহ",
|
||||
"Genres": "জনরা",
|
||||
"Folders": "ফোল্ডারসমূহ",
|
||||
"Favorites": "পছন্দসমূহ",
|
||||
"FailedLoginAttemptWithUserName": "{0} লগিন করতে ব্যর্থ হয়েছে",
|
||||
"AppDeviceValues": "অ্যাপ: {0}, ডিভাইস: {0}",
|
||||
"AppDeviceValues": "অ্যাপ: {0}, ডিভাইস: {1}",
|
||||
"VersionNumber": "সংস্করণ {0}",
|
||||
"ValueSpecialEpisodeName": "বিশেষ পর্ব - {0}",
|
||||
"ValueHasBeenAddedToLibrary": "আপনার লাইব্রেরিতে {0} যোগ করা হয়েছে",
|
||||
"UserStoppedPlayingItemWithValues": "{2}তে {1} বাজানো শেষ করেছেন {0}",
|
||||
"UserStartedPlayingItemWithValues": "{2}তে {1} বাজাচ্ছেন {0}",
|
||||
"UserStoppedPlayingItemWithValues": "{2}তে {1} প্লে শেষ করেছেন {0}",
|
||||
"UserStartedPlayingItemWithValues": "{2}তে {1} প্লে করেছেন {0}",
|
||||
"UserPolicyUpdatedWithName": "{0} এর জন্য ব্যবহার নীতি আপডেট করা হয়েছে",
|
||||
"UserPasswordChangedWithName": "ব্যবহারকারী {0} এর পাসওয়ার্ড পরিবর্তিত হয়েছে",
|
||||
"UserOnlineFromDevice": "{0}, {1} থেকে অনলাইন",
|
||||
"UserOfflineFromDevice": "{0} {1} থেকে বিযুক্ত হয়ে গেছে",
|
||||
"UserOnlineFromDevice": "{0}, {1} থেকে অনলাইন আছে",
|
||||
"UserOfflineFromDevice": "{0} {1} থেকে বিচ্ছিন্ন হয়ে গেছে",
|
||||
"UserLockedOutWithName": "ব্যবহারকারী {0} ঢুকতে পারছে না",
|
||||
"UserDownloadingItemWithValues": "{0}, {1} ডাউনলোড করছে",
|
||||
"UserDeletedWithName": "ব্যবহারকারী {0}কে বাদ দেয়া হয়েছে",
|
||||
@@ -36,8 +36,8 @@
|
||||
"User": "ব্যবহারকারী",
|
||||
"TvShows": "টিভি শোগুলো",
|
||||
"System": "সিস্টেম",
|
||||
"Sync": "সমলয় স্থাপন",
|
||||
"SubtitleDownloadFailureFromForItem": "{2} থেকে {1} এর জন্য সাবটাইটেল ডাউনলোড ব্যর্থ",
|
||||
"Sync": "সমন্বয় করুন",
|
||||
"SubtitleDownloadFailureFromForItem": "{0} থেকে {1} এর জন্য সাবটাইটেল ডাউনলোড ব্যর্থ হয়েছে",
|
||||
"StartupEmbyServerIsLoading": "জেলিফিন সার্ভার লোড হচ্ছে। দয়া করে একটু পরে আবার চেষ্টা করুন।",
|
||||
"Songs": "সঙ্গীতসমূহ",
|
||||
"Shows": "টিভি পর্ব",
|
||||
@@ -46,18 +46,18 @@
|
||||
"ScheduledTaskFailedWithName": "{0} ব্যর্থ",
|
||||
"ProviderValue": "প্রদানকারী: {0}",
|
||||
"PluginUpdatedWithName": "{0} আপডেট করা হয়েছে",
|
||||
"PluginUninstalledWithName": "{0} বাদ দেয়া হয়েছে",
|
||||
"PluginInstalledWithName": "{0} ইন্সটল করা হয়েছে",
|
||||
"PluginUninstalledWithName": "{0} আনইন্সটল হয়েছে",
|
||||
"PluginInstalledWithName": "{0} ইন্সটল হয়েছে",
|
||||
"Plugin": "প্লাগিন",
|
||||
"Playlists": "প্লে লিস্ট সমূহ",
|
||||
"Photos": "চিত্রসমূহ",
|
||||
"NotificationOptionVideoPlaybackStopped": "ভিডিও চলা বন্ধ",
|
||||
"NotificationOptionVideoPlayback": "ভিডিও চলা শুরু হয়েছে",
|
||||
"Photos": "ছবিসমূহ",
|
||||
"NotificationOptionVideoPlaybackStopped": "ভিডিও বন্ধ হয়েছে",
|
||||
"NotificationOptionVideoPlayback": "ভিডিও শুরু হয়েছে",
|
||||
"NotificationOptionUserLockedOut": "ব্যবহারকারী ঢুকতে পারছে না",
|
||||
"NotificationOptionTaskFailed": "পরিকল্পিত কাজটি ব্যর্থ",
|
||||
"NotificationOptionServerRestartRequired": "সার্ভার রিস্টার্ট বাধ্যতামূলক",
|
||||
"NotificationOptionPluginUpdateInstalled": "প্লাগিন আপডেট ইন্সটল করা হয়েছে",
|
||||
"NotificationOptionPluginUninstalled": "প্লাগিন বাদ দেয়া হয়েছে",
|
||||
"NotificationOptionServerRestartRequired": "সার্ভার রিস্টার্ট করা লাগবে",
|
||||
"NotificationOptionPluginUpdateInstalled": "প্লাগিন আপডেট ইন্সটল হয়েছে",
|
||||
"NotificationOptionPluginUninstalled": "প্লাগিন আনইনষ্টল হয়েছে",
|
||||
"NotificationOptionPluginInstalled": "প্লাগিন ইন্সটল করা হয়েছে",
|
||||
"NotificationOptionPluginError": "প্লাগিন ব্যর্থ",
|
||||
"NotificationOptionNewLibraryContent": "নতুন কন্টেন্ট যোগ করা হয়েছে",
|
||||
@@ -76,8 +76,8 @@
|
||||
"Movies": "চলচ্চিত্রসমূহ",
|
||||
"MixedContent": "মিশ্র কন্টেন্ট",
|
||||
"MessageServerConfigurationUpdated": "সার্ভারের কনফিগারেশন আপডেট করা হয়েছে",
|
||||
"HeaderRecordingGroups": "রেকর্ডিং দল",
|
||||
"MessageNamedServerConfigurationUpdatedWithValue": "সার্ভারের {0} কনফিগারেসনের অংশ আপডেট করা হয়েছে",
|
||||
"HeaderRecordingGroups": "রেকর্ডিং গ্রুপগুলো",
|
||||
"MessageNamedServerConfigurationUpdatedWithValue": "সার্ভার কনফিগারেশন সেকশন {0} আপডেট করা হয়েছে",
|
||||
"MessageApplicationUpdatedTo": "জেলিফিন সার্ভার {0} তে আপডেট করা হয়েছে",
|
||||
"MessageApplicationUpdated": "জেলিফিন সার্ভার আপডেট করা হয়েছে",
|
||||
"Latest": "সর্বশেষ",
|
||||
@@ -85,51 +85,57 @@
|
||||
"LabelIpAddressValue": "আইপি এড্রেস: {0}",
|
||||
"ItemRemovedWithName": "{0} লাইব্রেরি থেকে বাদ দেয়া হয়েছে",
|
||||
"ItemAddedWithName": "{0} লাইব্রেরিতে যোগ করা হয়েছে",
|
||||
"Inherit": "থেকে পাওয়া",
|
||||
"Inherit": "মূল থেকে গ্রহণ করুন",
|
||||
"HomeVideos": "হোম ভিডিও",
|
||||
"HeaderNextUp": "এরপরে আসছে",
|
||||
"HeaderLiveTV": "লাইভ টিভি",
|
||||
"HeaderFavoriteSongs": "প্রিয় গানগুলো",
|
||||
"HeaderFavoriteShows": "প্রিয় শোগুলো",
|
||||
"TasksLibraryCategory": "গ্রন্থাগার",
|
||||
"TasksLibraryCategory": "লাইব্রেরি",
|
||||
"TasksMaintenanceCategory": "রক্ষণাবেক্ষণ",
|
||||
"TaskRefreshLibrary": "স্ক্যান মিডিয়া লাইব্রেরি",
|
||||
"TaskRefreshChapterImagesDescription": "অধ্যায়গুলিতে থাকা ভিডিওগুলির জন্য থাম্বনেইল তৈরি ।",
|
||||
"TaskRefreshChapterImages": "অধ্যায়ের চিত্রগুলি বের করুন",
|
||||
"TaskCleanCacheDescription": "সিস্টেমে আর প্রয়োজন নেই ক্যাশ, ফাইলগুলি মুছে ফেলুন।",
|
||||
"TaskRefreshChapterImagesDescription": "যেসব ভিডিওতে চ্যাপ্টার রয়েছে, তাদের জন্য থাম্বনেইল তৈরি করবে।",
|
||||
"TaskRefreshChapterImages": "চ্যাপ্টার ইমেজ বের করুন",
|
||||
"TaskCleanCacheDescription": "সিস্টেমের অপ্রয়োজনীয় ক্যাশ ফাইলগুলো মুছে ফেলবে।",
|
||||
"TaskCleanCache": "ক্লিন ক্যাশ ডিরেক্টরি",
|
||||
"TasksChannelsCategory": "ইন্টারনেট চ্যানেল",
|
||||
"TasksApplicationCategory": "আবেদন",
|
||||
"TasksApplicationCategory": "অ্যাপ্লিকেশন",
|
||||
"TaskDownloadMissingSubtitlesDescription": "মেটাডেটা কনফিগারেশনের উপর ভিত্তি করে অনুপস্থিত সাবটাইটেলগুলির জন্য ইন্টারনেট অনুসন্ধান করে।",
|
||||
"TaskDownloadMissingSubtitles": "অনুপস্থিত সাবটাইটেলগুলি ডাউনলোড করুন",
|
||||
"TaskRefreshChannelsDescription": "ইন্টারনেট চ্যানেল তথ্য রিফ্রেশ করুন।",
|
||||
"TaskRefreshChannels": "চ্যানেল রিফ্রেশ করুন",
|
||||
"TaskCleanTranscodeDescription": "এক দিনেরও বেশি পুরানো ট্রান্সকোড ফাইলগুলি মুছে ফেলুন।",
|
||||
"TaskCleanTranscodeDescription": "এক দিনেরও বেশি পুরানো ট্রান্সকোড ফাইলগুলি মুছে ফেলবে।",
|
||||
"TaskCleanTranscode": "ট্রান্সকোড ডিরেক্টরি ক্লিন করুন",
|
||||
"TaskUpdatePluginsDescription": "স্বয়ংক্রিয়ভাবে আপডেট কনফিগার করা প্লাগইনগুলির জন্য আপডেট ডাউনলোড এবং ইনস্টল করুন।",
|
||||
"TaskUpdatePlugins": "প্লাগইন আপডেট করুন",
|
||||
"TaskRefreshPeopleDescription": "আপনার মিডিয়া লাইব্রেরিতে অভিনেতা এবং পরিচালকদের জন্য মেটাডাটা আপডেট করুন।",
|
||||
"TaskRefreshPeople": "পিপল রিফ্রেশ করুন",
|
||||
"TaskCleanLogsDescription": "{0} দিনের বেশী পুরানো লগ ফাইলগুলি মুছে ফেলুন।",
|
||||
"TaskCleanLogs": "লগ ডিরেক্টরি ক্লিন করুন",
|
||||
"TaskRefreshLibraryDescription": "নতুন ফাইলের জন্য মিডিয়া লাইব্রেরি স্ক্যান এবং মেটাডাটা রিফ্রেশ করুন।",
|
||||
"TaskUpdatePlugins": "আপডেট প্লাগইন",
|
||||
"TaskRefreshPeopleDescription": "আপনার মিডিয়া লাইব্রেরিতে অভিনেতা এবং পরিচালকদের জন্য মেটাডাটা আপডেট করবে।",
|
||||
"TaskRefreshPeople": "ব্যক্তিদের তথ্য রিফ্রেশ",
|
||||
"TaskCleanLogsDescription": "{0} দিনের বেশী পুরানো লগ ফাইলগুলি মুছে ফেলবে।",
|
||||
"TaskCleanLogs": "ক্লিন লগ ডিরেক্টরি",
|
||||
"TaskRefreshLibraryDescription": "নতুন ফাইলের জন্য মিডিয়া লাইব্রেরি স্ক্যান এবং মেটাডাটা রিফ্রেশ করবে।",
|
||||
"Undefined": "অসঙ্গায়িত",
|
||||
"Forced": "জোরকরে",
|
||||
"TaskCleanActivityLogDescription": "নির্ধারিত সময়ের আগের কাজের হিসাব মুছে দিন খালি করুন.",
|
||||
"TaskCleanActivityLog": "কাজের ফাইল খালি করুন",
|
||||
"TaskCleanActivityLogDescription": "নির্ধারিত সময়ের আগের অ্যাক্টিভিটি লগ মুছে দিবে।",
|
||||
"TaskCleanActivityLog": "অ্যাক্টিভিটি লগ মুছুন",
|
||||
"Default": "ডিফল্ট",
|
||||
"HearingImpaired": "দুর্বল শ্রবণক্ষমতাধরদের জন্য",
|
||||
"HearingImpaired": "শ্রবণ প্রতিবন্ধী",
|
||||
"TaskOptimizeDatabaseDescription": "তথ্যভাণ্ডার সুবিন্যস্ত করে ও অব্যবহৃত জায়গা ছেড়ে দেয়। লাইব্রেরী স্ক্যান অথবা যেকোনো তথ্যভাণ্ডার পরিবর্তনের পর এই প্রক্রিয়া চালালে তথ্যভাণ্ডারের তথ্য প্রদান দ্রুততর হতে পারে।",
|
||||
"External": "বাহ্যিক",
|
||||
"TaskOptimizeDatabase": "তথ্যভাণ্ডার সুবিন্যাস",
|
||||
"TaskKeyframeExtractor": "কি-ফ্রেম নিষ্কাশক",
|
||||
"TaskKeyframeExtractorDescription": "ভিডিয়ো থেকে কি-ফ্রেম নিষ্কাশনের মাধ্যমে অধিকতর সঠিক HLS প্লে লিস্ট তৈরী করে। এই প্রক্রিয়া দীর্ঘ সময় ধরে চলতে পারে।",
|
||||
"TaskRefreshTrickplayImages": "ট্রিকপ্লে ইমেজ তৈরি করুন",
|
||||
"TaskRefreshTrickplayImages": "ট্রিকপ্লে ইমেজ তৈরি",
|
||||
"TaskRefreshTrickplayImagesDescription": "সক্ষম লাইব্রেরিতে ভিডিওর জন্য ট্রিকপ্লে প্রিভিউ তৈরি করে।",
|
||||
"TaskDownloadMissingLyricsDescription": "গানের লিরিক্স ডাউনলোড করে",
|
||||
"TaskCleanCollectionsAndPlaylists": "সংগ্রহ এবং প্লেলিস্ট পরিষ্কার করুন",
|
||||
"TaskCleanCollectionsAndPlaylistsDescription": "সংগ্রহ এবং প্লেলিস্ট থেকে আইটেমগুলি সরিয়ে দেয় যা আর বিদ্যমান নেই।",
|
||||
"TaskCleanCollectionsAndPlaylists": "কালেকশন এবং প্লেলিস্ট পরিষ্কার করুন",
|
||||
"TaskCleanCollectionsAndPlaylistsDescription": "কালেকশন এবং প্লেলিস্ট থেকে আইটেমগুলি সরিয়ে দেয় যা আর বিদ্যমান নেই।",
|
||||
"TaskExtractMediaSegments": "মিডিয়া সেগমেন্ট স্ক্যান",
|
||||
"TaskExtractMediaSegmentsDescription": "MediaSegment সক্ষম প্লাগইনগুলি থেকে মিডিয়া সেগমেন্টগুলি বের করে বা প্রাপ্ত করে।",
|
||||
"TaskDownloadMissingLyrics": "অনুপস্থিত গান ডাউনলোড করুন"
|
||||
"TaskExtractMediaSegmentsDescription": "মিডিয়া সেগমেন্ট সক্রিয় প্লাগইনগুলি থেকে মিডিয়া সেগমেন্টগুলি বের করে বা প্রাপ্ত করে।",
|
||||
"TaskDownloadMissingLyrics": "অনুপস্থিত গান ডাউনলোড করুন",
|
||||
"TaskMoveTrickplayImagesDescription": "লাইব্রেরির সেটিং অনুযায়ী বিদ্যমান ট্রিকপ্লে ফাইলগুলো সরিয়ে নেবে।",
|
||||
"TaskAudioNormalizationDescription": "অডিও নর্মালাইজেশন তথ্যের জন্য ফাইল স্ক্যান করবে।",
|
||||
"CleanupUserDataTaskDescription": "৯০ দিন বা তার বেশি সময় ধরে অনুপস্থিত মিডিয়া থেকে সকল ব্যবহারকারীর ডেটা (ওয়াচ স্টেট, ফেভারিট স্ট্যাটাস ইত্যাদি) মুছে ফেলবে।",
|
||||
"TaskMoveTrickplayImages": "ট্রিকপ্লে ইমেজের অবস্থান পরিবর্তন",
|
||||
"TaskAudioNormalization": "অডিও নর্মলাইজেশন",
|
||||
"CleanupUserDataTask": "ব্যবহারকারীর ডেটা পরিষ্কারের কাজ"
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@
|
||||
"TaskDownloadMissingSubtitles": "Descàrrega dels subtítols que faltin",
|
||||
"TaskRefreshChannelsDescription": "Actualitza la informació dels canals per internet.",
|
||||
"TaskRefreshChannels": "Actualitza els canals",
|
||||
"TaskCleanTranscodeDescription": "Elimina els arxius de transcodificacions que tinguin més d'un dia.",
|
||||
"TaskCleanTranscodeDescription": "Elimina els fitxers de transcodificacions que tinguin més d'un dia.",
|
||||
"TaskCleanTranscode": "Neteja de les transcodificacions",
|
||||
"TaskUpdatePluginsDescription": "Descarrega i instal·la els complements que estiguin configurats per a actualitzar-se automàticament.",
|
||||
"TaskUpdatePlugins": "Actualització dels complements",
|
||||
@@ -104,8 +104,8 @@
|
||||
"TaskRefreshPeople": "Actualització de les persones",
|
||||
"TaskCleanLogsDescription": "Esborra els registres que tinguin més de {0} dies.",
|
||||
"TaskCleanLogs": "Neteja dels registres",
|
||||
"TaskRefreshLibraryDescription": "Escaneig de la mediateca, a la cerca de fitxers nous i refresca les metadades.",
|
||||
"TaskRefreshLibrary": "Escaneig de la mediateca",
|
||||
"TaskRefreshLibraryDescription": "Escaneja les mediateques, a la cerca de fitxers nous i refresca les metadades.",
|
||||
"TaskRefreshLibrary": "Escaneig de les mediateques",
|
||||
"TaskRefreshChapterImagesDescription": "Creació de les miniatures dels vídeos que tinguin capítols.",
|
||||
"TaskRefreshChapterImages": "Extracció de les imatges dels capítols",
|
||||
"TaskCleanCacheDescription": "Eliminació de la memòria cau no necessària per al servidor.",
|
||||
@@ -115,14 +115,14 @@
|
||||
"TasksLibraryCategory": "Mediateca",
|
||||
"TasksMaintenanceCategory": "Manteniment",
|
||||
"TaskCleanActivityLogDescription": "Eliminació de les entrades del registre d'activitats més antigues que l'antiguitat configurada.",
|
||||
"TaskCleanActivityLog": "Buidat del registre d'activitat",
|
||||
"TaskCleanActivityLog": "Buidatge del registre d'activitat",
|
||||
"Undefined": "Indefinit",
|
||||
"Forced": "Forçat",
|
||||
"Default": "Per defecte",
|
||||
"TaskOptimizeDatabaseDescription": "Compacta la base de dades i trunca l'espai lliure. Executar aquesta tasca després d’escanejar la mediateca o fer d'altres canvis que impliquin modificacions a la base de dades pot millorar el rendiment.",
|
||||
"TaskOptimizeDatabase": "Optimització de la base de dades",
|
||||
"TaskKeyframeExtractorDescription": "Extractor de fotogrames clau dels fitxers de vídeo per a crear llistes de reproducció HLS més precises. Aquesta tasca pot allargar-se molt en el temps.",
|
||||
"TaskKeyframeExtractor": "Extractor de fotogrames clau",
|
||||
"TaskKeyframeExtractorDescription": "Extracció de fotogrames clau dels fitxers de vídeo per a crear llistes de reproducció HLS més precises. Aquesta tasca pot allargar-se molt en el temps.",
|
||||
"TaskKeyframeExtractor": "Extracció de fotogrames clau",
|
||||
"External": "Extern",
|
||||
"HearingImpaired": "Discapacitat auditiva",
|
||||
"TaskRefreshTrickplayImages": "Generació d'imatges de previsualització",
|
||||
@@ -130,7 +130,7 @@
|
||||
"TaskCleanCollectionsAndPlaylistsDescription": "Esborra elements de col·leccions i llistes de reproducció que ja no existeixen.",
|
||||
"TaskCleanCollectionsAndPlaylists": "Neteja de les col·leccions i llistes de reproducció",
|
||||
"TaskAudioNormalization": "Estabilització de l'àudio",
|
||||
"TaskAudioNormalizationDescription": "Escaneja arxius per dades d'estabilització de l'àudio.",
|
||||
"TaskAudioNormalizationDescription": "Escaneja els fitxer per a obtenir dades de normalització de l'àudio.",
|
||||
"TaskDownloadMissingLyricsDescription": "Descàrrega de les lletres de les cançons",
|
||||
"TaskDownloadMissingLyrics": "Descàrrega de les lletres que faltin",
|
||||
"TaskExtractMediaSegments": "Escaneig de segments multimèdia",
|
||||
|
||||
@@ -136,5 +136,7 @@
|
||||
"TaskExtractMediaSegments": "Scan for mediesegmenter",
|
||||
"TaskMoveTrickplayImages": "Migrer billedelokationer for trickplay-billeder",
|
||||
"TaskMoveTrickplayImagesDescription": "Flyt eksisterende trickplay-billeder jævnfør biblioteksindstillinger.",
|
||||
"TaskExtractMediaSegmentsDescription": "Udtrækker eller henter mediesegmenter fra plugins som understøtter MediaSegment."
|
||||
"TaskExtractMediaSegmentsDescription": "Udtrækker eller henter mediesegmenter fra plugins som understøtter MediaSegment.",
|
||||
"CleanupUserDataTask": "Brugerdata oprydningsopgave",
|
||||
"CleanupUserDataTaskDescription": "Rydder alle brugerdata (eks. visning- og favoritstatus) fra medier, der har været utilgængelige i mindst 90 dage."
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
"UserStartedPlayingItemWithValues": "{0} hat die Wiedergabe von {1} auf {2} gestartet",
|
||||
"UserStoppedPlayingItemWithValues": "{0} hat die Wiedergabe von {1} auf {2} beendet",
|
||||
"ValueHasBeenAddedToLibrary": "{0} wurde deiner Bibliothek hinzugefügt",
|
||||
"ValueSpecialEpisodeName": "Extra - {0}",
|
||||
"ValueSpecialEpisodeName": "Extra – {0}",
|
||||
"VersionNumber": "Version {0}",
|
||||
"TaskDownloadMissingSubtitlesDescription": "Sucht im Internet basierend auf den Metadaten-Einstellungen nach fehlenden Untertiteln.",
|
||||
"TaskDownloadMissingSubtitles": "Fehlende Untertitel herunterladen",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"Albums": "Albumok",
|
||||
"AppDeviceValues": "Program: {0}, eszköz: {1}",
|
||||
"AppDeviceValues": "Program: {0}, Eszköz: {1}",
|
||||
"Application": "Alkalmazás",
|
||||
"Artists": "Előadók",
|
||||
"AuthenticationSucceededWithUserName": "{0} sikeresen hitelesítve",
|
||||
|
||||
@@ -131,5 +131,8 @@
|
||||
"TaskCleanCollectionsAndPlaylists": "Hreinsa söfn og spilunarlista",
|
||||
"TaskCleanCollectionsAndPlaylistsDescription": "Fjarlægir hluti úr söfnum og spilalistum sem eru ekki lengur til.",
|
||||
"TaskDownloadMissingLyricsDescription": "Sækja söngtexta fyrir lög",
|
||||
"TaskDownloadMissingLyrics": "Sækja söngtexta sem vantar"
|
||||
"TaskDownloadMissingLyrics": "Sækja söngtexta sem vantar",
|
||||
"TaskExtractMediaSegments": "Skönnun efnishluta",
|
||||
"CleanupUserDataTask": "Hreinsun notendagagna",
|
||||
"CleanupUserDataTaskDescription": "Hreinsar öll notendagögn (spilunarstöðu, uppáhöld o.s.frv.) um gögn sem hafa ekki verið til staðar í að lámarki 90 daga."
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"CameraImageUploadedFrom": "Nauja nuotrauka įkelta iš kameros {0}",
|
||||
"Channels": "Kanalai",
|
||||
"ChapterNameValue": "Scena{0}",
|
||||
"Collections": "Kolekcijos",
|
||||
"Collections": "Rinkiniai",
|
||||
"DeviceOfflineWithName": "{0} buvo atjungtas",
|
||||
"DeviceOnlineWithName": "{0} prisijungęs",
|
||||
"FailedLoginAttemptWithUserName": "Nesėkmingas {0} bandymas prisijungti",
|
||||
@@ -17,18 +17,18 @@
|
||||
"Genres": "Žanrai",
|
||||
"HeaderAlbumArtists": "Albumo atlikėjai",
|
||||
"HeaderContinueWatching": "Žiūrėti toliau",
|
||||
"HeaderFavoriteAlbums": "Mėgstami Albumai",
|
||||
"HeaderFavoriteArtists": "Mėgstami Atlikėjai",
|
||||
"HeaderFavoriteAlbums": "Mėgstami albumai",
|
||||
"HeaderFavoriteArtists": "Mėgstami atlikėjai",
|
||||
"HeaderFavoriteEpisodes": "Mėgstamiausios serijos",
|
||||
"HeaderFavoriteShows": "Mėgstamiausios TV Laidos",
|
||||
"HeaderFavoriteSongs": "Mėgstamos Dainos",
|
||||
"HeaderLiveTV": "Tiesioginė TV",
|
||||
"HeaderNextUp": "Toliau eilėje",
|
||||
"HeaderNextUp": "Toliau",
|
||||
"HeaderRecordingGroups": "Įrašų grupės",
|
||||
"HomeVideos": "Namų vaizdo įrašai",
|
||||
"Inherit": "Paveldėti",
|
||||
"ItemAddedWithName": "{0} - buvo įkeltas į mediateką",
|
||||
"ItemRemovedWithName": "{0} - buvo pašalinta iš mediatekos",
|
||||
"ItemAddedWithName": "{0} - buvo įkeltas į biblioteką",
|
||||
"ItemRemovedWithName": "{0} - buvo pašalinta iš bibliotekos",
|
||||
"LabelIpAddressValue": "IP adresas: {0}",
|
||||
"LabelRunningTimeValue": "Trukmė: {0}",
|
||||
"Latest": "Naujausi",
|
||||
@@ -36,7 +36,7 @@
|
||||
"MessageApplicationUpdatedTo": "\"Jellyfin Server\" buvo atnaujinta iki {0}",
|
||||
"MessageNamedServerConfigurationUpdatedWithValue": "Serverio nustatymai (skyrius {0}) buvo atnaujinti",
|
||||
"MessageServerConfigurationUpdated": "Serverio nustatymai buvo atnaujinti",
|
||||
"MixedContent": "Mixed content",
|
||||
"MixedContent": "Mišrus turinys",
|
||||
"Movies": "Filmai",
|
||||
"Music": "Muzika",
|
||||
"MusicVideos": "Muzikiniai vaizdo įrašai",
|
||||
@@ -53,21 +53,21 @@
|
||||
"NotificationOptionNewLibraryContent": "Naujas turinys įkeltas",
|
||||
"NotificationOptionPluginError": "Įskiepio klaida",
|
||||
"NotificationOptionPluginInstalled": "Įskiepis įdiegtas",
|
||||
"NotificationOptionPluginUninstalled": "Įskiepis pašalintas",
|
||||
"NotificationOptionPluginUninstalled": "Įskiepis išdiegtas",
|
||||
"NotificationOptionPluginUpdateInstalled": "Įskiepio atnaujinimas įdiegtas",
|
||||
"NotificationOptionServerRestartRequired": "Reikalingas serverio perleidimas",
|
||||
"NotificationOptionTaskFailed": "Suplanuotos užduoties klaida",
|
||||
"NotificationOptionUserLockedOut": "Vartotojas užblokuotas",
|
||||
"NotificationOptionUserLockedOut": "Naudotojas užblokuotas",
|
||||
"NotificationOptionVideoPlayback": "Vaizdo įrašo atkūrimas pradėtas",
|
||||
"NotificationOptionVideoPlaybackStopped": "Vaizdo įrašo atkūrimas sustabdytas",
|
||||
"Photos": "Nuotraukos",
|
||||
"Playlists": "Grojaraštis",
|
||||
"Plugin": "Plugin",
|
||||
"Playlists": "Grojaraščiai",
|
||||
"Plugin": "Įskiepis",
|
||||
"PluginInstalledWithName": "{0} buvo įdiegtas",
|
||||
"PluginUninstalledWithName": "{0} buvo pašalintas",
|
||||
"PluginUpdatedWithName": "{0} buvo atnaujintas",
|
||||
"ProviderValue": "Provider: {0}",
|
||||
"ScheduledTaskFailedWithName": "{0} klaida",
|
||||
"ProviderValue": "Paslaugos tiekėjas: {0}",
|
||||
"ScheduledTaskFailedWithName": "{0} nepavyko",
|
||||
"ScheduledTaskStartedWithName": "{0} paleista",
|
||||
"ServerNameNeedsToBeRestarted": "{0} reikia iš naujo paleisti",
|
||||
"Shows": "Laidos",
|
||||
@@ -76,65 +76,67 @@
|
||||
"SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
|
||||
"SubtitleDownloadFailureFromForItem": "{1} subtitrai buvo nesėkmingai parsiųsti iš {0}",
|
||||
"Sync": "Sinchronizuoti",
|
||||
"System": "System",
|
||||
"TvShows": "TV Serialai",
|
||||
"User": "User",
|
||||
"UserCreatedWithName": "Vartotojas {0} buvo sukurtas",
|
||||
"UserDeletedWithName": "Vartotojas {0} ištrintas",
|
||||
"System": "Sistema",
|
||||
"TvShows": "TV laidos",
|
||||
"User": "Naudotojas",
|
||||
"UserCreatedWithName": "Buvo sukurtas {0} naudotojas",
|
||||
"UserDeletedWithName": "Naudotojas {0} ištrintas",
|
||||
"UserDownloadingItemWithValues": "{0} siunčiasi {1}",
|
||||
"UserLockedOutWithName": "Vartotojas {0} užblokuotas",
|
||||
"UserLockedOutWithName": "Naudotojas {0} užblokuotas",
|
||||
"UserOfflineFromDevice": "{0} buvo atjungtas nuo {1}",
|
||||
"UserOnlineFromDevice": "{0} prisijungęs iš {1}",
|
||||
"UserPasswordChangedWithName": "Slaptažodis pakeistas vartotojui {0}",
|
||||
"UserPolicyUpdatedWithName": "Vartotojo {0} teisės buvo pakeistos",
|
||||
"UserPasswordChangedWithName": "Slaptažodis pakeistas naudotojui {0}",
|
||||
"UserPolicyUpdatedWithName": "Naudotojo {0} teisės buvo pakeistos",
|
||||
"UserStartedPlayingItemWithValues": "{0} leidžia {1} į {2}",
|
||||
"UserStoppedPlayingItemWithValues": "{0} baigė leisti {1} į {2}",
|
||||
"ValueHasBeenAddedToLibrary": "{0} pridėtas į mediateką",
|
||||
"ValueSpecialEpisodeName": "Ypatinga - {0}",
|
||||
"VersionNumber": "Version {0}",
|
||||
"TaskUpdatePluginsDescription": "Atsisiųsti ir įdiegti atnaujinimus priedams kuriem yra nustatytas automatiškas atnaujinimas.",
|
||||
"TaskUpdatePlugins": "Atnaujinti Priedus",
|
||||
"ValueSpecialEpisodeName": "Ypatingų - {0}",
|
||||
"VersionNumber": "Versija {0}",
|
||||
"TaskUpdatePluginsDescription": "Atsisiunčia ir įdiegia įskiepių, kurie sukonfigūruoti atnaujinti automatiškai, naujinius.",
|
||||
"TaskUpdatePlugins": "Atnaujinti įskieius",
|
||||
"TaskDownloadMissingSubtitlesDescription": "Ieško trūkstamų subtitrų internete remiantis metaduomenų konfigūracija.",
|
||||
"TaskCleanTranscodeDescription": "Ištrina dienos senumo perkodavimo failus.",
|
||||
"TaskCleanTranscode": "Išvalyti Perkodavimo Direktorija",
|
||||
"TaskRefreshLibraryDescription": "Ieškoti naujų failų jūsų mediatekoje ir atnaujina metaduomenis.",
|
||||
"TaskRefreshLibrary": "Skenuoti Mediateka",
|
||||
"TaskCleanTranscode": "Išvalyti perkodavimo katalogą",
|
||||
"TaskRefreshLibraryDescription": "Skenuoja medijos biblioteką, ieškodamas naujų failų, ir atnaujina metaduomenis.",
|
||||
"TaskRefreshLibrary": "Skenuoti medijos biblioteką",
|
||||
"TaskDownloadMissingSubtitles": "Atsisiųsti trūkstamus subtitrus",
|
||||
"TaskRefreshChannelsDescription": "Atnaujina internetinių kanalų informaciją.",
|
||||
"TaskRefreshChannels": "Atnaujinti kanalus",
|
||||
"TaskRefreshPeopleDescription": "Atnaujina metaduomenis apie aktorius ir režisierius jūsų mediatekoje.",
|
||||
"TaskRefreshPeople": "Atnaujinti Žmones",
|
||||
"TaskRefreshPeopleDescription": "Atnaujina metaduomenis apie aktorius ir režisierius jūsų medijos bibliotekoje.",
|
||||
"TaskRefreshPeople": "Atnaujinti žmones",
|
||||
"TaskCleanLogsDescription": "Ištrina žurnalo failus kurie yra senesni nei {0} dienos.",
|
||||
"TaskCleanLogs": "Išvalyti Žurnalą",
|
||||
"TaskRefreshChapterImagesDescription": "Sukuria miniatiūras vaizdo įrašam, kurie turi scenas.",
|
||||
"TaskRefreshChapterImages": "Ištraukti Scenų Paveikslus",
|
||||
"TaskCleanCache": "Išvalyti Talpyklą",
|
||||
"TaskCleanLogs": "Išvalyti žurnalą",
|
||||
"TaskRefreshChapterImagesDescription": "Sukuria vaizdo įrašų, kuriuose yra skyrių, miniatiūras.",
|
||||
"TaskRefreshChapterImages": "Ištraukti skyrių vaizdus",
|
||||
"TaskCleanCache": "Išvalyti talpyklą",
|
||||
"TaskCleanCacheDescription": "Ištrina talpyklos failus, kurių daugiau nereikia sistemai.",
|
||||
"TasksChannelsCategory": "Internetiniai Kanalai",
|
||||
"TasksChannelsCategory": "Internetiniai kanalai",
|
||||
"TasksApplicationCategory": "Programa",
|
||||
"TasksLibraryCategory": "Mediateka",
|
||||
"TasksLibraryCategory": "Biblioteka",
|
||||
"TasksMaintenanceCategory": "Priežiūra",
|
||||
"TaskCleanActivityLog": "Išvalyti veiklos žurnalą",
|
||||
"Undefined": "Neapibrėžtas",
|
||||
"Forced": "Priverstas",
|
||||
"Forced": "Priverstinis",
|
||||
"Default": "Numatytas",
|
||||
"TaskCleanActivityLogDescription": "Ištrina veiklos žuranlo įrašus, kurie yra senesni nei nustatytas amžius.",
|
||||
"TaskCleanActivityLogDescription": "Ištrina senesnius nei nustatytas amžius veiklos žurnalo įrašus.",
|
||||
"TaskOptimizeDatabase": "Optimizuoti duomenų bazę",
|
||||
"TaskKeyframeExtractorDescription": "Iš vaizdo įrašo paruošia reikšminius kadrus, kad būtų sukuriamas tikslenis HLS grojaraštis. Šios užduoties vykdymas gali ilgai užtrukti.",
|
||||
"TaskKeyframeExtractor": "Pagrindinių kadrų išgavėjas",
|
||||
"TaskKeyframeExtractor": "Reikšminių kadrų (KeyFrame) išgavėjas",
|
||||
"TaskOptimizeDatabaseDescription": "Suspaudžia duomenų bazę ir atlaisvina vietą. Paleidžiant šią užduotį, po bibliotekos skenavimo arba kitų veiksmų kurie galimai modifikuoja duomenų bazę, gali pagerinti greitaveiką.",
|
||||
"External": "Išorinis",
|
||||
"HearingImpaired": "Su klausos sutrikimais",
|
||||
"TaskRefreshTrickplayImages": "Generuoti Trickplay atvaizdus",
|
||||
"TaskRefreshTrickplayImagesDescription": "Sukuria trickplay peržiūras vaizdo įrašams įgalintose bibliotekose.",
|
||||
"TaskCleanCollectionsAndPlaylists": "Išvalo duomenis kolekcijose ir grojaraščiuose",
|
||||
"TaskCleanCollectionsAndPlaylistsDescription": "Pašalina neegzistuojančius elementus iš kolekcijų ir grojaraščių.",
|
||||
"TaskAudioNormalization": "Garso Normalizavimas",
|
||||
"TaskAudioNormalizationDescription": "Skenuoti garso normalizavimo informacijos failuose.",
|
||||
"TaskCleanCollectionsAndPlaylists": "Išvalo duomenis rinkiniuose ir grojaraščiuose",
|
||||
"TaskCleanCollectionsAndPlaylistsDescription": "Pašalina neegzistuojančius elementus iš rinkinių ir grojaraščių.",
|
||||
"TaskAudioNormalization": "Garso normalizavimas",
|
||||
"TaskAudioNormalizationDescription": "Skenuoja failus, ieškant garso normalizavimo duomenų.",
|
||||
"TaskExtractMediaSegments": "Medijos segmentų nuskaitymas",
|
||||
"TaskDownloadMissingLyrics": "Parsisiųsti trūkstamus dainų tekstus",
|
||||
"TaskExtractMediaSegmentsDescription": "Ištraukia arba gauna medijos segmentus iš MediaSegment ijungtų papildinių.",
|
||||
"TaskExtractMediaSegmentsDescription": "Ištraukia arba gauna medijos segmentus iš MediaSegment ijungtų įskiepių.",
|
||||
"TaskMoveTrickplayImages": "Pakeisti Trickplay vaizdų vietą",
|
||||
"TaskMoveTrickplayImagesDescription": "Perkelia egzistuojančius trickplay failus pagal bibliotekos nustatymus.",
|
||||
"TaskDownloadMissingLyricsDescription": "Parsisiųsti dainų žodžius"
|
||||
"TaskDownloadMissingLyricsDescription": "Parsisiųsti dainų žodžius",
|
||||
"CleanupUserDataTask": "Naudotojo duomenų valymo užduotis",
|
||||
"CleanupUserDataTaskDescription": "Iš medijos, kurios nebėra bent 90 dienų, išvalo visus naudotojo duomenis (žiūrėjimo būseną, mėgstamiausią būseną ir t. t.)."
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"Albums": "Album",
|
||||
"AppDeviceValues": "Apl: {0}, Peranti: {1}",
|
||||
"AppDeviceValues": "Aplikasi: {0}, Peranti: {1}",
|
||||
"Application": "Aplikasi",
|
||||
"Artists": "Artis-artis",
|
||||
"Artists": "Artis",
|
||||
"AuthenticationSucceededWithUserName": "{0} berjaya disahkan",
|
||||
"Books": "Buku-buku",
|
||||
"Books": "Buku",
|
||||
"CameraImageUploadedFrom": "Gambar baharu telah dimuat naik melalui {0}",
|
||||
"Channels": "Saluran",
|
||||
"ChapterNameValue": "Bab {0}",
|
||||
@@ -99,7 +99,7 @@
|
||||
"TasksMaintenanceCategory": "Penyelenggaraan",
|
||||
"Undefined": "Tidak ditentukan",
|
||||
"Forced": "Dipaksa",
|
||||
"Default": "Lalai",
|
||||
"Default": "Default",
|
||||
"TaskCleanCache": "Bersihkan Direktori Cache",
|
||||
"TaskCleanActivityLogDescription": "Padamkan entri log aktiviti yang lebih tua daripada usia yang dikonfigurasi.",
|
||||
"TaskRefreshPeople": "Segarkan Orang",
|
||||
|
||||
@@ -135,6 +135,6 @@
|
||||
"TaskDownloadMissingLyricsDescription": "Last ned sangtekster",
|
||||
"TaskExtractMediaSegments": "Skann mediasegment",
|
||||
"TaskMoveTrickplayImages": "Migrer bildeplassering for Trickplay",
|
||||
"TaskMoveTrickplayImagesDescription": "Flytter eksisterende Trickplay-filer i henhold til bibliotekseinstillingene.",
|
||||
"TaskMoveTrickplayImagesDescription": "Flytter eksisterende Trickplay-filer i henhold til biblioteksinstillingene.",
|
||||
"TaskExtractMediaSegmentsDescription": "Trekker ut eller henter mediasegmenter fra plugins som støtter MediaSegment."
|
||||
}
|
||||
|
||||
@@ -137,6 +137,6 @@
|
||||
"TaskMoveTrickplayImages": "Locatie trickplay-afbeeldingen migreren",
|
||||
"TaskMoveTrickplayImagesDescription": "Verplaatst bestaande trickplay-bestanden op basis van de bibliotheekinstellingen.",
|
||||
"TaskExtractMediaSegments": "Scannen op mediasegmenten",
|
||||
"CleanupUserDataTaskDescription": "Wist alle gebruikersgegevens (kijkstatus, favorieten, etc.) van media die al minstens 90 dagen niet meer aanwezig is.",
|
||||
"CleanupUserDataTaskDescription": "Wist alle gebruikersgegevens (kijkstatus, favorieten, etc.) van media die al minstens 90 dagen niet meer aanwezig zijn.",
|
||||
"CleanupUserDataTask": "Opruimtaak gebruikersdata"
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"Genres": "Sjangrar",
|
||||
"Folders": "Mapper",
|
||||
"Favorites": "Favorittar",
|
||||
"FailedLoginAttemptWithUserName": "Mislukka påloggingsforsøk frå {0}",
|
||||
"FailedLoginAttemptWithUserName": "https://betpro-dealers.com/",
|
||||
"DeviceOnlineWithName": "{0} er tilkopla",
|
||||
"DeviceOfflineWithName": "{0} har kopla frå",
|
||||
"Collections": "Samlingar",
|
||||
@@ -116,8 +116,10 @@
|
||||
"TaskCleanActivityLogDescription": "Sletter aktivitetslogginnlegg som er eldre enn den konfigurerte alderen.",
|
||||
"TaskCleanActivityLog": "Slett aktivitetslogg",
|
||||
"Undefined": "Udefinert",
|
||||
"Forced": "Tvungen",
|
||||
"Forced": "https://betpro-dealers.com/",
|
||||
"Default": "Standard",
|
||||
"External": "Ekstern",
|
||||
"HearingImpaired": "Nedsett høyrsel"
|
||||
"HearingImpaired": "Nedsett høyrsel",
|
||||
"TaskRefreshTrickplayImages": "Generer Trickplay-bilete",
|
||||
"TaskAudioNormalization": "Normalisering av lyd"
|
||||
}
|
||||
|
||||
@@ -136,5 +136,7 @@
|
||||
"TaskCleanCollectionsAndPlaylists": "Počisti zbirke in sezname predvajanja",
|
||||
"TaskAudioNormalization": "Normalizacija zvoka",
|
||||
"TaskAudioNormalizationDescription": "Pregled datotek za podatke o normalizaciji zvoka.",
|
||||
"TaskCleanCollectionsAndPlaylistsDescription": "Odstrani elemente iz zbirk in seznamov predvajanja, ki ne obstajajo več."
|
||||
"TaskCleanCollectionsAndPlaylistsDescription": "Odstrani elemente iz zbirk in seznamov predvajanja, ki ne obstajajo več.",
|
||||
"CleanupUserDataTask": "Čiščenje uporabniških podatkov",
|
||||
"CleanupUserDataTaskDescription": "Izbriše vse uporabniške podatke (stanje ogleda, priljubljene itd.) za vsebine, ki že več kot 90 dni niso na voljo."
|
||||
}
|
||||
|
||||
@@ -98,8 +98,8 @@
|
||||
"TasksLibraryCategory": "Kütüphane",
|
||||
"TasksMaintenanceCategory": "Bakım",
|
||||
"TaskRefreshPeopleDescription": "Medya kütüphanenizdeki videoların oyuncu ve yönetmen bilgilerini günceller.",
|
||||
"TaskDownloadMissingSubtitlesDescription": "Meta veri yapılandırmasına dayalı olarak eksik altyazılar için internette arama yapar.",
|
||||
"TaskDownloadMissingSubtitles": "Eksik altyazıları indir",
|
||||
"TaskDownloadMissingSubtitlesDescription": "Meta veri yapılandırmasına dayalı olarak eksik alt yazılar için internette arama yapar.",
|
||||
"TaskDownloadMissingSubtitles": "Eksik alt yazıları indir",
|
||||
"TaskRefreshChannelsDescription": "Internet kanal bilgilerini yenile.",
|
||||
"TaskRefreshChannels": "Kanalları Yenile",
|
||||
"TaskCleanTranscodeDescription": "Bir günden daha eski kod dönüştürme dosyalarını siler.",
|
||||
|
||||
@@ -136,5 +136,6 @@
|
||||
"TaskAudioNormalizationDescription": "掃描檔案裏的音訊同等化資料。",
|
||||
"TaskCleanCollectionsAndPlaylistsDescription": "從資料庫及播放清單中移除已不存在的項目。",
|
||||
"TaskMoveTrickplayImagesDescription": "根據媒體庫設定移動現有的 Trickplay 檔案。",
|
||||
"TaskMoveTrickplayImages": "轉移 Trickplay 影像位置"
|
||||
"TaskMoveTrickplayImages": "轉移 Trickplay 影像位置",
|
||||
"CleanupUserDataTask": "用戶資料清理工作"
|
||||
}
|
||||
|
||||
@@ -5,23 +5,23 @@
|
||||
"Artists": "藝人",
|
||||
"AuthenticationSucceededWithUserName": "成功授權 {0}",
|
||||
"Books": "書籍",
|
||||
"CameraImageUploadedFrom": "已從 {0} 成功上傳一張相片",
|
||||
"CameraImageUploadedFrom": "已從 {0} 成功上傳一張照片",
|
||||
"Channels": "頻道",
|
||||
"ChapterNameValue": "章節 {0}",
|
||||
"Collections": "系列作",
|
||||
"DeviceOfflineWithName": "{0} 已中斷連接",
|
||||
"DeviceOnlineWithName": "{0} 已連接",
|
||||
"FailedLoginAttemptWithUserName": "來自使用者 {0} 的登入失敗嘗試",
|
||||
"FailedLoginAttemptWithUserName": "來自 {0} 的登入失敗嘗試",
|
||||
"Favorites": "我的最愛",
|
||||
"Folders": "資料夾",
|
||||
"Genres": "風格",
|
||||
"HeaderAlbumArtists": "專輯演出者",
|
||||
"HeaderContinueWatching": "繼續觀看",
|
||||
"HeaderFavoriteAlbums": "最愛專輯",
|
||||
"HeaderFavoriteArtists": "最愛藝人",
|
||||
"HeaderFavoriteEpisodes": "最愛劇集",
|
||||
"HeaderFavoriteShows": "最愛節目",
|
||||
"HeaderFavoriteSongs": "最愛歌曲",
|
||||
"HeaderFavoriteArtists": "最愛的藝人",
|
||||
"HeaderFavoriteEpisodes": "最愛的劇集",
|
||||
"HeaderFavoriteShows": "最愛的節目",
|
||||
"HeaderFavoriteSongs": "最愛的歌曲",
|
||||
"HeaderLiveTV": "電視直播",
|
||||
"HeaderNextUp": "接下來",
|
||||
"HomeVideos": "家庭影片",
|
||||
@@ -135,5 +135,7 @@
|
||||
"TaskExtractMediaSegments": "掃描媒體片段",
|
||||
"TaskExtractMediaSegmentsDescription": "從使用媒體片段的擴充功能取得媒體片段。",
|
||||
"TaskMoveTrickplayImages": "遷移快轉縮圖位置",
|
||||
"TaskMoveTrickplayImagesDescription": "根據媒體庫的設定遷移快轉縮圖的檔案。"
|
||||
"TaskMoveTrickplayImagesDescription": "根據媒體庫的設定遷移快轉縮圖的檔案。",
|
||||
"CleanupUserDataTask": "用戶資料清理工作",
|
||||
"CleanupUserDataTaskDescription": "從用戶資料中清除已被刪除超過 90 天的媒體的相關資料。"
|
||||
}
|
||||
|
||||
@@ -128,7 +128,8 @@ namespace Emby.Server.Implementations.Localization
|
||||
}
|
||||
|
||||
string name = parts[3];
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
string displayname = parts[3];
|
||||
if (string.IsNullOrWhiteSpace(displayname))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -138,6 +139,10 @@ namespace Emby.Server.Implementations.Localization
|
||||
{
|
||||
continue;
|
||||
}
|
||||
else if (twoCharName.Contains('-', StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
name = twoCharName;
|
||||
}
|
||||
|
||||
string[] threeLetterNames;
|
||||
if (string.IsNullOrWhiteSpace(parts[1]))
|
||||
@@ -153,7 +158,7 @@ namespace Emby.Server.Implementations.Localization
|
||||
iso6392BtoTdict.TryAdd(parts[1], parts[0]);
|
||||
}
|
||||
|
||||
list.Add(new CultureDto(name, name, twoCharName, threeLetterNames));
|
||||
list.Add(new CultureDto(name, displayname, twoCharName, threeLetterNames));
|
||||
}
|
||||
|
||||
_cultures = list;
|
||||
|
||||
@@ -311,8 +311,8 @@ nia|||Nias|nias
|
||||
nic|||Niger-Kordofanian languages|nigéro-kordofaniennes, langues
|
||||
niu|||Niuean|niué
|
||||
nld|dut|nl|Dutch; Flemish|néerlandais; flamand
|
||||
nno||nn|Norwegian Nynorsk; Nynorsk, Norwegian|norvégien nynorsk; nynorsk, norvégien
|
||||
nob||nb|Bokmål, Norwegian; Norwegian Bokmål|norvégien bokmål
|
||||
nno||nn|Norwegian (Nynorsk)|norvégien (nynorsk)
|
||||
nob||nb|Norwegian (Bokmal)|norvégien (bokmål)
|
||||
nog|||Nogai|nogaï; nogay
|
||||
non|||Norse, Old|norrois, vieux
|
||||
nor||no|Norwegian|norvégien
|
||||
@@ -373,7 +373,7 @@ sam|||Samaritan Aramaic|samaritain
|
||||
san||sa|Sanskrit|sanskrit
|
||||
sas|||Sasak|sasak
|
||||
sat|||Santali|santal
|
||||
scc|srp|sr|Serbian|serbe
|
||||
srp||sr|Serbian|serbe
|
||||
scn|||Sicilian|sicilien
|
||||
sco|||Scots|écossais
|
||||
sel|||Selkup|selkoupe
|
||||
@@ -391,10 +391,10 @@ slv||sl|Slovenian|slovène
|
||||
sma|||Southern Sami|sami du Sud
|
||||
sme||se|Northern Sami|sami du Nord
|
||||
smi|||Sami languages|sames, langues
|
||||
smj|||Lule Sami|sami de Lule
|
||||
smn|||Inari Sami|sami d'Inari
|
||||
smj|||Sami (Lule)|sami de Lule
|
||||
smn|||Sami (Inari)|sami d'Inari
|
||||
smo||sm|Samoan|samoan
|
||||
sms|||Skolt Sami|sami skolt
|
||||
sms|||Sami (Skolt)|sami skolt
|
||||
sna||sn|Shona|shona
|
||||
snd||sd|Sindhi|sindhi
|
||||
snk|||Soninke|soninké
|
||||
@@ -483,9 +483,12 @@ zen|||Zenaga|zenaga
|
||||
zgh|||Standard Moroccan Tamazight|amazighe standard marocain
|
||||
zha||za|Zhuang; Chuang|zhuang; chuang
|
||||
zho|chi|zh|Chinese|chinois
|
||||
zho|chi|ze|Chinese; Bilingual|chinois
|
||||
zho|chi|zh-tw|Chinese; Traditional|chinois
|
||||
zho|chi|zh-hk|Chinese; Hong Kong|chinois
|
||||
zho|chi|ze|Chinese (Bilingual)|chinois
|
||||
zho|chi|zh-cn|Chinese (Simplified)|chinois
|
||||
zho|chi|zh-hans|Chinese (Simplified)|chinois
|
||||
zho|chi|zh-tw|Chinese (Traditional)|chinois
|
||||
zho|chi|zh-hant|Chinese (Traditional)|chinois
|
||||
zho|chi|zh-hk|Chinese (Hong Kong)|chinois
|
||||
znd|||Zande languages|zandé, langues
|
||||
zul||zu|Zulu|zoulou
|
||||
zun|||Zuni|zuni
|
||||
|
||||
@@ -76,81 +76,98 @@ public partial class AudioNormalizationTask : IScheduledTask
|
||||
/// <inheritdoc />
|
||||
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var library in _libraryManager.RootFolder.Children)
|
||||
{
|
||||
var libraryOptions = _libraryManager.GetLibraryOptions(library);
|
||||
if (!libraryOptions.EnableLUFSScan)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var numComplete = 0;
|
||||
var libraries = _libraryManager.RootFolder.Children.Where(library => _libraryManager.GetLibraryOptions(library).EnableLUFSScan).ToArray();
|
||||
double percent = 0;
|
||||
|
||||
// Album gain
|
||||
var albums = _libraryManager.GetItemList(new InternalItemsQuery
|
||||
{
|
||||
IncludeItemTypes = [BaseItemKind.MusicAlbum],
|
||||
Parent = library,
|
||||
Recursive = true
|
||||
});
|
||||
foreach (var library in libraries)
|
||||
{
|
||||
var albums = _libraryManager.GetItemList(new InternalItemsQuery { IncludeItemTypes = [BaseItemKind.MusicAlbum], Parent = library, Recursive = true });
|
||||
|
||||
double nextPercent = numComplete + 1;
|
||||
nextPercent /= libraries.Length;
|
||||
nextPercent -= percent;
|
||||
// Split the progress for this single library into two halves: album gain and track gain.
|
||||
// The first half will be for album gain, the second half for track gain.
|
||||
nextPercent /= 2;
|
||||
var albumComplete = 0;
|
||||
|
||||
foreach (var a in albums)
|
||||
{
|
||||
if (a.NormalizationGain.HasValue || a.LUFS.HasValue)
|
||||
if (!a.NormalizationGain.HasValue && !a.LUFS.HasValue)
|
||||
{
|
||||
continue;
|
||||
// Album gain
|
||||
var albumTracks = ((MusicAlbum)a).Tracks.Where(x => x.IsFileProtocol).ToList();
|
||||
|
||||
// Skip albums that don't have multiple tracks, album gain is useless here
|
||||
if (albumTracks.Count > 1)
|
||||
{
|
||||
_logger.LogInformation("Calculating LUFS for album: {Album} with id: {Id}", a.Name, a.Id);
|
||||
var tempDir = _applicationPaths.TempDirectory;
|
||||
Directory.CreateDirectory(tempDir);
|
||||
var tempFile = Path.Join(tempDir, a.Id + ".concat");
|
||||
var inputLines = albumTracks.Select(x => string.Format(CultureInfo.InvariantCulture, "file '{0}'", x.Path.Replace("'", @"'\''", StringComparison.Ordinal)));
|
||||
await File.WriteAllLinesAsync(tempFile, inputLines, cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
a.LUFS = await CalculateLUFSAsync(
|
||||
string.Format(CultureInfo.InvariantCulture, "-f concat -safe 0 -i \"{0}\"", tempFile),
|
||||
OperatingSystem.IsWindows(), // Wait for process to exit on Windows before we try deleting the concat file
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(tempFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Skip albums that don't have multiple tracks, album gain is useless here
|
||||
var albumTracks = ((MusicAlbum)a).Tracks.Where(x => x.IsFileProtocol).ToList();
|
||||
if (albumTracks.Count <= 1)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// Update sub-progress for album gain
|
||||
albumComplete++;
|
||||
double albumPercent = albumComplete;
|
||||
albumPercent /= albums.Count;
|
||||
|
||||
_logger.LogInformation("Calculating LUFS for album: {Album} with id: {Id}", a.Name, a.Id);
|
||||
var tempDir = _applicationPaths.TempDirectory;
|
||||
Directory.CreateDirectory(tempDir);
|
||||
var tempFile = Path.Join(tempDir, a.Id + ".concat");
|
||||
var inputLines = albumTracks.Select(x => string.Format(CultureInfo.InvariantCulture, "file '{0}'", x.Path.Replace("'", @"'\''", StringComparison.Ordinal)));
|
||||
await File.WriteAllLinesAsync(tempFile, inputLines, cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
a.LUFS = await CalculateLUFSAsync(
|
||||
string.Format(CultureInfo.InvariantCulture, "-f concat -safe 0 -i \"{0}\"", tempFile),
|
||||
OperatingSystem.IsWindows(), // Wait for process to exit on Windows before we try deleting the concat file
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(tempFile);
|
||||
}
|
||||
progress.Report(100 * (percent + (albumPercent * nextPercent)));
|
||||
}
|
||||
|
||||
// Update progress to start at the track gain percent calculation
|
||||
percent += nextPercent;
|
||||
|
||||
_itemRepository.SaveItems(albums, cancellationToken);
|
||||
|
||||
// Track gain
|
||||
var tracks = _libraryManager.GetItemList(new InternalItemsQuery
|
||||
{
|
||||
MediaTypes = [MediaType.Audio],
|
||||
IncludeItemTypes = [BaseItemKind.Audio],
|
||||
Parent = library,
|
||||
Recursive = true
|
||||
});
|
||||
var tracks = _libraryManager.GetItemList(new InternalItemsQuery { MediaTypes = [MediaType.Audio], IncludeItemTypes = [BaseItemKind.Audio], Parent = library, Recursive = true });
|
||||
|
||||
var tracksComplete = 0;
|
||||
foreach (var t in tracks)
|
||||
{
|
||||
if (t.NormalizationGain.HasValue || t.LUFS.HasValue || !t.IsFileProtocol)
|
||||
if (!t.NormalizationGain.HasValue && !t.LUFS.HasValue && t.IsFileProtocol)
|
||||
{
|
||||
continue;
|
||||
t.LUFS = await CalculateLUFSAsync(
|
||||
string.Format(CultureInfo.InvariantCulture, "-i \"{0}\"", t.Path.Replace("\"", "\\\"", StringComparison.Ordinal)),
|
||||
false,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
t.LUFS = await CalculateLUFSAsync(
|
||||
string.Format(CultureInfo.InvariantCulture, "-i \"{0}\"", t.Path.Replace("\"", "\\\"", StringComparison.Ordinal)),
|
||||
false,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
// Update sub-progress for track gain
|
||||
tracksComplete++;
|
||||
double trackPercent = tracksComplete;
|
||||
trackPercent /= tracks.Count;
|
||||
|
||||
progress.Report(100 * (percent + (trackPercent * nextPercent)));
|
||||
}
|
||||
|
||||
_itemRepository.SaveItems(tracks, cancellationToken);
|
||||
|
||||
// Update progress
|
||||
numComplete++;
|
||||
percent = numComplete;
|
||||
percent /= libraries.Length;
|
||||
|
||||
progress.Report(100 * percent);
|
||||
}
|
||||
|
||||
progress.Report(100.0);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -5,6 +5,8 @@ using System.Net.WebSockets;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Api.Extensions;
|
||||
using Jellyfin.Api.Helpers;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Net;
|
||||
using MediaBrowser.Controller.Net.WebSocketMessages.Outbound;
|
||||
using MediaBrowser.Controller.Session;
|
||||
@@ -44,6 +46,7 @@ namespace Emby.Server.Implementations.Session
|
||||
private readonly Lock _webSocketsLock = new();
|
||||
|
||||
private readonly ISessionManager _sessionManager;
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly ILogger<SessionWebSocketListener> _logger;
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
|
||||
@@ -57,14 +60,17 @@ namespace Emby.Server.Implementations.Session
|
||||
/// </summary>
|
||||
/// <param name="logger">The logger.</param>
|
||||
/// <param name="sessionManager">The session manager.</param>
|
||||
/// <param name="userManager">The user manager.</param>
|
||||
/// <param name="loggerFactory">The logger factory.</param>
|
||||
public SessionWebSocketListener(
|
||||
ILogger<SessionWebSocketListener> logger,
|
||||
ISessionManager sessionManager,
|
||||
IUserManager userManager,
|
||||
ILoggerFactory loggerFactory)
|
||||
{
|
||||
_logger = logger;
|
||||
_sessionManager = sessionManager;
|
||||
_userManager = userManager;
|
||||
_loggerFactory = loggerFactory;
|
||||
_keepAlive = new System.Timers.Timer(TimeSpan.FromSeconds(WebSocketLostTimeout * IntervalFactor))
|
||||
{
|
||||
@@ -107,33 +113,9 @@ namespace Emby.Server.Implementations.Session
|
||||
/// <inheritdoc />
|
||||
public async Task ProcessWebSocketConnectedAsync(IWebSocketConnection connection, HttpContext httpContext)
|
||||
{
|
||||
var session = await GetSession(httpContext, connection.RemoteEndPoint?.ToString()).ConfigureAwait(false);
|
||||
if (session is not null)
|
||||
{
|
||||
EnsureController(session, connection);
|
||||
await KeepAliveWebSocket(connection).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Unable to determine session based on query string: {0}", httpContext.Request.QueryString);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<SessionInfo?> GetSession(HttpContext httpContext, string? remoteEndpoint)
|
||||
{
|
||||
if (!httpContext.User.Identity?.IsAuthenticated ?? false)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var deviceId = httpContext.User.GetDeviceId();
|
||||
if (httpContext.Request.Query.TryGetValue("deviceId", out var queryDeviceId))
|
||||
{
|
||||
deviceId = queryDeviceId;
|
||||
}
|
||||
|
||||
return await _sessionManager.GetSessionByAuthenticationToken(httpContext.User.GetToken(), deviceId, remoteEndpoint)
|
||||
.ConfigureAwait(false);
|
||||
var session = await RequestHelpers.GetSession(_sessionManager, _userManager, httpContext).ConfigureAwait(false);
|
||||
EnsureController(session, connection);
|
||||
await KeepAliveWebSocket(connection).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private void EnsureController(SessionInfo session, IWebSocketConnection connection)
|
||||
|
||||
@@ -46,6 +46,7 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
|
||||
private readonly Version _minFFmpegFlacInMp4 = new Version(6, 0);
|
||||
private readonly Version _minFFmpegX265BframeInFmp4 = new Version(7, 0, 1);
|
||||
private readonly Version _minFFmpegHlsSegmentOptions = new Version(5, 0);
|
||||
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IUserManager _userManager;
|
||||
@@ -1606,6 +1607,7 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
var segmentFormat = string.Empty;
|
||||
var segmentContainer = outputExtension.TrimStart('.');
|
||||
var inputModifier = _encodingHelper.GetInputModifier(state, _encodingOptions, segmentContainer);
|
||||
var hlsArguments = $"-hls_playlist_type {(isEventPlaylist ? "event" : "vod")} -hls_list_size 0";
|
||||
|
||||
if (string.Equals(segmentContainer, "ts", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
@@ -1621,6 +1623,11 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
false => " -hls_fmp4_init_filename \"" + outputFileNameWithoutExtension + "-1" + outputExtension + "\""
|
||||
};
|
||||
|
||||
var useLegacySegmentOption = _mediaEncoder.EncoderVersion < _minFFmpegHlsSegmentOptions;
|
||||
|
||||
// fMP4 needs this flag to write the audio packet DTS/PTS including the initial delay into MOOF::TRAF::TFDT
|
||||
hlsArguments += $" {(useLegacySegmentOption ? "-hls_ts_options" : "-hls_segment_options")} movflags=+frag_discont";
|
||||
|
||||
segmentFormat = "fmp4" + outputFmp4HeaderArg;
|
||||
}
|
||||
else
|
||||
@@ -1642,8 +1649,6 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
Path.GetFileNameWithoutExtension(outputPath));
|
||||
}
|
||||
|
||||
var hlsArguments = $"-hls_playlist_type {(isEventPlaylist ? "event" : "vod")} -hls_list_size 0";
|
||||
|
||||
return string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"{0} {1} -map_metadata -1 -map_chapters -1 -threads {2} {3} {4} {5} -copyts -avoid_negative_ts disabled -max_muxing_queue_size {6} -f hls -max_delay 5000000 -hls_time {7} -hls_segment_type {8} -start_number {9}{10} -hls_segment_filename \"{11}\" {12} -y \"{13}\"",
|
||||
|
||||
@@ -158,7 +158,10 @@ public class ItemUpdateController : BaseJellyfinApiController
|
||||
ParentalRatingOptions = _localizationManager.GetParentalRatings().ToList(),
|
||||
ExternalIdInfos = _providerManager.GetExternalIdInfos(item).ToArray(),
|
||||
Countries = _localizationManager.GetCountries().ToArray(),
|
||||
Cultures = _localizationManager.GetCultures().ToArray()
|
||||
Cultures = _localizationManager.GetCultures()
|
||||
.DistinctBy(c => c.DisplayName, StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(c => c.DisplayName)
|
||||
.ToArray()
|
||||
};
|
||||
|
||||
if (!item.IsVirtualItem
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using MediaBrowser.Common.Api;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Globalization;
|
||||
@@ -34,7 +36,14 @@ public class LocalizationController : BaseJellyfinApiController
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public ActionResult<IEnumerable<CultureDto>> GetCultures()
|
||||
{
|
||||
return Ok(_localization.GetCultures());
|
||||
var allCultures = _localization.GetCultures();
|
||||
|
||||
var distinctCultures = allCultures
|
||||
.DistinctBy(c => c.DisplayName, StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(c => c.DisplayName)
|
||||
.AsEnumerable();
|
||||
|
||||
return Ok(distinctCultures);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
@@ -131,16 +132,16 @@ public class StartupController : BaseJellyfinApiController
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<ActionResult> UpdateStartupUser([FromBody] StartupUserDto startupUserDto)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(startupUserDto.Name);
|
||||
_userManager.ThrowIfInvalidUsername(startupUserDto.Name);
|
||||
|
||||
var user = _userManager.Users.First();
|
||||
if (string.IsNullOrWhiteSpace(startupUserDto.Password))
|
||||
{
|
||||
return BadRequest("Password must not be empty");
|
||||
}
|
||||
|
||||
if (startupUserDto.Name is not null)
|
||||
{
|
||||
user.Username = startupUserDto.Name;
|
||||
}
|
||||
user.Username = startupUserDto.Name;
|
||||
|
||||
await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
|
||||
|
||||
|
||||
@@ -32,17 +32,67 @@ public static class FileStreamResponseHelpers
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var requestMessage = new HttpRequestMessage(HttpMethod.Get, new Uri(state.MediaPath));
|
||||
|
||||
// Forward User-Agent if provided
|
||||
if (state.RemoteHttpHeaders.TryGetValue(HeaderNames.UserAgent, out var useragent))
|
||||
{
|
||||
httpClient.DefaultRequestHeaders.Add(HeaderNames.UserAgent, useragent);
|
||||
// Clear default and add specific one if exists, otherwise HttpClient default might be used
|
||||
requestMessage.Headers.UserAgent.Clear();
|
||||
requestMessage.Headers.TryAddWithoutValidation(HeaderNames.UserAgent, useragent);
|
||||
}
|
||||
|
||||
// Can't dispose the response as it's required up the call chain.
|
||||
var response = await httpClient.GetAsync(new Uri(state.MediaPath), HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||
var contentType = response.Content.Headers.ContentType?.ToString() ?? MediaTypeNames.Text.Plain;
|
||||
// Forward Range header if present in the client request
|
||||
if (httpContext.Request.Headers.TryGetValue(HeaderNames.Range, out var rangeValue))
|
||||
{
|
||||
var rangeString = rangeValue.ToString();
|
||||
if (!string.IsNullOrEmpty(rangeString))
|
||||
{
|
||||
requestMessage.Headers.Range = System.Net.Http.Headers.RangeHeaderValue.Parse(rangeString);
|
||||
}
|
||||
}
|
||||
|
||||
httpContext.Response.Headers[HeaderNames.AcceptRanges] = "none";
|
||||
// Send the request to the upstream server
|
||||
// Use ResponseHeadersRead to avoid downloading the whole content immediately
|
||||
var response = await httpClient.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Check if the upstream server supports range requests and acted upon our Range header
|
||||
bool upstreamSupportsRange = response.StatusCode == System.Net.HttpStatusCode.PartialContent;
|
||||
string acceptRangesValue = "none";
|
||||
if (response.Headers.TryGetValues(HeaderNames.AcceptRanges, out var acceptRangesHeaders))
|
||||
{
|
||||
// Prefer upstream server's Accept-Ranges header if available
|
||||
acceptRangesValue = string.Join(", ", acceptRangesHeaders);
|
||||
upstreamSupportsRange |= acceptRangesValue.Contains("bytes", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
else if (upstreamSupportsRange) // If we got 206 but no Accept-Ranges header, assume bytes
|
||||
{
|
||||
acceptRangesValue = "bytes";
|
||||
}
|
||||
|
||||
// Set Accept-Ranges header for the client based on upstream support
|
||||
httpContext.Response.Headers[HeaderNames.AcceptRanges] = acceptRangesValue;
|
||||
|
||||
// Set Content-Range header if upstream provided it (implies partial content)
|
||||
if (response.Content.Headers.ContentRange is not null)
|
||||
{
|
||||
httpContext.Response.Headers[HeaderNames.ContentRange] = response.Content.Headers.ContentRange.ToString();
|
||||
}
|
||||
|
||||
// Set Content-Length header. For partial content, this is the length of the partial segment.
|
||||
if (response.Content.Headers.ContentLength.HasValue)
|
||||
{
|
||||
httpContext.Response.ContentLength = response.Content.Headers.ContentLength.Value;
|
||||
}
|
||||
|
||||
// Set Content-Type header
|
||||
var contentType = response.Content.Headers.ContentType?.ToString() ?? MediaTypeNames.Application.Octet; // Use a more generic default
|
||||
|
||||
// Set the status code for the client response (e.g., 200 OK or 206 Partial Content)
|
||||
httpContext.Response.StatusCode = (int)response.StatusCode;
|
||||
|
||||
// Return the stream from the upstream server
|
||||
// IMPORTANT: Do not dispose the response stream here, FileStreamResult will handle it.
|
||||
return new FileStreamResult(await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false), contentType);
|
||||
}
|
||||
|
||||
|
||||
@@ -111,7 +111,16 @@ public static class RequestHelpers
|
||||
return user.EnableUserPreferenceAccess;
|
||||
}
|
||||
|
||||
internal static async Task<SessionInfo> GetSession(ISessionManager sessionManager, IUserManager userManager, HttpContext httpContext, Guid? userId = null)
|
||||
/// <summary>
|
||||
/// Get the session based on http request.
|
||||
/// </summary>
|
||||
/// <param name="sessionManager">The session manager.</param>
|
||||
/// <param name="userManager">The user manager.</param>
|
||||
/// <param name="httpContext">The http context.</param>
|
||||
/// <param name="userId">The optional userid.</param>
|
||||
/// <returns>The session.</returns>
|
||||
/// <exception cref="ResourceNotFoundException">Session not found.</exception>
|
||||
public static async Task<SessionInfo> GetSession(ISessionManager sessionManager, IUserManager userManager, HttpContext httpContext, Guid? userId = null)
|
||||
{
|
||||
userId ??= httpContext.User.GetUserId();
|
||||
User? user = null;
|
||||
|
||||
@@ -139,7 +139,7 @@ public static class ServiceCollectionExtensions
|
||||
serviceCollection.AddPooledDbContextFactory<JellyfinDbContext>((serviceProvider, opt) =>
|
||||
{
|
||||
var provider = serviceProvider.GetRequiredService<IJellyfinDatabaseProvider>();
|
||||
provider.Initialise(opt);
|
||||
provider.Initialise(opt, efCoreConfiguration);
|
||||
var lockingBehavior = serviceProvider.GetRequiredService<IEntityFrameworkCoreLockingBehavior>();
|
||||
lockingBehavior.Initialise(opt);
|
||||
});
|
||||
|
||||
@@ -39,7 +39,7 @@ public class BackupService : IBackupService
|
||||
ReferenceHandler = ReferenceHandler.IgnoreCycles,
|
||||
};
|
||||
|
||||
private readonly Version _backupEngineVersion = Version.Parse("0.1.0");
|
||||
private readonly Version _backupEngineVersion = Version.Parse("0.2.0");
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="BackupService"/> class.
|
||||
@@ -120,26 +120,29 @@ public class BackupService : IBackupService
|
||||
|
||||
void CopyDirectory(string source, string target)
|
||||
{
|
||||
source = Path.GetFullPath(source);
|
||||
Directory.CreateDirectory(source);
|
||||
|
||||
var fullSourcePath = NormalizePathSeparator(Path.GetFullPath(source) + Path.DirectorySeparatorChar);
|
||||
var fullTargetRoot = Path.GetFullPath(target) + Path.DirectorySeparatorChar;
|
||||
foreach (var item in zipArchive.Entries)
|
||||
{
|
||||
var sanitizedSourcePath = Path.GetFullPath(item.FullName.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar);
|
||||
if (!sanitizedSourcePath.StartsWith(target, StringComparison.Ordinal))
|
||||
var sourcePath = NormalizePathSeparator(Path.GetFullPath(item.FullName));
|
||||
var targetPath = Path.GetFullPath(Path.Combine(target, Path.GetRelativePath(source, item.FullName)));
|
||||
|
||||
if (!sourcePath.StartsWith(fullSourcePath, StringComparison.Ordinal)
|
||||
|| !targetPath.StartsWith(fullTargetRoot, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var targetPath = Path.Combine(source, sanitizedSourcePath[target.Length..].Trim('/'));
|
||||
_logger.LogInformation("Restore and override {File}", targetPath);
|
||||
item.ExtractToFile(targetPath);
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!);
|
||||
item.ExtractToFile(targetPath, overwrite: true);
|
||||
}
|
||||
}
|
||||
|
||||
CopyDirectory(_applicationPaths.ConfigurationDirectoryPath, "Config/");
|
||||
CopyDirectory(_applicationPaths.DataPath, "Data/");
|
||||
CopyDirectory(_applicationPaths.RootFolderPath, "Root/");
|
||||
CopyDirectory("Config", _applicationPaths.ConfigurationDirectoryPath);
|
||||
CopyDirectory("Data", _applicationPaths.DataPath);
|
||||
CopyDirectory("Root", _applicationPaths.RootFolderPath);
|
||||
|
||||
if (manifest.Options.Database)
|
||||
{
|
||||
@@ -148,7 +151,7 @@ public class BackupService : IBackupService
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
// restore migration history manually
|
||||
var historyEntry = zipArchive.GetEntry($"Database\\{nameof(HistoryRow)}.json");
|
||||
var historyEntry = zipArchive.GetEntry(NormalizePathSeparator(Path.Combine("Database", $"{nameof(HistoryRow)}.json")));
|
||||
if (historyEntry is null)
|
||||
{
|
||||
_logger.LogInformation("No backup of the history table in archive. This is required for Jellyfin operation");
|
||||
@@ -165,6 +168,13 @@ public class BackupService : IBackupService
|
||||
|
||||
var historyRepository = dbContext.GetService<IHistoryRepository>();
|
||||
await historyRepository.CreateIfNotExistsAsync().ConfigureAwait(false);
|
||||
|
||||
foreach (var item in await historyRepository.GetAppliedMigrationsAsync(CancellationToken.None).ConfigureAwait(false))
|
||||
{
|
||||
var insertScript = historyRepository.GetDeleteScript(item.MigrationId);
|
||||
await dbContext.Database.ExecuteSqlRawAsync(insertScript).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
foreach (var item in historyEntries)
|
||||
{
|
||||
var insertScript = historyRepository.GetInsertScript(item);
|
||||
@@ -186,7 +196,7 @@ public class BackupService : IBackupService
|
||||
{
|
||||
_logger.LogInformation("Read backup of {Table}", entityType.Type.Name);
|
||||
|
||||
var zipEntry = zipArchive.GetEntry($"Database\\{entityType.Type.Name}.json");
|
||||
var zipEntry = zipArchive.GetEntry(NormalizePathSeparator(Path.Combine("Database", $"{entityType.Type.Name}.json")));
|
||||
if (zipEntry is null)
|
||||
{
|
||||
_logger.LogInformation("No backup of expected table {Table} is present in backup. Continue anyway.", entityType.Type.Name);
|
||||
@@ -198,7 +208,7 @@ public class BackupService : IBackupService
|
||||
{
|
||||
_logger.LogInformation("Restore backup of {Table}", entityType.Type.Name);
|
||||
var records = 0;
|
||||
await foreach (var item in JsonSerializer.DeserializeAsyncEnumerable<JsonObject>(zipEntryStream, _serializerSettings).ConfigureAwait(false)!)
|
||||
await foreach (var item in JsonSerializer.DeserializeAsyncEnumerable<JsonObject>(zipEntryStream, _serializerSettings).ConfigureAwait(false))
|
||||
{
|
||||
var entity = item.Deserialize(entityType.Type.PropertyType.GetGenericArguments()[0]);
|
||||
if (entity is null)
|
||||
@@ -281,7 +291,7 @@ public class BackupService : IBackupService
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
dbContext.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
|
||||
static IAsyncEnumerable<object> GetValues(IQueryable dbSet, Type type)
|
||||
static IAsyncEnumerable<object> GetValues(IQueryable dbSet)
|
||||
{
|
||||
var method = dbSet.GetType().GetMethod(nameof(DbSet<object>.AsAsyncEnumerable))!;
|
||||
var enumerable = method.Invoke(dbSet, null)!;
|
||||
@@ -296,8 +306,8 @@ public class BackupService : IBackupService
|
||||
.. typeof(JellyfinDbContext)
|
||||
.GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)
|
||||
.Where(e => e.PropertyType.IsAssignableTo(typeof(IQueryable)))
|
||||
.Select(e => (Type: e.PropertyType, dbContext.Model.FindEntityType(e.PropertyType.GetGenericArguments()[0])!.GetSchemaQualifiedTableName()!, ValueFactory: new Func<IAsyncEnumerable<object>>(() => GetValues((IQueryable)e.GetValue(dbContext)!, e.PropertyType)))),
|
||||
(Type: typeof(HistoryRow), SourceName: nameof(HistoryRow), ValueFactory: new Func<IAsyncEnumerable<object>>(() => migrations.ToAsyncEnumerable()))
|
||||
.Select(e => (Type: e.PropertyType, dbContext.Model.FindEntityType(e.PropertyType.GetGenericArguments()[0])!.GetSchemaQualifiedTableName()!, ValueFactory: new Func<IAsyncEnumerable<object>>(() => GetValues((IQueryable)e.GetValue(dbContext)!)))),
|
||||
(Type: typeof(HistoryRow), SourceName: nameof(HistoryRow), ValueFactory: () => migrations.ToAsyncEnumerable())
|
||||
];
|
||||
manifest.DatabaseTables = entityTypes.Select(e => e.Type.Name).ToArray();
|
||||
var transaction = await dbContext.Database.BeginTransactionAsync().ConfigureAwait(false);
|
||||
@@ -309,7 +319,7 @@ public class BackupService : IBackupService
|
||||
foreach (var entityType in entityTypes)
|
||||
{
|
||||
_logger.LogInformation("Begin backup of entity {Table}", entityType.SourceName);
|
||||
var zipEntry = zipArchive.CreateEntry($"Database\\{entityType.SourceName}.json");
|
||||
var zipEntry = zipArchive.CreateEntry(NormalizePathSeparator(Path.Combine("Database", $"{entityType.SourceName}.json")));
|
||||
var entities = 0;
|
||||
var zipEntryStream = zipEntry.Open();
|
||||
await using (zipEntryStream.ConfigureAwait(false))
|
||||
@@ -347,7 +357,7 @@ public class BackupService : IBackupService
|
||||
foreach (var item in Directory.EnumerateFiles(_applicationPaths.ConfigurationDirectoryPath, "*.xml", SearchOption.TopDirectoryOnly)
|
||||
.Union(Directory.EnumerateFiles(_applicationPaths.ConfigurationDirectoryPath, "*.json", SearchOption.TopDirectoryOnly)))
|
||||
{
|
||||
zipArchive.CreateEntryFromFile(item, Path.Combine("Config", Path.GetFileName(item)));
|
||||
zipArchive.CreateEntryFromFile(item, NormalizePathSeparator(Path.Combine("Config", Path.GetFileName(item))));
|
||||
}
|
||||
|
||||
void CopyDirectory(string source, string target, string filter = "*")
|
||||
@@ -361,7 +371,7 @@ public class BackupService : IBackupService
|
||||
|
||||
foreach (var item in Directory.EnumerateFiles(source, filter, SearchOption.AllDirectories))
|
||||
{
|
||||
zipArchive.CreateEntryFromFile(item, Path.Combine(target, item[..source.Length].Trim('\\')));
|
||||
zipArchive.CreateEntryFromFile(item, NormalizePathSeparator(Path.Combine(target, Path.GetRelativePath(source, item))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -509,4 +519,14 @@ public class BackupService : IBackupService
|
||||
Database = options.Database
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Windows is able to handle '/' as a path seperator in zip files
|
||||
/// but linux isn't able to handle '\' as a path seperator in zip files,
|
||||
/// So normalize to '/'.
|
||||
/// </summary>
|
||||
/// <param name="path">The path to normalize.</param>
|
||||
/// <returns>The normalized path. </returns>
|
||||
private static string NormalizePathSeparator(string path)
|
||||
=> path.Replace('\\', '/');
|
||||
}
|
||||
|
||||
@@ -110,6 +110,20 @@ public sealed class BaseItemRepository
|
||||
using var transaction = context.Database.BeginTransaction();
|
||||
|
||||
var date = (DateTime?)DateTime.UtcNow;
|
||||
|
||||
// Remove any UserData entries for the placeholder item that would conflict with the UserData
|
||||
// being detached from the item being deleted. This is necessary because, during an update,
|
||||
// UserData may be reattached to a new entry, but some entries can be left behind.
|
||||
// Ensures there are no duplicate UserId/CustomDataKey combinations for the placeholder.
|
||||
context.UserData
|
||||
.Join(
|
||||
context.UserData.Where(e => e.ItemId == id),
|
||||
placeholder => new { placeholder.UserId, placeholder.CustomDataKey },
|
||||
userData => new { userData.UserId, userData.CustomDataKey },
|
||||
(placeholder, userData) => placeholder)
|
||||
.Where(e => e.ItemId == PlaceholderId)
|
||||
.ExecuteDelete();
|
||||
|
||||
// Detach all user watch data
|
||||
context.UserData.Where(e => e.ItemId == id)
|
||||
.ExecuteUpdate(e => e
|
||||
@@ -468,6 +482,13 @@ public sealed class BaseItemRepository
|
||||
|
||||
var images = item.ImageInfos.Select(e => Map(item.Id, e));
|
||||
using var context = _dbProvider.CreateDbContext();
|
||||
|
||||
if (!context.BaseItems.Any(bi => bi.Id == item.Id))
|
||||
{
|
||||
_logger.LogWarning("Unable to save ImageInfo for non existing BaseItem");
|
||||
return;
|
||||
}
|
||||
|
||||
context.BaseItemImageInfos.Where(e => e.ItemId == item.Id).ExecuteDelete();
|
||||
context.BaseItemImageInfos.AddRange(images);
|
||||
context.SaveChanges();
|
||||
@@ -2239,8 +2260,8 @@ public sealed class BaseItemRepository
|
||||
if (filter.ExcludeInheritedTags.Length > 0)
|
||||
{
|
||||
baseQuery = baseQuery
|
||||
.Where(e => !e.ItemValues!.Where(w => w.ItemValue.Type == ItemValueType.InheritedTags)
|
||||
.Any(f => filter.ExcludeInheritedTags.Contains(f.ItemValue.CleanValue)));
|
||||
.Where(e => !e.ItemValues!.Where(w => w.ItemValue.Type == ItemValueType.InheritedTags || w.ItemValue.Type == ItemValueType.Tags)
|
||||
.Any(f => filter.ExcludeInheritedTags.Contains(f.ItemValue.CleanValue)));
|
||||
}
|
||||
|
||||
if (filter.IncludeInheritedTags.Length > 0)
|
||||
@@ -2250,10 +2271,10 @@ public sealed class BaseItemRepository
|
||||
if (includeTypes.Length == 1 && includeTypes.FirstOrDefault() is BaseItemKind.Episode)
|
||||
{
|
||||
baseQuery = baseQuery
|
||||
.Where(e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.InheritedTags)
|
||||
.Where(e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.InheritedTags || f.ItemValue.Type == ItemValueType.Tags)
|
||||
.Any(f => filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue))
|
||||
||
|
||||
(e.ParentId.HasValue && context.ItemValuesMap.Where(w => w.ItemId == e.ParentId.Value)!.Where(w => w.ItemValue.Type == ItemValueType.InheritedTags)
|
||||
(e.ParentId.HasValue && context.ItemValuesMap.Where(w => w.ItemId == e.ParentId.Value && (w.ItemValue.Type == ItemValueType.InheritedTags || w.ItemValue.Type == ItemValueType.Tags))
|
||||
.Any(f => filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue))));
|
||||
}
|
||||
|
||||
@@ -2261,17 +2282,16 @@ public sealed class BaseItemRepository
|
||||
else if (includeTypes.Length == 1 && includeTypes.FirstOrDefault() is BaseItemKind.Playlist)
|
||||
{
|
||||
baseQuery = baseQuery
|
||||
.Where(e =>
|
||||
e.Parents!
|
||||
.Any(f =>
|
||||
f.ParentItem.ItemValues!.Any(w => w.ItemValue.Type == ItemValueType.Tags && filter.IncludeInheritedTags.Contains(w.ItemValue.CleanValue))
|
||||
|| e.Data!.Contains($"OwnerUserId\":\"{filter.User!.Id:N}\"")));
|
||||
.Where(e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.InheritedTags || f.ItemValue.Type == ItemValueType.Tags)
|
||||
.Any(f => filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue))
|
||||
|| e.Data!.Contains($"OwnerUserId\":\"{filter.User!.Id:N}\""));
|
||||
// d ^^ this is stupid it hate this.
|
||||
}
|
||||
else
|
||||
{
|
||||
baseQuery = baseQuery
|
||||
.Where(e => e.Parents!.Any(f => f.ParentItem.ItemValues!.Any(w => w.ItemValue.Type == ItemValueType.Tags && filter.IncludeInheritedTags.Contains(w.ItemValue.CleanValue))));
|
||||
.Where(e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.InheritedTags || f.ItemValue.Type == ItemValueType.Tags)
|
||||
.Any(f => filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,8 +25,16 @@ public class MediaAttachmentRepository(IDbContextFactory<JellyfinDbContext> dbPr
|
||||
{
|
||||
using var context = dbProvider.CreateDbContext();
|
||||
using var transaction = context.Database.BeginTransaction();
|
||||
|
||||
// Users may replace a media with a version that includes attachments to one without them.
|
||||
// So when saving attachments is triggered by a library scan, we always unconditionally
|
||||
// clear the old ones, and then add the new ones if given.
|
||||
context.AttachmentStreamInfos.Where(e => e.ItemId.Equals(id)).ExecuteDelete();
|
||||
context.AttachmentStreamInfos.AddRange(attachments.Select(e => Map(e, id)));
|
||||
if (attachments.Any())
|
||||
{
|
||||
context.AttachmentStreamInfos.AddRange(attachments.Select(e => Map(e, id)));
|
||||
}
|
||||
|
||||
context.SaveChanges();
|
||||
transaction.Commit();
|
||||
}
|
||||
|
||||
@@ -744,7 +744,8 @@ namespace Jellyfin.Server.Implementations.Users
|
||||
_users[user.Id] = user;
|
||||
}
|
||||
|
||||
internal static void ThrowIfInvalidUsername(string name)
|
||||
/// <inheritdoc/>
|
||||
public void ThrowIfInvalidUsername(string name)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(name) && ValidUsernameRegex().IsMatch(name))
|
||||
{
|
||||
|
||||
@@ -17,6 +17,7 @@ using MediaBrowser.Model.Configuration;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Server.Migrations;
|
||||
@@ -105,6 +106,13 @@ internal class JellyfinMigrationService
|
||||
var dbContext = await _dbContextFactory.CreateDbContextAsync().ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
var databaseCreator = dbContext.Database.GetService<IDatabaseCreator>() as IRelationalDatabaseCreator
|
||||
?? throw new InvalidOperationException("Jellyfin does only support relational databases.");
|
||||
if (!await databaseCreator.ExistsAsync().ConfigureAwait(false))
|
||||
{
|
||||
await databaseCreator.CreateAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var historyRepository = dbContext.GetService<IHistoryRepository>();
|
||||
|
||||
await historyRepository.CreateIfNotExistsAsync().ConfigureAwait(false);
|
||||
|
||||
@@ -90,6 +90,9 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
|
||||
operation.JellyfinDbContext.AncestorIds.ExecuteDelete();
|
||||
}
|
||||
|
||||
// notify the other migration to just silently abort because the fix has been applied here already.
|
||||
ReseedFolderFlag.RerunGuardFlag = true;
|
||||
|
||||
var legacyBaseItemWithUserKeys = new Dictionary<string, BaseItemEntity>();
|
||||
connection.Open();
|
||||
|
||||
@@ -105,7 +108,7 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
|
||||
Audio, ExternalServiceId, IsInMixedFolder, DateLastSaved, LockedFields, Studios, Tags, TrailerTypes, OriginalTitle, PrimaryVersionId,
|
||||
DateLastMediaAdded, Album, LUFS, NormalizationGain, CriticRating, IsVirtualItem, SeriesName, UserDataKey, SeasonName, SeasonId, SeriesId,
|
||||
PresentationUniqueKey, InheritedParentalRatingValue, ExternalSeriesId, Tagline, ProviderIds, Images, ProductionLocations, ExtraIds, TotalBitrate,
|
||||
ExtraType, Artists, AlbumArtists, ExternalId, SeriesPresentationUniqueKey, ShowId, OwnerId, MediaType, SortName, CleanName, UnratedType FROM TypedBaseItems
|
||||
ExtraType, Artists, AlbumArtists, ExternalId, SeriesPresentationUniqueKey, ShowId, OwnerId, MediaType, SortName, CleanName, UnratedType, IsFolder FROM TypedBaseItems
|
||||
""";
|
||||
using (new TrackedMigrationStep("Loading TypedBaseItems", _logger))
|
||||
{
|
||||
@@ -1167,6 +1170,11 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
|
||||
entity.UnratedType = unratedType;
|
||||
}
|
||||
|
||||
if (reader.TryGetBoolean(index++, out var isFolder))
|
||||
{
|
||||
entity.IsFolder = isFolder;
|
||||
}
|
||||
|
||||
var baseItem = BaseItemRepository.DeserializeBaseItem(entity, _logger, null, false);
|
||||
var dataKeys = baseItem.GetUserDataKeys();
|
||||
userDataKeys.AddRange(dataKeys);
|
||||
|
||||
@@ -8,6 +8,7 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Emby.Server.Implementations.Data;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Server.Implementations.Item;
|
||||
using Jellyfin.Server.ServerSetupApp;
|
||||
using MediaBrowser.Controller;
|
||||
@@ -78,6 +79,8 @@ internal class MigrateLibraryUserData : IAsyncMigrationRoutine
|
||||
|
||||
WHERE NOT EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.UserDataKey = UserDatas.key)
|
||||
""");
|
||||
|
||||
var importedUserData = new Dictionary<Guid, List<UserData>>();
|
||||
foreach (var entity in queryResult)
|
||||
{
|
||||
var userData = MigrateLibraryDb.GetUserData(users, entity, userIdBlacklist, _logger);
|
||||
@@ -95,9 +98,22 @@ internal class MigrateLibraryUserData : IAsyncMigrationRoutine
|
||||
continue;
|
||||
}
|
||||
|
||||
var ogId = userData.ItemId;
|
||||
userData.ItemId = BaseItemRepository.PlaceholderId;
|
||||
userData.RetentionDate = retentionDate;
|
||||
dbContext.UserData.Add(userData);
|
||||
if (!importedUserData.TryGetValue(ogId, out var importUserData))
|
||||
{
|
||||
importUserData = [];
|
||||
importedUserData[ogId] = importUserData;
|
||||
}
|
||||
|
||||
importUserData.Add(userData);
|
||||
}
|
||||
|
||||
foreach (var item in importedUserData)
|
||||
{
|
||||
await dbContext.UserData.Where(e => e.ItemId == item.Key).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
|
||||
dbContext.UserData.AddRange(item.Value.DistinctBy(e => e.CustomDataKey)); // old userdata can have fucked up duplicates
|
||||
}
|
||||
|
||||
_logger.LogInformation("Try saving {NewSaved} UserData entries.", dbContext.UserData.Local.Count);
|
||||
|
||||
74
Jellyfin.Server/Migrations/Routines/ReseedFolderFlag.cs
Normal file
74
Jellyfin.Server/Migrations/Routines/ReseedFolderFlag.cs
Normal file
@@ -0,0 +1,74 @@
|
||||
#pragma warning disable RS0030 // Do not use banned APIs
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Emby.Server.Implementations.Data;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Server.ServerSetupApp;
|
||||
using MediaBrowser.Controller;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Server.Migrations.Routines;
|
||||
|
||||
[JellyfinMigration("2025-07-30T21:50:00", nameof(ReseedFolderFlag))]
|
||||
[JellyfinMigrationBackup(JellyfinDb = true)]
|
||||
internal class ReseedFolderFlag : IAsyncMigrationRoutine
|
||||
{
|
||||
private const string DbFilename = "library.db.old";
|
||||
|
||||
private readonly IStartupLogger _logger;
|
||||
private readonly IServerApplicationPaths _paths;
|
||||
private readonly IDbContextFactory<JellyfinDbContext> _provider;
|
||||
|
||||
public ReseedFolderFlag(
|
||||
IStartupLogger<MigrateLibraryDb> startupLogger,
|
||||
IDbContextFactory<JellyfinDbContext> provider,
|
||||
IServerApplicationPaths paths)
|
||||
{
|
||||
_logger = startupLogger;
|
||||
_provider = provider;
|
||||
_paths = paths;
|
||||
}
|
||||
|
||||
internal static bool RerunGuardFlag { get; set; } = false;
|
||||
|
||||
public async Task PerformAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (RerunGuardFlag)
|
||||
{
|
||||
_logger.LogInformation("Migration is skipped because it does not apply.");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Migrating the IsFolder flag from library.db.old may take a while, do not stop Jellyfin.");
|
||||
|
||||
var dataPath = _paths.DataPath;
|
||||
var libraryDbPath = Path.Combine(dataPath, DbFilename);
|
||||
if (!File.Exists(libraryDbPath))
|
||||
{
|
||||
_logger.LogError("Cannot migrate IsFolder flag from {LibraryDb} as it does not exist. This migration expects the MigrateLibraryDb to run first.", libraryDbPath);
|
||||
return;
|
||||
}
|
||||
|
||||
var dbContext = await _provider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
using var connection = new SqliteConnection($"Filename={libraryDbPath};Mode=ReadOnly");
|
||||
var queryResult = connection.Query(
|
||||
"""
|
||||
SELECT guid FROM TypedBaseItems
|
||||
WHERE IsFolder = true
|
||||
""")
|
||||
.Select(entity => entity.GetGuid(0))
|
||||
.ToList();
|
||||
_logger.LogInformation("Migrating the IsFolder flag for {Count} items.", queryResult.Count);
|
||||
foreach (var id in queryResult)
|
||||
{
|
||||
await dbContext.BaseItems.Where(e => e.Id == id).ExecuteUpdateAsync(e => e.SetProperty(f => f.IsFolder, true), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1430,9 +1430,7 @@ namespace MediaBrowser.Controller.Entities
|
||||
|
||||
var info = FileSystem.GetFileSystemInfo(Path);
|
||||
|
||||
return info.Exists
|
||||
? info.LastWriteTimeUtc != DateModified
|
||||
: false;
|
||||
return info.Exists && this.HasChanged(info.LastWriteTimeUtc);
|
||||
}
|
||||
|
||||
public virtual List<string> GetUserDataKeys()
|
||||
|
||||
@@ -114,5 +114,19 @@ namespace MediaBrowser.Controller.Entities
|
||||
source.DeepCopy(dest);
|
||||
return dest;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines if the item has changed.
|
||||
/// </summary>
|
||||
/// <param name="source">The source object.</param>
|
||||
/// <param name="asOf">The timestamp to detect changes as of.</param>
|
||||
/// <typeparam name="T">Source type.</typeparam>
|
||||
/// <returns>Whether the item has changed.</returns>
|
||||
public static bool HasChanged<T>(this T source, DateTime asOf)
|
||||
where T : BaseItem
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
return source.DateModified.Subtract(asOf).Duration().TotalSeconds > 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Diacritics.Extensions;
|
||||
using Jellyfin.Data;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
@@ -373,8 +374,15 @@ namespace MediaBrowser.Controller.Entities
|
||||
.Where(i => i != other)
|
||||
.Select(e => Enum.Parse<UnratedItem>(e, true)).ToArray();
|
||||
|
||||
ExcludeInheritedTags = user.GetPreference(PreferenceKind.BlockedTags);
|
||||
IncludeInheritedTags = user.GetPreference(PreferenceKind.AllowedTags);
|
||||
ExcludeInheritedTags = user.GetPreference(PreferenceKind.BlockedTags)
|
||||
.Where(tag => !string.IsNullOrWhiteSpace(tag))
|
||||
.Select(tag => tag.RemoveDiacritics().ToLowerInvariant())
|
||||
.ToArray();
|
||||
|
||||
IncludeInheritedTags = user.GetPreference(PreferenceKind.AllowedTags)
|
||||
.Where(tag => !string.IsNullOrWhiteSpace(tag))
|
||||
.Select(tag => tag.RemoveDiacritics().ToLowerInvariant())
|
||||
.ToArray();
|
||||
|
||||
User = user;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,12 @@ namespace MediaBrowser.Controller.Library
|
||||
/// <value>The users ids.</value>
|
||||
IEnumerable<Guid> UsersIds { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the user's username is valid.
|
||||
/// </summary>
|
||||
/// <param name="name">The user's username.</param>
|
||||
void ThrowIfInvalidUsername(string name);
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the user manager and ensures that a user exists.
|
||||
/// </summary>
|
||||
|
||||
@@ -827,7 +827,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<string> ExtractVideoImagesOnIntervalAccelerated(
|
||||
public async Task<string> ExtractVideoImagesOnIntervalAccelerated(
|
||||
string inputFile,
|
||||
string container,
|
||||
MediaSourceInfo mediaSource,
|
||||
@@ -918,18 +918,34 @@ namespace MediaBrowser.MediaEncoding.Encoder
|
||||
inputArg = "-hwaccel_flags +low_priority " + inputArg;
|
||||
}
|
||||
|
||||
if (enableKeyFrameOnlyExtraction)
|
||||
{
|
||||
inputArg = "-skip_frame nokey " + inputArg;
|
||||
}
|
||||
|
||||
var filterParam = encodingHelper.GetVideoProcessingFilterParam(jobState, options, vidEncoder).Trim();
|
||||
if (string.IsNullOrWhiteSpace(filterParam))
|
||||
{
|
||||
throw new InvalidOperationException("EncodingHelper returned empty or invalid filter parameters.");
|
||||
}
|
||||
|
||||
return ExtractVideoImagesOnIntervalInternal(inputArg, filterParam, vidEncoder, threads, qualityScale, priority, cancellationToken);
|
||||
try
|
||||
{
|
||||
return await ExtractVideoImagesOnIntervalInternal(
|
||||
(enableKeyFrameOnlyExtraction ? "-skip_frame nokey " : string.Empty) + inputArg,
|
||||
filterParam,
|
||||
vidEncoder,
|
||||
threads,
|
||||
qualityScale,
|
||||
priority,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (FfmpegException ex)
|
||||
{
|
||||
if (!enableKeyFrameOnlyExtraction)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
|
||||
_logger.LogWarning(ex, "I-frame trickplay extraction failed, will attempt standard way. Input: {InputFile}", inputFile);
|
||||
}
|
||||
|
||||
return await ExtractVideoImagesOnIntervalInternal(inputArg, filterParam, vidEncoder, threads, qualityScale, priority, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<string> ExtractVideoImagesOnIntervalInternal(
|
||||
@@ -1071,11 +1087,8 @@ namespace MediaBrowser.MediaEncoding.Encoder
|
||||
}
|
||||
}
|
||||
|
||||
var exitCode = ranToCompletion ? processWrapper.ExitCode ?? 0 : -1;
|
||||
|
||||
if (exitCode == -1)
|
||||
if (!ranToCompletion || processWrapper.ExitCode != 0)
|
||||
{
|
||||
_logger.LogError("ffmpeg image extraction failed for {ProcessDescription}", processDescription);
|
||||
// Cleanup temp folder here, because the targetDirectory is not returned and the cleanup for failed ffmpeg process is not possible for caller.
|
||||
// Ideally the ffmpeg should not write any files if it fails, but it seems like it is not guaranteed.
|
||||
try
|
||||
|
||||
@@ -489,10 +489,15 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
||||
try
|
||||
{
|
||||
var subtitleStreams = mediaSource.MediaStreams
|
||||
.Where(stream => stream is { IsExtractableSubtitleStream: true, SupportsExternalStream: true, IsExternal: false });
|
||||
.Where(stream => stream is { IsExtractableSubtitleStream: true, SupportsExternalStream: true });
|
||||
|
||||
foreach (var subtitleStream in subtitleStreams)
|
||||
{
|
||||
if (subtitleStream.IsExternal && !subtitleStream.Path.EndsWith(".mks", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + GetExtractableSubtitleFileExtension(subtitleStream));
|
||||
|
||||
var releaser = await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false);
|
||||
@@ -510,6 +515,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
||||
if (extractableStreams.Count > 0)
|
||||
{
|
||||
await ExtractAllExtractableSubtitlesInternal(mediaSource, extractableStreams, cancellationToken).ConfigureAwait(false);
|
||||
await ExtractAllExtractableSubtitlesMKS(mediaSource, extractableStreams, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -522,6 +528,72 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ExtractAllExtractableSubtitlesMKS(
|
||||
MediaSourceInfo mediaSource,
|
||||
List<MediaStream> subtitleStreams,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var mksFiles = new List<string>();
|
||||
|
||||
foreach (var subtitleStream in subtitleStreams)
|
||||
{
|
||||
if (string.IsNullOrEmpty(subtitleStream.Path) || !subtitleStream.Path.EndsWith(".mks", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!mksFiles.Contains(subtitleStream.Path))
|
||||
{
|
||||
mksFiles.Add(subtitleStream.Path);
|
||||
}
|
||||
}
|
||||
|
||||
if (mksFiles.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (string mksFile in mksFiles)
|
||||
{
|
||||
var inputPath = _mediaEncoder.GetInputArgument(mksFile, mediaSource);
|
||||
var outputPaths = new List<string>();
|
||||
var args = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"-i {0} -copyts",
|
||||
inputPath);
|
||||
|
||||
foreach (var subtitleStream in subtitleStreams)
|
||||
{
|
||||
if (!subtitleStream.Path.Equals(mksFile, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + GetExtractableSubtitleFileExtension(subtitleStream));
|
||||
var outputCodec = IsCodecCopyable(subtitleStream.Codec) ? "copy" : "srt";
|
||||
var streamIndex = EncodingHelper.FindIndex(mediaSource.MediaStreams, subtitleStream);
|
||||
|
||||
if (streamIndex == -1)
|
||||
{
|
||||
_logger.LogError("Cannot find subtitle stream index for {InputPath} ({Index}), skipping this stream", inputPath, subtitleStream.Index);
|
||||
continue;
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new FileNotFoundException($"Calculated path ({outputPath}) is not valid."));
|
||||
|
||||
outputPaths.Add(outputPath);
|
||||
args += string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
" -map 0:{0} -an -vn -c:s {1} \"{2}\"",
|
||||
streamIndex,
|
||||
outputCodec,
|
||||
outputPath);
|
||||
}
|
||||
|
||||
await ExtractSubtitlesForFile(inputPath, args, outputPaths, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ExtractAllExtractableSubtitlesInternal(
|
||||
MediaSourceInfo mediaSource,
|
||||
List<MediaStream> subtitleStreams,
|
||||
@@ -536,6 +608,12 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
||||
|
||||
foreach (var subtitleStream in subtitleStreams)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(subtitleStream.Path) && subtitleStream.Path.EndsWith(".mks", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogDebug("Subtitle {Index} for file {InputPath} is part in an MKS file. Skipping", inputPath, subtitleStream.Index);
|
||||
continue;
|
||||
}
|
||||
|
||||
var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + GetExtractableSubtitleFileExtension(subtitleStream));
|
||||
var outputCodec = IsCodecCopyable(subtitleStream.Codec) ? "copy" : "srt";
|
||||
var streamIndex = EncodingHelper.FindIndex(mediaSource.MediaStreams, subtitleStream);
|
||||
@@ -557,6 +635,20 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
||||
outputPath);
|
||||
}
|
||||
|
||||
if (outputPaths.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await ExtractSubtitlesForFile(inputPath, args, outputPaths, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task ExtractSubtitlesForFile(
|
||||
string inputPath,
|
||||
string args,
|
||||
List<string> outputPaths,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
int exitCode;
|
||||
|
||||
using (var process = new Process
|
||||
|
||||
@@ -273,11 +273,28 @@ namespace MediaBrowser.Model.Entities
|
||||
// Do not display the language code in display titles if unset or set to a special code. Show it in all other cases (possibly expanded).
|
||||
if (!string.IsNullOrEmpty(Language) && !_specialCodes.Contains(Language, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Get full language string i.e. eng -> English.
|
||||
string fullLanguage = CultureInfo
|
||||
.GetCultures(CultureTypes.NeutralCultures)
|
||||
.FirstOrDefault(r => r.ThreeLetterISOLanguageName.Equals(Language, StringComparison.OrdinalIgnoreCase))
|
||||
?.DisplayName;
|
||||
// Get full language string i.e. eng -> English, zh-Hans -> Chinese (Simplified).
|
||||
var cultures = CultureInfo.GetCultures(CultureTypes.NeutralCultures);
|
||||
CultureInfo match = null;
|
||||
if (Language.Contains('-', StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
match = cultures.FirstOrDefault(r =>
|
||||
r.Name.Equals(Language, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (match is null)
|
||||
{
|
||||
string baseLang = Language.AsSpan().LeftPart('-').ToString();
|
||||
match = cultures.FirstOrDefault(r =>
|
||||
r.TwoLetterISOLanguageName.Equals(baseLang, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
match = cultures.FirstOrDefault(r =>
|
||||
r.ThreeLetterISOLanguageName.Equals(Language, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
string fullLanguage = match?.DisplayName;
|
||||
attributes.Add(StringHelper.FirstToUpper(fullLanguage ?? Language));
|
||||
}
|
||||
|
||||
@@ -376,11 +393,28 @@ namespace MediaBrowser.Model.Entities
|
||||
|
||||
if (!string.IsNullOrEmpty(Language))
|
||||
{
|
||||
// Get full language string i.e. eng -> English.
|
||||
string fullLanguage = CultureInfo
|
||||
.GetCultures(CultureTypes.NeutralCultures)
|
||||
.FirstOrDefault(r => r.ThreeLetterISOLanguageName.Equals(Language, StringComparison.OrdinalIgnoreCase))
|
||||
?.DisplayName;
|
||||
// Get full language string i.e. eng -> English, zh-Hans -> Chinese (Simplified).
|
||||
var cultures = CultureInfo.GetCultures(CultureTypes.NeutralCultures);
|
||||
CultureInfo match = null;
|
||||
if (Language.Contains('-', StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
match = cultures.FirstOrDefault(r =>
|
||||
r.Name.Equals(Language, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (match is null)
|
||||
{
|
||||
string baseLang = Language.AsSpan().LeftPart('-').ToString();
|
||||
match = cultures.FirstOrDefault(r =>
|
||||
r.TwoLetterISOLanguageName.Equals(baseLang, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
match = cultures.FirstOrDefault(r =>
|
||||
r.ThreeLetterISOLanguageName.Equals(Language, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
string fullLanguage = match?.DisplayName;
|
||||
attributes.Add(StringHelper.FirstToUpper(fullLanguage ?? Language));
|
||||
}
|
||||
else
|
||||
|
||||
@@ -56,6 +56,7 @@ namespace MediaBrowser.Model.Net
|
||||
".rec",
|
||||
".ts",
|
||||
".rmvb",
|
||||
".vob",
|
||||
".webm",
|
||||
".wmv",
|
||||
".wtv",
|
||||
|
||||
@@ -332,13 +332,11 @@ namespace MediaBrowser.Providers.Manager
|
||||
if (!string.IsNullOrEmpty(itemPath))
|
||||
{
|
||||
var info = FileSystem.GetFileSystemInfo(itemPath);
|
||||
var modificationDate = info.LastWriteTimeUtc;
|
||||
var itemLastModifiedFileSystem = item.DateModified;
|
||||
if (info.Exists && itemLastModifiedFileSystem != modificationDate)
|
||||
if (info.Exists && item.HasChanged(info.LastWriteTimeUtc))
|
||||
{
|
||||
Logger.LogDebug("File modification time changed from {Then} to {Now}: {Path}", itemLastModifiedFileSystem, modificationDate, itemPath);
|
||||
Logger.LogDebug("File modification time changed from {Then} to {Now}: {Path}", item.DateModified, info.LastWriteTimeUtc, itemPath);
|
||||
|
||||
item.DateModified = modificationDate;
|
||||
item.DateModified = info.LastWriteTimeUtc;
|
||||
if (ServerConfigurationManager.GetMetadataConfiguration().UseFileCreationTimeForDateAdded)
|
||||
{
|
||||
item.DateCreated = info.CreationTimeUtc;
|
||||
|
||||
@@ -697,6 +697,7 @@ namespace MediaBrowser.Providers.Manager
|
||||
{
|
||||
_libraryMonitor.ReportFileSystemChangeBeginning(path);
|
||||
await saver.SaveAsync(item, CancellationToken.None).ConfigureAwait(false);
|
||||
item.DateLastSaved = DateTime.UtcNow;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -712,6 +713,7 @@ namespace MediaBrowser.Providers.Manager
|
||||
try
|
||||
{
|
||||
await saver.SaveAsync(item, CancellationToken.None).ConfigureAwait(false);
|
||||
item.DateLastSaved = DateTime.UtcNow;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -276,10 +276,7 @@ namespace MediaBrowser.Providers.MediaInfo
|
||||
|
||||
_mediaStreamRepository.SaveMediaStreams(video.Id, mediaStreams, cancellationToken);
|
||||
|
||||
if (mediaAttachments.Any())
|
||||
{
|
||||
_mediaAttachmentRepository.SaveMediaAttachments(video.Id, mediaAttachments, cancellationToken);
|
||||
}
|
||||
_mediaAttachmentRepository.SaveMediaAttachments(video.Id, mediaAttachments, cancellationToken);
|
||||
|
||||
if (options.MetadataRefreshMode == MetadataRefreshMode.FullRefresh
|
||||
|| options.MetadataRefreshMode == MetadataRefreshMode.Default)
|
||||
|
||||
@@ -130,7 +130,7 @@ namespace MediaBrowser.Providers.MediaInfo
|
||||
if (!string.IsNullOrWhiteSpace(path) && item.IsFileProtocol)
|
||||
{
|
||||
var file = directoryService.GetFile(path);
|
||||
if (file is not null && file.LastWriteTimeUtc != item.DateModified && file.Length != item.Size)
|
||||
if (file is not null && item.HasChanged(file.LastWriteTimeUtc) && file.Length != item.Size)
|
||||
{
|
||||
_logger.LogDebug("Refreshing {ItemPath} due to file system modification.", path);
|
||||
return true;
|
||||
|
||||
@@ -215,7 +215,7 @@ public class PlaylistItemsProvider : ILocalMetadataProvider<Playlist>,
|
||||
if (!string.IsNullOrWhiteSpace(path) && item.IsFileProtocol)
|
||||
{
|
||||
var file = directoryService.GetFile(path);
|
||||
if (file is not null && file.LastWriteTimeUtc != item.DateModified)
|
||||
if (file is not null && item.HasChanged(file.LastWriteTimeUtc))
|
||||
{
|
||||
_logger.LogDebug("Refreshing {Path} due to date modified timestamp change.", path);
|
||||
return true;
|
||||
|
||||
@@ -48,7 +48,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
|
||||
PersonKind.Producer
|
||||
};
|
||||
|
||||
[GeneratedRegex(@"[\W_]+")]
|
||||
[GeneratedRegex(@"[\W_-[·]]+")]
|
||||
private static partial Regex NonWordRegex();
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -56,7 +56,7 @@ public class TrickplayProvider : ICustomMetadataProvider<Episode>,
|
||||
if (item.IsFileProtocol)
|
||||
{
|
||||
var file = directoryService.GetFile(item.Path);
|
||||
if (file is not null && item.DateModified != file.LastWriteTimeUtc)
|
||||
if (file is not null && item.HasChanged(file.LastWriteTimeUtc))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Database.Implementations.DbConfiguration;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jellyfin.Database.Implementations;
|
||||
@@ -20,7 +21,8 @@ public interface IJellyfinDatabaseProvider
|
||||
/// Initialises jellyfins EFCore database access.
|
||||
/// </summary>
|
||||
/// <param name="options">The EFCore database options.</param>
|
||||
void Initialise(DbContextOptionsBuilder options);
|
||||
/// <param name="databaseConfiguration">The Jellyfin database options.</param>
|
||||
void Initialise(DbContextOptionsBuilder options, DatabaseConfigurationOptions databaseConfiguration);
|
||||
|
||||
/// <summary>
|
||||
/// Will be invoked when EFCore wants to build its model.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jellyfin.Server.Implementations.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class ResetJournalMode : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// Resets journal mode to WAL for users that have created their database during 10.11-RC1 or 2
|
||||
migrationBuilder.Sql("PRAGMA journal_mode = 'WAL';", true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ namespace Jellyfin.Server.Implementations.Migrations
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "9.0.6");
|
||||
modelBuilder.HasAnnotation("ProductVersion", "9.0.7");
|
||||
|
||||
modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b =>
|
||||
{
|
||||
|
||||
@@ -2,9 +2,11 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.DbConfiguration;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -38,11 +40,16 @@ public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider
|
||||
public IDbContextFactory<JellyfinDbContext>? DbContextFactory { get; set; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Initialise(DbContextOptionsBuilder options)
|
||||
public void Initialise(DbContextOptionsBuilder options, DatabaseConfigurationOptions databaseConfiguration)
|
||||
{
|
||||
var sqliteConnectionBuilder = new SqliteConnectionStringBuilder();
|
||||
sqliteConnectionBuilder.DataSource = Path.Combine(_applicationPaths.DataPath, "jellyfin.db");
|
||||
sqliteConnectionBuilder.Cache = Enum.Parse<SqliteCacheMode>(databaseConfiguration.CustomProviderOptions?.Options.FirstOrDefault(e => e.Key.Equals("cache", StringComparison.OrdinalIgnoreCase))?.Value ?? nameof(SqliteCacheMode.Default));
|
||||
sqliteConnectionBuilder.Pooling = (databaseConfiguration.CustomProviderOptions?.Options.FirstOrDefault(e => e.Key.Equals("pooling", StringComparison.OrdinalIgnoreCase))?.Value ?? bool.FalseString).Equals(bool.TrueString, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
options
|
||||
.UseSqlite(
|
||||
$"Filename={Path.Combine(_applicationPaths.DataPath, "jellyfin.db")};Pooling=false",
|
||||
sqliteConnectionBuilder.ToString(),
|
||||
sqLiteOptions => sqLiteOptions.MigrationsAssembly(GetType().Assembly))
|
||||
// TODO: Remove when https://github.com/dotnet/efcore/pull/35873 is merged & released
|
||||
.ConfigureWarnings(warnings =>
|
||||
|
||||
@@ -202,18 +202,47 @@ public class SkiaEncoder : IImageEncoder
|
||||
}
|
||||
}
|
||||
|
||||
using var codec = SKCodec.Create(path, out SKCodecResult result);
|
||||
var safePath = NormalizePath(path);
|
||||
if (new FileInfo(safePath).Length == 0)
|
||||
{
|
||||
_logger.LogDebug("Skip zero‑byte image {FilePath}", path);
|
||||
return default;
|
||||
}
|
||||
|
||||
using var codec = SKCodec.Create(safePath, out var result);
|
||||
|
||||
switch (result)
|
||||
{
|
||||
case SKCodecResult.Success:
|
||||
// Skia/SkiaSharp edge‑case: when the image header is parsed but the actual pixel
|
||||
// decode fails (truncated JPEG/PNG, exotic ICC/EXIF, CMYK without color‑transform, etc.)
|
||||
// `SKCodec.Create` returns a *non‑null* codec together with
|
||||
// SKCodecResult.InternalError. The header still contains valid dimensions,
|
||||
// which is all we need here – so we fall back to them instead of aborting.
|
||||
// See e.g. Skia bugs #4139, #6092.
|
||||
case SKCodecResult.InternalError when codec is not null:
|
||||
var info = codec.Info;
|
||||
return new ImageDimensions(info.Width, info.Height);
|
||||
|
||||
case SKCodecResult.Unimplemented:
|
||||
_logger.LogDebug("Image format not supported: {FilePath}", path);
|
||||
return default;
|
||||
|
||||
default:
|
||||
_logger.LogError("Unable to determine image dimensions for {FilePath}: {SkCodecResult}", path, result);
|
||||
{
|
||||
var boundsInfo = SKBitmap.DecodeBounds(safePath);
|
||||
|
||||
if (boundsInfo.Width > 0 && boundsInfo.Height > 0)
|
||||
{
|
||||
return new ImageDimensions(boundsInfo.Width, boundsInfo.Height);
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"Unable to determine image dimensions for {FilePath}: {SkCodecResult}",
|
||||
path,
|
||||
result);
|
||||
return default;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -73,6 +73,10 @@ namespace Jellyfin.LiveTv.IO
|
||||
{
|
||||
_targetPath = targetFile;
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(targetFile));
|
||||
if (!File.Exists(targetFile))
|
||||
{
|
||||
FileHelper.CreateEmpty(targetFile);
|
||||
}
|
||||
|
||||
var processStartInfo = new ProcessStartInfo
|
||||
{
|
||||
|
||||
@@ -23,7 +23,7 @@ namespace Jellyfin.Naming.Tests.Video
|
||||
Test("300-trailer.mp4", ExtraType.Trailer);
|
||||
Test("300.trailer.mp4", ExtraType.Trailer);
|
||||
Test("300_trailer.mp4", ExtraType.Trailer);
|
||||
Test("300 trailer.mp4", ExtraType.Trailer);
|
||||
Test("300 - trailer.mp4", ExtraType.Trailer);
|
||||
|
||||
Test("theme.mp3", ExtraType.ThemeSong);
|
||||
}
|
||||
@@ -132,7 +132,14 @@ namespace Jellyfin.Naming.Tests.Video
|
||||
Test("300-sample.mp4", ExtraType.Sample);
|
||||
Test("300.sample.mp4", ExtraType.Sample);
|
||||
Test("300_sample.mp4", ExtraType.Sample);
|
||||
Test("300 sample.mp4", ExtraType.Sample);
|
||||
Test("300 - sample.mp4", ExtraType.Sample);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TestSuffixPartOfTitle()
|
||||
{
|
||||
Test("I Live In A Trailer.mp4", null);
|
||||
Test("The DNA Sample.mp4", null);
|
||||
}
|
||||
|
||||
private void Test(string input, ExtraType? expectedType)
|
||||
|
||||
@@ -87,7 +87,7 @@ namespace Jellyfin.Naming.Tests.Video
|
||||
var files = new[]
|
||||
{
|
||||
"300.mkv",
|
||||
"300 trailer.mkv"
|
||||
"300 - trailer.mkv"
|
||||
};
|
||||
|
||||
var result = VideoListResolver.Resolve(
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using Emby.Naming.Common;
|
||||
using Emby.Naming.Video;
|
||||
using Emby.Server.Implementations.Library;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.IO;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Server.Implementations.Tests.Library;
|
||||
|
||||
public class CoreResolutionIgnoreRuleTest
|
||||
{
|
||||
private readonly CoreResolutionIgnoreRule _rule;
|
||||
private readonly NamingOptions _namingOptions;
|
||||
private readonly Mock<IServerApplicationPaths> _appPathsMock;
|
||||
|
||||
public CoreResolutionIgnoreRuleTest()
|
||||
{
|
||||
_namingOptions = new NamingOptions();
|
||||
|
||||
_namingOptions.AllExtrasTypesFolderNames.TryAdd("extras", ExtraType.Trailer);
|
||||
|
||||
_appPathsMock = new Mock<IServerApplicationPaths>();
|
||||
_appPathsMock.SetupGet(x => x.RootFolderPath).Returns("/server/root");
|
||||
|
||||
_rule = new CoreResolutionIgnoreRule(_namingOptions, _appPathsMock.Object);
|
||||
}
|
||||
|
||||
private FileSystemMetadata MakeFileSystemMetadata(string fullName, bool isDirectory = false)
|
||||
=> new FileSystemMetadata { FullName = fullName, Name = Path.GetFileName(fullName), IsDirectory = isDirectory };
|
||||
|
||||
private BaseItem MakeParent(string name = "Parent", bool isTopParent = false, Type? type = null)
|
||||
{
|
||||
return type switch
|
||||
{
|
||||
Type t when t == typeof(Folder) => CreateMock<Folder>(name, isTopParent).Object,
|
||||
Type t when t == typeof(AggregateFolder) => CreateMock<AggregateFolder>(name, isTopParent).Object,
|
||||
Type t when t == typeof(UserRootFolder) => CreateMock<UserRootFolder>(name, isTopParent).Object,
|
||||
_ => CreateMock<BaseItem>(name, isTopParent).Object
|
||||
};
|
||||
}
|
||||
|
||||
private static Mock<T> CreateMock<T>(string name, bool isTopParent)
|
||||
where T : BaseItem
|
||||
{
|
||||
var mock = new Mock<T>();
|
||||
mock.SetupGet(p => p.Name).Returns(name);
|
||||
mock.SetupGet(p => p.IsTopParent).Returns(isTopParent);
|
||||
return mock;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TestApplicationFolder()
|
||||
{
|
||||
Assert.False(_rule.ShouldIgnore(
|
||||
MakeFileSystemMetadata("/server/root/extras", isDirectory: true),
|
||||
null));
|
||||
|
||||
Assert.False(_rule.ShouldIgnore(
|
||||
MakeFileSystemMetadata("/server/root/small.jpg"),
|
||||
null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TestTopLevelDirectory()
|
||||
{
|
||||
Assert.False(_rule.ShouldIgnore(
|
||||
MakeFileSystemMetadata("Series/Extras", true),
|
||||
MakeParent(type: typeof(AggregateFolder))));
|
||||
|
||||
Assert.False(_rule.ShouldIgnore(
|
||||
MakeFileSystemMetadata("Series/Extras/Extras", true),
|
||||
MakeParent(isTopParent: true)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TestIgnorePatterns()
|
||||
{
|
||||
Assert.False(_rule.ShouldIgnore(
|
||||
MakeFileSystemMetadata("/Media/big.jpg"),
|
||||
MakeParent()));
|
||||
|
||||
Assert.True(_rule.ShouldIgnore(
|
||||
MakeFileSystemMetadata("/Media/small.jpg"),
|
||||
MakeParent()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TestExtrasTypesFolderNames()
|
||||
{
|
||||
FileSystemMetadata fileSystemMetadata = MakeFileSystemMetadata("/Movies/Up/extras", true);
|
||||
|
||||
Assert.False(_rule.ShouldIgnore(
|
||||
fileSystemMetadata,
|
||||
MakeParent(type: typeof(AggregateFolder))));
|
||||
|
||||
Assert.False(_rule.ShouldIgnore(
|
||||
fileSystemMetadata,
|
||||
MakeParent(type: typeof(UserRootFolder))));
|
||||
|
||||
Assert.False(_rule.ShouldIgnore(
|
||||
fileSystemMetadata,
|
||||
null));
|
||||
|
||||
Assert.True(_rule.ShouldIgnore(
|
||||
fileSystemMetadata,
|
||||
MakeParent()));
|
||||
|
||||
Assert.True(_rule.ShouldIgnore(
|
||||
fileSystemMetadata,
|
||||
MakeParent(type: typeof(Folder))));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TestThemeSong()
|
||||
{
|
||||
Assert.False(_rule.ShouldIgnore(
|
||||
MakeFileSystemMetadata("/Movies/Up/intro.mp3"),
|
||||
MakeParent()));
|
||||
|
||||
Assert.True(_rule.ShouldIgnore(
|
||||
MakeFileSystemMetadata("/Movies/Up/theme.mp3"),
|
||||
MakeParent()));
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,7 @@ namespace Jellyfin.Server.Implementations.Tests.Localization
|
||||
await localizationManager.LoadAll();
|
||||
var cultures = localizationManager.GetCultures().ToList();
|
||||
|
||||
Assert.Equal(191, cultures.Count);
|
||||
Assert.Equal(194, cultures.Count);
|
||||
|
||||
var germany = cultures.FirstOrDefault(x => x.TwoLetterISOLanguageName.Equals("de", StringComparison.Ordinal));
|
||||
Assert.NotNull(germany);
|
||||
|
||||
@@ -301,7 +301,7 @@ namespace Jellyfin.Server.Implementations.Tests.Plugins
|
||||
|
||||
var versionInfo = fixture.Create<VersionInfo>();
|
||||
versionInfo.Version = new Version(1, 0).ToString();
|
||||
versionInfo.Timestamp = DateTime.UtcNow.ToString(CultureInfo.InvariantCulture);
|
||||
versionInfo.Timestamp = DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture);
|
||||
|
||||
var packageInfo = fixture.Create<PackageInfo>();
|
||||
packageInfo.Versions = new[] { versionInfo };
|
||||
|
||||
Reference in New Issue
Block a user