diff options
54 files changed, 1197 insertions, 161 deletions
diff --git a/.github/workflows/ci-codeql-analysis.yml b/.github/workflows/ci-codeql-analysis.yml index bc7ef7f5da..ca7213a690 100644 --- a/.github/workflows/ci-codeql-analysis.yml +++ b/.github/workflows/ci-codeql-analysis.yml @@ -24,7 +24,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup .NET uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 @@ -32,13 +32,13 @@ jobs: dotnet-version: '10.0.x' - name: Initialize CodeQL - uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1 + uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 with: languages: ${{ matrix.language }} queries: +security-extended - name: Autobuild - uses: github/codeql-action/autobuild@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1 + uses: github/codeql-action/autobuild@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1 + uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 diff --git a/.github/workflows/ci-compat.yml b/.github/workflows/ci-compat.yml index f2c0a5c4cd..bf28129368 100644 --- a/.github/workflows/ci-compat.yml +++ b/.github/workflows/ci-compat.yml @@ -11,7 +11,7 @@ jobs: permissions: read-all steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.pull_request.head.sha }} repository: ${{ github.event.pull_request.head.repo.full_name }} @@ -40,7 +40,7 @@ jobs: permissions: read-all steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.pull_request.head.sha }} repository: ${{ github.event.pull_request.head.repo.full_name }} diff --git a/.github/workflows/ci-format.yml b/.github/workflows/ci-format.yml index 4531c824ce..73c8e7e50c 100644 --- a/.github/workflows/ci-format.yml +++ b/.github/workflows/ci-format.yml @@ -15,7 +15,7 @@ jobs: format-check: runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index f8704fb1eb..5b29a66382 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -20,7 +20,7 @@ jobs: runs-on: "${{ matrix.os }}" steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: diff --git a/.github/workflows/commands.yml b/.github/workflows/commands.yml index b43ed3ee27..d14ce6d508 100644 --- a/.github/workflows/commands.yml +++ b/.github/workflows/commands.yml @@ -24,7 +24,7 @@ jobs: reactions: '+1' - name: Checkout the latest code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: token: ${{ secrets.JF_BOT_TOKEN }} fetch-depth: 0 @@ -40,7 +40,7 @@ jobs: runs-on: ubuntu-latest steps: - name: pull in script - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: jellyfin/jellyfin-triage-script diff --git a/.github/workflows/issue-template-check.yml b/.github/workflows/issue-template-check.yml index 1d3294798b..3220bcbeac 100644 --- a/.github/workflows/issue-template-check.yml +++ b/.github/workflows/issue-template-check.yml @@ -10,7 +10,7 @@ jobs: issues: write steps: - name: pull in script - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: jellyfin/jellyfin-triage-script diff --git a/.github/workflows/openapi-generate.yml b/.github/workflows/openapi-generate.yml index 09ab727c15..48bd8adb03 100644 --- a/.github/workflows/openapi-generate.yml +++ b/.github/workflows/openapi-generate.yml @@ -22,7 +22,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ inputs.ref }} repository: ${{ inputs.repository }} diff --git a/.github/workflows/openapi-pull-request.yml b/.github/workflows/openapi-pull-request.yml index 6b5d035420..9ed1873942 100644 --- a/.github/workflows/openapi-pull-request.yml +++ b/.github/workflows/openapi-pull-request.yml @@ -10,7 +10,7 @@ jobs: base_ref: ${{ steps.ancestor.outputs.base_ref }} steps: - name: Checkout Repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.pull_request.head.sha }} repository: ${{ github.event.pull_request.head.repo.full_name }} diff --git a/.github/workflows/release-bump-version.yaml b/.github/workflows/release-bump-version.yaml index d22e8bf6bd..4d6e1ad6fa 100644 --- a/.github/workflows/release-bump-version.yaml +++ b/.github/workflows/release-bump-version.yaml @@ -33,7 +33,7 @@ jobs: yq-version: v4.9.8 - name: Checkout Repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ env.TAG_BRANCH }} @@ -66,7 +66,7 @@ jobs: NEXT_VERSION: ${{ github.event.inputs.NEXT_VERSION }} steps: - name: Checkout Repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ env.TAG_BRANCH }} diff --git a/Directory.Packages.props b/Directory.Packages.props index a840d26ce1..15f36872e0 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -68,7 +68,7 @@ <PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" /> <PackageVersion Include="Serilog.Sinks.Graylog" Version="3.1.1" /> <PackageVersion Include="SerilogAnalyzer" Version="0.15.0" /> - <PackageVersion Include="SharpCompress" Version="0.49.1" /> + <PackageVersion Include="SharpCompress" Version="0.50.0" /> <PackageVersion Include="SharpFuzz" Version="2.3.0" /> <PackageVersion Include="SkiaSharp" Version="3.119.4" /> <PackageVersion Include="SkiaSharp.HarfBuzz" Version="3.119.4" /> diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index 69e23bcb63..0c1c7d3f5b 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -39,6 +39,8 @@ using Emby.Server.Implementations.SyncPlay; using Emby.Server.Implementations.TV; using Emby.Server.Implementations.Updates; using Jellyfin.Api.Helpers; +using Jellyfin.Data; +using Jellyfin.Database.Implementations.Enums; using Jellyfin.Drawing; using Jellyfin.MediaEncoding.Hls.Playlist; using Jellyfin.Networking.Manager; @@ -417,6 +419,8 @@ namespace Emby.Server.Implementations { Logger.LogInformation("Running startup tasks"); + EnsureStartupWizardIntegrity(); + Resolve<ITaskManager>().AddTasks(GetExports<IScheduledTask>(false)); ConfigurationManager.ConfigurationUpdated += OnConfigurationUpdated; @@ -436,6 +440,24 @@ namespace Emby.Server.Implementations return Task.CompletedTask; } + private void EnsureStartupWizardIntegrity() + { + if (ConfigurationManager.CommonConfiguration.IsStartupWizardCompleted) + { + return; + } + + var hasConfiguredAdministrator = Resolve<IUserManager>().GetUsers() + .Any(user => user.HasPermission(PermissionKind.IsAdministrator) && !string.IsNullOrEmpty(user.Password)); + + if (hasConfiguredAdministrator) + { + Logger.LogWarning("The startup wizard is marked incomplete but a configured administrator already exists. Marking setup as completed to prevent the unauthenticated setup endpoints from being reachable."); + ConfigurationManager.Configuration.IsStartupWizardCompleted = true; + ConfigurationManager.SaveConfiguration(); + } + } + /// <inheritdoc/> public void Init(IServiceCollection serviceCollection) { diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 3691f4e19d..f684f4dca3 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -3886,5 +3886,17 @@ namespace Emby.Server.Implementations.Library { return _mediaStreamRepository.GetMediaStreamLanguages(mediaStreamType); } + + /// <inheritdoc /> + public IReadOnlyList<string> GetMediaStreamLanguages(MediaStreamType mediaStreamType, InternalItemsQuery query) + { + if (query.User is not null) + { + AddUserToQuery(query, query.User); + } + + SetTopParentOrAncestorIds(query); + return _itemRepository.GetMediaStreamLanguages(query, mediaStreamType); + } } } diff --git a/Emby.Server.Implementations/Library/PathManager.cs b/Emby.Server.Implementations/Library/PathManager.cs index fad948ad97..2a50fcc7fe 100644 --- a/Emby.Server.Implementations/Library/PathManager.cs +++ b/Emby.Server.Implementations/Library/PathManager.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Globalization; using System.IO; +using Jellyfin.Extensions; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; @@ -43,7 +44,19 @@ public class PathManager : IPathManager public string? GetAttachmentPath(string mediaSourceId, string fileName) { var folder = GetAttachmentFolderPath(mediaSourceId); - return folder is null ? null : Path.Combine(folder, fileName); + if (folder is null) + { + return null; + } + + var safeName = PathHelper.GetSafeLeafFileName(fileName); + if (safeName is null) + { + _logger.LogWarning("Rejecting attachment filename '{FileName}' for MediaSource {MediaSourceId}: not a valid leaf name.", fileName, mediaSourceId); + return null; + } + + return Path.Combine(folder, safeName); } /// <inheritdoc /> diff --git a/Emby.Server.Implementations/Localization/Core/lv.json b/Emby.Server.Implementations/Localization/Core/lv.json index 4a1b248e76..76fa9e3cf7 100644 --- a/Emby.Server.Implementations/Localization/Core/lv.json +++ b/Emby.Server.Implementations/Localization/Core/lv.json @@ -107,5 +107,6 @@ "TaskDownloadMissingLyricsDescription": "Lejupielādēt vārdus dziesmām", "CleanupUserDataTask": "Lietotāju datu tīrīšanas uzdevums", "CleanupUserDataTaskDescription": "Notīra visus lietotāja datus (skatīšanās stāvokļus, favorītu statusi utt.) no medijiem, kas vairs nav pieejami vismaz 90 dienas.", - "Original": "Oriģināls" + "Original": "Oriģināls", + "LyricDownloadFailureFromForItem": "Dziesmu vārdi nevarēja tikt lejupielādēti no {0} priekš {1}" } diff --git a/Emby.Server.Implementations/Localization/LocalizationManager.cs b/Emby.Server.Implementations/Localization/LocalizationManager.cs index e7dd984ec4..0331ec39e5 100644 --- a/Emby.Server.Implementations/Localization/LocalizationManager.cs +++ b/Emby.Server.Implementations/Localization/LocalizationManager.cs @@ -262,6 +262,24 @@ namespace Emby.Server.Implementations.Localization } /// <inheritdoc /> + public string? GetLanguageDisplayName(string language) + { + if (string.IsNullOrEmpty(language)) + { + return null; + } + + var displayName = FindLanguageInfo(language)?.DisplayName; + if (displayName is null) + { + return null; + } + + // Truncate at the first delimiter to avoid cluttered display names + return displayName.Split([';', ','], StringSplitOptions.None)[0].Trim(); + } + + /// <inheritdoc /> public IReadOnlyList<CountryInfo> GetCountries() { using var stream = _assembly.GetManifestResourceStream(CountriesPath) ?? throw new InvalidOperationException($"Invalid resource path: '{CountriesPath}'"); diff --git a/Jellyfin.Api/Controllers/FilterController.cs b/Jellyfin.Api/Controllers/FilterController.cs index cfc8be28ae..b458bd90f3 100644 --- a/Jellyfin.Api/Controllers/FilterController.cs +++ b/Jellyfin.Api/Controllers/FilterController.cs @@ -158,13 +158,43 @@ public class FilterController : BaseJellyfinApiController IsSeries = isSeries }; + var streamLanguageQuery = new InternalItemsQuery(user) + { + // It's possible that different langauges are only available on alternative versions. + // To fetch them all, owned items are included. + IncludeOwnedItems = true, + IncludeItemTypes = includeItemTypes, + DtoOptions = new DtoOptions + { + Fields = Array.Empty<ItemFields>(), + EnableImages = false, + EnableUserData = false + }, + IsAiring = isAiring, + IsMovie = isMovie, + IsSports = isSports, + IsKids = isKids, + IsNews = isNews, + IsSeries = isSeries + }; + if ((recursive ?? true) || parentItem is UserView || parentItem is ICollectionFolder) { - genreQuery.AncestorIds = parentItem is null ? Array.Empty<Guid>() : new[] { parentItem.Id }; + var ancestorIds = parentItem is null ? Array.Empty<Guid>() : new[] { parentItem.Id }; + genreQuery.AncestorIds = ancestorIds; + streamLanguageQuery.AncestorIds = ancestorIds; } else { genreQuery.Parent = parentItem; + streamLanguageQuery.Parent = parentItem; + } + + if ((includeItemTypes.Contains(BaseItemKind.Series) || includeItemTypes.Contains(BaseItemKind.Season)) + && !includeItemTypes.Contains(BaseItemKind.Episode)) + { + // streams are joined on epsiodes not shows or seasons + streamLanguageQuery.IncludeItemTypes = [.. includeItemTypes, BaseItemKind.Episode]; } if (includeItemTypes.Length == 1 @@ -188,10 +218,13 @@ public class FilterController : BaseJellyfinApiController }).ToArray(); } - if (includeItemTypes.Contains(BaseItemKind.Movie) || includeItemTypes.Contains(BaseItemKind.Series)) + if (includeItemTypes.Contains(BaseItemKind.Movie) + || includeItemTypes.Contains(BaseItemKind.Series) + || includeItemTypes.Contains(BaseItemKind.Season) + || includeItemTypes.Contains(BaseItemKind.Episode)) { filters.AudioLanguages = _libraryManager - .GetMediaStreamLanguages(MediaStreamType.Audio) + .GetMediaStreamLanguages(MediaStreamType.Audio, streamLanguageQuery) .Select(language => { var culture = _localization.FindLanguageInfo(language); @@ -204,7 +237,7 @@ public class FilterController : BaseJellyfinApiController .OrderBy(l => l.Name) .ToArray(); filters.SubtitleLanguages = _libraryManager - .GetMediaStreamLanguages(MediaStreamType.Subtitle) + .GetMediaStreamLanguages(MediaStreamType.Subtitle, streamLanguageQuery) .Select(language => { var culture = _localization.FindLanguageInfo(language); diff --git a/Jellyfin.Api/Controllers/ImageController.cs b/Jellyfin.Api/Controllers/ImageController.cs index 52d8b4dad1..d492a2f5ba 100644 --- a/Jellyfin.Api/Controllers/ImageController.cs +++ b/Jellyfin.Api/Controllers/ImageController.cs @@ -125,7 +125,13 @@ public class ImageController : BaseJellyfinApiController { // Handle image/png; charset=utf-8 var mimeType = Request.ContentType?.Split(';').FirstOrDefault(); - var userDataPath = Path.Combine(_serverConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath, user.Username); + var userConfigurationDirectoryPath = _serverConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath; + var userDataPath = Path.Combine(userConfigurationDirectoryPath, user.Username); + if (!PathHelper.IsContainedIn(userConfigurationDirectoryPath, userDataPath)) + { + return BadRequest("Invalid user."); + } + if (user.ProfileImage is not null) { await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false); diff --git a/Jellyfin.Api/Controllers/ItemUpdateController.cs b/Jellyfin.Api/Controllers/ItemUpdateController.cs index e26264d292..36c82cf461 100644 --- a/Jellyfin.Api/Controllers/ItemUpdateController.cs +++ b/Jellyfin.Api/Controllers/ItemUpdateController.cs @@ -236,7 +236,7 @@ public class ItemUpdateController : BaseJellyfinApiController return NoContent(); } - private async Task UpdateItem(BaseItemDto request, BaseItem item) + internal async Task UpdateItem(BaseItemDto request, BaseItem item) { item.Name = request.Name; item.ForcedSortName = request.ForcedSortName; @@ -250,7 +250,11 @@ public class ItemUpdateController : BaseJellyfinApiController item.IndexNumber = request.IndexNumber; item.ParentIndexNumber = request.ParentIndexNumber; item.Overview = request.Overview; - item.Genres = request.Genres.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + + if (request.Genres is not null) + { + item.Genres = request.Genres.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + } if (item is Episode episode) { @@ -293,10 +297,20 @@ public class ItemUpdateController : BaseJellyfinApiController item.CustomRating = request.CustomRating; var currentTags = item.Tags; - var newTags = request.Tags.Select(t => t.Trim()).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); - var removedTags = currentTags.Except(newTags).ToList(); - var addedTags = newTags.Except(currentTags).ToList(); - item.Tags = newTags; + List<string> removedTags; + List<string> addedTags; + if (request.Tags is not null) + { + var newTags = request.Tags.Select(t => t.Trim()).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + removedTags = currentTags.Except(newTags).ToList(); + addedTags = newTags.Except(currentTags).ToList(); + item.Tags = newTags; + } + else + { + removedTags = []; + addedTags = []; + } if (item is Series rseries) { @@ -412,15 +426,18 @@ public class ItemUpdateController : BaseJellyfinApiController item.RunTimeTicks = request.RunTimeTicks; } - foreach (var pair in request.ProviderIds.ToList()) + if (request.ProviderIds is not null) { - if (string.IsNullOrEmpty(pair.Value)) + foreach (var pair in request.ProviderIds.ToList()) { - request.ProviderIds.Remove(pair.Key); + if (string.IsNullOrEmpty(pair.Value)) + { + request.ProviderIds.Remove(pair.Key); + } } - } - item.ProviderIds = request.ProviderIds; + item.ProviderIds = request.ProviderIds; + } if (item is Video video) { diff --git a/Jellyfin.Api/Controllers/PluginsController.cs b/Jellyfin.Api/Controllers/PluginsController.cs index 2c84fde972..79b8b60cb7 100644 --- a/Jellyfin.Api/Controllers/PluginsController.cs +++ b/Jellyfin.Api/Controllers/PluginsController.cs @@ -6,6 +6,7 @@ using System.Linq; using System.Text.Json; using System.Threading.Tasks; using Jellyfin.Api.Attributes; +using Jellyfin.Extensions; using Jellyfin.Extensions.Json; using MediaBrowser.Common.Api; using MediaBrowser.Common.Plugins; diff --git a/Jellyfin.Api/Controllers/StartupController.cs b/Jellyfin.Api/Controllers/StartupController.cs index fa6d9efe36..47c8f21241 100644 --- a/Jellyfin.Api/Controllers/StartupController.cs +++ b/Jellyfin.Api/Controllers/StartupController.cs @@ -140,6 +140,11 @@ public class StartupController : BaseJellyfinApiController return NotFound(); } + if (!string.IsNullOrEmpty(user.Password)) + { + return Forbid(); + } + if (string.IsNullOrWhiteSpace(startupUserDto.Password)) { return BadRequest("Password must not be empty"); diff --git a/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs b/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs index a534fa5fa0..7c10a5dc77 100644 --- a/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs +++ b/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs @@ -359,18 +359,39 @@ public class BackupService : IBackupService jsonSerializer.WriteStartArray(); var set = entityType.ValueFactory().ConfigureAwait(false); - await foreach (var item in set.ConfigureAwait(false)) + var enumerator = set.GetAsyncEnumerator(); + await using (enumerator) { - entities++; - try + while (true) { - using var document = JsonSerializer.SerializeToDocument(item, _serializerSettings); - document.WriteTo(jsonSerializer); - } - catch (Exception ex) - { - _logger.LogError(ex, "Could not load entity {Entity}", item); - throw; + bool hasNext; + try + { + hasNext = await enumerator.MoveNextAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Could not read next entity of type {Table}, the underlying data appears to be corrupt. Skipping this row and continuing backup; the affected database row should be inspected and fixed manually", entityType.SourceName); + continue; + } + + if (!hasNext) + { + break; + } + + var item = enumerator.Current; + entities++; + try + { + using var document = JsonSerializer.SerializeToDocument(item, _serializerSettings); + document.WriteTo(jsonSerializer); + } + catch (Exception ex) + { + _logger.LogError(ex, "Could not load entity {Entity}", item); + throw; + } } } diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs index c5b5fbf6d8..6fe8563bf5 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs @@ -7,6 +7,7 @@ using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Entities; using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; using MediaBrowser.Model.Querying; using Microsoft.EntityFrameworkCore; using BaseItemDto = MediaBrowser.Controller.Entities.BaseItem; @@ -81,6 +82,41 @@ public sealed partial class BaseItemRepository _itemTypeLookup.MusicGenreTypes); } + /// <inheritdoc /> + public IReadOnlyList<string> GetMediaStreamLanguages(InternalItemsQuery filter, MediaStreamType mediaStreamType) + { + ArgumentNullException.ThrowIfNull(filter); + + using var context = _dbProvider.CreateDbContext(); + + return TranslateQuery( + context.BaseItems.Include(e => e.MediaStreams).Where(e => e.Id != EF.Constant(PlaceholderId)), + context, + new InternalItemsQuery(filter.User) + { + IncludeOwnedItems = filter.IncludeOwnedItems, + ExcludeItemTypes = filter.ExcludeItemTypes, + IncludeItemTypes = filter.IncludeItemTypes, + MediaTypes = filter.MediaTypes, + AncestorIds = filter.AncestorIds, + ItemIds = filter.ItemIds, + TopParentIds = filter.TopParentIds, + ParentId = filter.ParentId, + IsAiring = filter.IsAiring, + IsMovie = filter.IsMovie, + IsSports = filter.IsSports, + IsKids = filter.IsKids, + IsNews = filter.IsNews, + IsSeries = filter.IsSeries + }) + .Where(e => e.MediaStreams != null) + .SelectMany(e => e.MediaStreams!) + .Where(e => e.StreamType == (MediaStreamTypeEntity)mediaStreamType) + .Select(s => string.IsNullOrEmpty(s.Language) ? "und" : s.Language) // und = undetermined + .Distinct() + .ToArray(); + } + private string[] GetItemValueNames(IReadOnlyList<ItemValueType> itemValueTypes, IReadOnlyList<string> withItemTypes, IReadOnlyList<string> excludeItemTypes) { using var context = _dbProvider.CreateDbContext(); diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs index decd45ae2c..a4de9feb05 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs @@ -447,6 +447,7 @@ public sealed partial class BaseItemRepository if (filter.IncludeInheritedTags.Length > 0) { var includeTags = filter.IncludeInheritedTags.Select(e => e.GetCleanValue()).ToArray(); + var personTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Person]; var allowedTagItemIds = context.ItemValuesMap .Where(f => f.ItemValue.Type == ItemValueType.Tags && includeTags.Contains(f.ItemValue.CleanValue)) .Select(f => f.ItemId); @@ -455,7 +456,10 @@ public sealed partial class BaseItemRepository allowedTagItemIds.Contains(e.Id) || (e.SeriesId.HasValue && allowedTagItemIds.Contains(e.SeriesId.Value)) || e.Parents!.Any(p => allowedTagItemIds.Contains(p.ParentItemId)) - || (e.TopParentId.HasValue && allowedTagItemIds.Contains(e.TopParentId.Value))); + || (e.TopParentId.HasValue && allowedTagItemIds.Contains(e.TopParentId.Value)) + + // People don't carry the tags of the media they appear in and would never match + || e.Type == personTypeName); } // Exclude alternate versions (have PrimaryVersionId set) and owned non-extra items. diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs index 1198f99473..f19df6259e 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs @@ -1089,6 +1089,7 @@ public sealed partial class BaseItemRepository { var includeTags = filter.IncludeInheritedTags.Select(e => e.GetCleanValue()).ToArray(); var isPlaylistOnlyQuery = includeTypes.Length == 1 && includeTypes.FirstOrDefault() == BaseItemKind.Playlist; + var personTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Person]; var allowedTagItemIds = context.ItemValuesMap .Where(f => f.ItemValue.Type == ItemValueType.Tags && includeTags.Contains(f.ItemValue.CleanValue)) .Select(f => f.ItemId); @@ -1099,6 +1100,9 @@ public sealed partial class BaseItemRepository || e.Parents!.Any(p => allowedTagItemIds.Contains(p.ParentItemId)) || (e.TopParentId.HasValue && allowedTagItemIds.Contains(e.TopParentId.Value)) + // People don't carry the tags of the media they appear in and would never match + || e.Type == personTypeName + // A playlist should be accessible to its owner regardless of allowed tags || (isPlaylistOnlyQuery && e.Data!.Contains($"OwnerUserId\":\"{filter.User!.Id:N}\""))); } diff --git a/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs b/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs index 7fa33c8639..a25629132b 100644 --- a/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs +++ b/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs @@ -172,8 +172,7 @@ public class MediaStreamRepository : IMediaStreamRepository if (!string.IsNullOrEmpty(dto.Language)) { - var culture = _localization.FindLanguageInfo(dto.Language); - dto.LocalizedLanguage = culture?.DisplayName; + dto.LocalizedLanguage = _localization.GetLanguageDisplayName(dto.Language); } if (dto.Type is MediaStreamType.Audio) diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs index 41648268a9..81408d9aa8 100644 --- a/Jellyfin.Server.Implementations/Users/UserManager.cs +++ b/Jellyfin.Server.Implementations/Users/UserManager.cs @@ -911,7 +911,7 @@ namespace Jellyfin.Server.Implementations.Users internal static void ThrowIfInvalidUsername(string name) { - if (!string.IsNullOrWhiteSpace(name) && ValidUsernameRegex().IsMatch(name)) + if (!string.IsNullOrWhiteSpace(name) && ValidUsernameRegex().IsMatch(name) && !string.Equals(name, ".", StringComparison.Ordinal) && !string.Equals(name, "..", StringComparison.Ordinal)) { return; } diff --git a/MediaBrowser.Controller/ClientEvent/ClientEventLogger.cs b/MediaBrowser.Controller/ClientEvent/ClientEventLogger.cs index 14dc64dabd..36f0d2195c 100644 --- a/MediaBrowser.Controller/ClientEvent/ClientEventLogger.cs +++ b/MediaBrowser.Controller/ClientEvent/ClientEventLogger.cs @@ -1,6 +1,7 @@ using System; using System.IO; using System.Threading.Tasks; +using Jellyfin.Extensions; namespace MediaBrowser.Controller.ClientEvent { @@ -21,8 +22,15 @@ namespace MediaBrowser.Controller.ClientEvent /// <inheritdoc /> public async Task<string> WriteDocumentAsync(string clientName, string clientVersion, Stream fileContents) { - var fileName = $"upload_{clientName}_{clientVersion}_{DateTime.UtcNow:yyyyMMddHHmmss}_{Guid.NewGuid():N}.log"; + var safeClientName = PathHelper.GetSafeLeafFileName(clientName) ?? "unknown-client"; + var safeClientVersion = PathHelper.GetSafeLeafFileName(clientVersion) ?? "unknown-version"; + var fileName = $"upload_{safeClientName}_{safeClientVersion}_{DateTime.UtcNow:yyyyMMddHHmmss}_{Guid.NewGuid():N}.log"; var logFilePath = Path.Combine(_applicationPaths.LogDirectoryPath, fileName); + if (!PathHelper.IsContainedIn(_applicationPaths.LogDirectoryPath, logFilePath)) + { + throw new ArgumentException("Path resolved to filename not in log directory"); + } + var fileStream = new FileStream(logFilePath, FileMode.CreateNew, FileAccess.Write, FileShare.None); await using (fileStream.ConfigureAwait(false)) { diff --git a/MediaBrowser.Controller/Entities/Person.cs b/MediaBrowser.Controller/Entities/Person.cs index 5cc4d322f7..14325d971a 100644 --- a/MediaBrowser.Controller/Entities/Person.cs +++ b/MediaBrowser.Controller/Entities/Person.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Text.Json.Serialization; +using Jellyfin.Database.Implementations.Entities; using Jellyfin.Extensions; using MediaBrowser.Controller.Providers; using Microsoft.Extensions.Logging; @@ -75,6 +76,16 @@ namespace MediaBrowser.Controller.Entities return false; } + /// <inheritdoc /> + /// <remarks> + /// People don't carry the tags of the media they appear in, so the allowed tags check + /// is skipped for them; otherwise no person would be visible to users with allowed tags configured. + /// </remarks> + public override bool IsVisible(User user, bool skipAllowedTagsCheck = false) + { + return base.IsVisible(user, true); + } + public override bool IsSaveLocalMetadataEnabled() { return true; diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs index 0b64da291c..6d85a1e401 100644 --- a/MediaBrowser.Controller/Library/ILibraryManager.cs +++ b/MediaBrowser.Controller/Library/ILibraryManager.cs @@ -799,5 +799,13 @@ namespace MediaBrowser.Controller.Library /// <param name="mediaStreamType">The stream type.</param> /// <returns>List of language codes.</returns> IReadOnlyList<string> GetMediaStreamLanguages(MediaStreamType mediaStreamType); + + /// <summary> + /// Gets a list of all language codes for the matching items and the the provided stream type. + /// </summary> + /// <param name="mediaStreamType">The stream type.</param> + /// <param name="query">The query filter.</param> + /// <returns>List of language codes.</returns> + IReadOnlyList<string> GetMediaStreamLanguages(MediaStreamType mediaStreamType, InternalItemsQuery query); } } diff --git a/MediaBrowser.Controller/Persistence/IItemRepository.cs b/MediaBrowser.Controller/Persistence/IItemRepository.cs index 291916ab25..d44fe57bed 100644 --- a/MediaBrowser.Controller/Persistence/IItemRepository.cs +++ b/MediaBrowser.Controller/Persistence/IItemRepository.cs @@ -7,6 +7,7 @@ using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Entities; using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; using MediaBrowser.Model.Querying; namespace MediaBrowser.Controller.Persistence; @@ -120,6 +121,14 @@ public interface IItemRepository IReadOnlyList<string> GetGenreNames(); /// <summary> + /// Gets all language codes of the matching base items and the provided stream type. + /// </summary> + /// <param name="filter">The query filter.</param> + /// <param name="mediaStreamType">The type of the media stream.</param> + /// <returns>List of language codes.</returns> + public IReadOnlyList<string> GetMediaStreamLanguages(InternalItemsQuery filter, MediaStreamType mediaStreamType); + + /// <summary> /// Gets all artist names. /// </summary> /// <returns>The list of artist names.</returns> diff --git a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs index 9dd3dcecba..12a5ab877c 100644 --- a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs +++ b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs @@ -8,6 +8,7 @@ using System.Text; using System.Threading; using System.Threading.Tasks; using AsyncKeyedLock; +using Jellyfin.Extensions; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.IO; @@ -101,7 +102,7 @@ namespace MediaBrowser.MediaEncoding.Attachments CancellationToken cancellationToken) { var shouldExtractOneByOne = mediaSource.MediaAttachments.Any(a => !string.IsNullOrEmpty(a.FileName) - && (a.FileName.Contains('/', StringComparison.OrdinalIgnoreCase) || a.FileName.Contains('\\', StringComparison.OrdinalIgnoreCase))); + && !string.Equals(PathHelper.GetSafeLeafFileName(a.FileName), a.FileName, StringComparison.Ordinal)); if (shouldExtractOneByOne && !inputFile.EndsWith(".mks", StringComparison.OrdinalIgnoreCase)) { await ExtractAllAttachmentsIndividuallyInternal( @@ -387,7 +388,9 @@ namespace MediaBrowser.MediaEncoding.Attachments using (await _semaphoreLocks.LockAsync(attachmentFolderPath, cancellationToken).ConfigureAwait(false)) { - var attachmentPath = _pathManager.GetAttachmentPath(mediaSource.Id, mediaAttachment.FileName ?? mediaAttachment.Index.ToString(CultureInfo.InvariantCulture))!; + var indexName = mediaAttachment.Index.ToString(CultureInfo.InvariantCulture); + var attachmentPath = _pathManager.GetAttachmentPath(mediaSource.Id, mediaAttachment.FileName ?? indexName) + ?? _pathManager.GetAttachmentPath(mediaSource.Id, indexName)!; if (!File.Exists(attachmentPath)) { await ExtractAttachmentInternal( diff --git a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs index 203e565ac7..989701350c 100644 --- a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs +++ b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs @@ -732,9 +732,10 @@ namespace MediaBrowser.MediaEncoding.Probing stream.LocalizedDefault = _localization.GetLocalizedString("Default"); stream.LocalizedExternal = _localization.GetLocalizedString("External"); stream.LocalizedOriginal = _localization.GetLocalizedString("Original"); - stream.LocalizedLanguage = string.IsNullOrEmpty(stream.Language) - ? null - : _localization.FindLanguageInfo(stream.Language)?.DisplayName; + if (!string.IsNullOrEmpty(stream.Language)) + { + stream.LocalizedLanguage = _localization.GetLanguageDisplayName(stream.Language); + } stream.Channels = streamInfo.Channels; @@ -773,9 +774,10 @@ namespace MediaBrowser.MediaEncoding.Probing stream.LocalizedForced = _localization.GetLocalizedString("Forced"); stream.LocalizedExternal = _localization.GetLocalizedString("External"); stream.LocalizedHearingImpaired = _localization.GetLocalizedString("HearingImpaired"); - stream.LocalizedLanguage = string.IsNullOrEmpty(stream.Language) - ? null - : _localization.FindLanguageInfo(stream.Language)?.DisplayName; + if (!string.IsNullOrEmpty(stream.Language)) + { + stream.LocalizedLanguage = _localization.GetLanguageDisplayName(stream.Language); + } if (string.IsNullOrEmpty(stream.Title)) { diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs index c568a74b01..bd516f0a9f 100644 --- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs +++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs @@ -163,28 +163,36 @@ namespace MediaBrowser.MediaEncoding.Subtitles return (stream, fileInfo); } - private async Task<Stream> GetSubtitleStream(SubtitleInfo fileInfo, CancellationToken cancellationToken) + internal async Task<Stream> GetSubtitleStream(SubtitleInfo fileInfo, CancellationToken cancellationToken) { - if (fileInfo.Protocol == MediaProtocol.Http) + if (fileInfo.IsExternal && MediaStream.IsTextFormat(fileInfo.Format)) { var result = await DetectCharset(fileInfo.Path, cancellationToken).ConfigureAwait(false); var detected = result.Detected; - if (detected is not null) + var stream = fileInfo.Protocol == MediaProtocol.Http + ? await _httpClientFactory.CreateClient(NamedClient.Default) + .GetStreamAsync(new Uri(fileInfo.Path), cancellationToken) + .ConfigureAwait(false) + : AsyncFile.OpenRead(fileInfo.Path); + + // Short-circuit when the file is already UTF-8/ASCII. + if (detected is null + || string.Equals(detected.EncodingName, "utf-8", StringComparison.OrdinalIgnoreCase) + || string.Equals(detected.EncodingName, "ascii", StringComparison.OrdinalIgnoreCase) + || string.Equals(detected.EncodingName, "us-ascii", StringComparison.OrdinalIgnoreCase)) { - _logger.LogDebug("charset {CharSet} detected for {Path}", detected.EncodingName, fileInfo.Path); + return stream; + } - using var stream = await _httpClientFactory.CreateClient(NamedClient.Default) - .GetStreamAsync(new Uri(fileInfo.Path), cancellationToken) - .ConfigureAwait(false); + _logger.LogDebug("charset {CharSet} detected for {Path}", detected.EncodingName, fileInfo.Path); - await using (stream.ConfigureAwait(false)) - { - using var reader = new StreamReader(stream, detected.Encoding); - var text = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false); + await using (stream.ConfigureAwait(false)) + { + using var reader = new StreamReader(stream, detected.Encoding); + var text = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false); - return new MemoryStream(Encoding.UTF8.GetBytes(text)); - } + return new MemoryStream(Encoding.UTF8.GetBytes(text)); } } diff --git a/MediaBrowser.Model/Configuration/ImageSavingConvention.cs b/MediaBrowser.Model/Configuration/ImageSavingConvention.cs index 485a4d2f80..c67f379fde 100644 --- a/MediaBrowser.Model/Configuration/ImageSavingConvention.cs +++ b/MediaBrowser.Model/Configuration/ImageSavingConvention.cs @@ -1,10 +1,18 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Model.Configuration { + /// <summary> + /// The convention used for naming saved images. + /// </summary> public enum ImageSavingConvention { + /// <summary> + /// The legacy naming convention. + /// </summary> Legacy, + + /// <summary> + /// The naming convention compatible with other media servers and metadata managers. + /// </summary> Compatible } } diff --git a/MediaBrowser.Model/Dlna/StreamBuilder.cs b/MediaBrowser.Model/Dlna/StreamBuilder.cs index 59f97d8c7c..a9ab7d6db0 100644 --- a/MediaBrowser.Model/Dlna/StreamBuilder.cs +++ b/MediaBrowser.Model/Dlna/StreamBuilder.cs @@ -576,11 +576,8 @@ namespace MediaBrowser.Model.Dlna foreach (var profile in subtitleProfiles) { if (profile.Method == SubtitleDeliveryMethod.External - && (string.Equals(profile.Format, stream.Codec, StringComparison.OrdinalIgnoreCase) - // FFmpeg cannot mux VobSub back into an .idx/.sub pair, so extracted VobSub streams are exposed as .mks. - || (string.Equals(profile.Format, "mks", StringComparison.OrdinalIgnoreCase) - && stream.IsVobSubSubtitleStream - && (!stream.IsExternal || stream.Path.EndsWith(".mks", StringComparison.OrdinalIgnoreCase))))) + && (IsVobSubMksProfile(profile, stream) + || (!IsVobSubMksDeliveryProfile(profile) && string.Equals(profile.Format, stream.Codec, StringComparison.OrdinalIgnoreCase)))) { return stream.Index; } @@ -1590,13 +1587,11 @@ namespace MediaBrowser.Model.Dlna continue; } - // FFmpeg cannot mux VobSub back into an .idx/.sub pair, so extracted VobSub streams are matched against external .mks delivery profiles. - bool isVobSubMksProfile = string.Equals(profile.Format, "mks", StringComparison.OrdinalIgnoreCase) - && subtitleStream.IsVobSubSubtitleStream - && (!subtitleStream.IsExternal || subtitleStream.Path.EndsWith(".mks", StringComparison.OrdinalIgnoreCase)); + bool isVobSubMksProfile = IsVobSubMksProfile(profile, subtitleStream); if ((profile.Method == SubtitleDeliveryMethod.External - && (isVobSubMksProfile || subtitleStream.IsTextSubtitleStream == MediaStream.IsTextFormat(profile.Format))) || + && (isVobSubMksProfile + || (!IsVobSubMksDeliveryProfile(profile) && subtitleStream.IsTextSubtitleStream == MediaStream.IsTextFormat(profile.Format)))) || (profile.Method == SubtitleDeliveryMethod.Hls && subtitleStream.IsTextSubtitleStream)) { bool requiresConversion = !isVobSubMksProfile @@ -1628,6 +1623,21 @@ namespace MediaBrowser.Model.Dlna return null; } + private static bool IsVobSubMksDeliveryProfile(SubtitleProfile profile) + { + return MediaStream.IsVobSubFormat(profile.Format) + && !string.IsNullOrWhiteSpace(profile.Container) + && ContainerHelper.ContainsContainer(profile.Container, "mks"); + } + + private static bool IsVobSubMksProfile(SubtitleProfile profile, MediaStream subtitleStream) + { + // FFmpeg cannot mux VobSub back into an .idx/.sub pair, so extracted VobSub streams are exposed as .mks. + return IsVobSubMksDeliveryProfile(profile) + && subtitleStream.IsVobSubSubtitleStream + && (!subtitleStream.IsExternal || subtitleStream.Path?.EndsWith(".mks", StringComparison.OrdinalIgnoreCase) == true); + } + private bool IsBitrateLimitExceeded(MediaSourceInfo item, long maxBitrate) { // Don't restrict bitrate if item is remote. diff --git a/MediaBrowser.Model/Dto/IHasServerId.cs b/MediaBrowser.Model/Dto/IHasServerId.cs index c754d276c5..49452d736a 100644 --- a/MediaBrowser.Model/Dto/IHasServerId.cs +++ b/MediaBrowser.Model/Dto/IHasServerId.cs @@ -1,10 +1,15 @@ #nullable disable -#pragma warning disable CS1591 namespace MediaBrowser.Model.Dto { + /// <summary> + /// Interface for DTOs that reference the id of the server they originate from. + /// </summary> public interface IHasServerId { + /// <summary> + /// Gets the server id. + /// </summary> string ServerId { get; } } } diff --git a/MediaBrowser.Model/Dto/MediaSourceType.cs b/MediaBrowser.Model/Dto/MediaSourceType.cs index 42314d5198..ca6649e64b 100644 --- a/MediaBrowser.Model/Dto/MediaSourceType.cs +++ b/MediaBrowser.Model/Dto/MediaSourceType.cs @@ -1,11 +1,23 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Model.Dto { + /// <summary> + /// The type of a media source. + /// </summary> public enum MediaSourceType { + /// <summary> + /// A default media source. + /// </summary> Default = 0, + + /// <summary> + /// A grouping of media sources. + /// </summary> Grouping = 1, + + /// <summary> + /// A placeholder media source, for example a disc that has to be inserted. + /// </summary> Placeholder = 2 } } diff --git a/MediaBrowser.Model/Dto/RatingType.cs b/MediaBrowser.Model/Dto/RatingType.cs index 033776f9c6..2c2b7b705d 100644 --- a/MediaBrowser.Model/Dto/RatingType.cs +++ b/MediaBrowser.Model/Dto/RatingType.cs @@ -1,10 +1,18 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Model.Dto { + /// <summary> + /// The type of a community rating. + /// </summary> public enum RatingType { + /// <summary> + /// The rating is a numeric score. + /// </summary> Score, + + /// <summary> + /// The rating is based on likes. + /// </summary> Likes } } diff --git a/MediaBrowser.Model/Globalization/ILocalizationManager.cs b/MediaBrowser.Model/Globalization/ILocalizationManager.cs index 7ad240abfb..0fff70c4e0 100644 --- a/MediaBrowser.Model/Globalization/ILocalizationManager.cs +++ b/MediaBrowser.Model/Globalization/ILocalizationManager.cs @@ -73,6 +73,14 @@ public interface ILocalizationManager CultureDto? FindLanguageInfo(string language); /// <summary> + /// Gets a human-readable display name for the given language code. + /// Truncates at the first semicolon or comma to avoid cluttered ISO-639-2 names. + /// </summary> + /// <param name="language">An ISO language code.</param> + /// <returns>The display name, or null if not found.</returns> + string? GetLanguageDisplayName(string language); + + /// <summary> /// Returns the language in ISO 639-2/T when the input is ISO 639-2/B. /// </summary> /// <param name="isoB">The language in ISO 639-2/B.</param> diff --git a/MediaBrowser.Model/Library/PlayAccess.cs b/MediaBrowser.Model/Library/PlayAccess.cs index a2f263ce54..22daaf7254 100644 --- a/MediaBrowser.Model/Library/PlayAccess.cs +++ b/MediaBrowser.Model/Library/PlayAccess.cs @@ -1,10 +1,18 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Model.Library { + /// <summary> + /// The play access of an item. + /// </summary> public enum PlayAccess { + /// <summary> + /// The item can be played. + /// </summary> Full = 0, + + /// <summary> + /// The item cannot be played. + /// </summary> None = 1 } } diff --git a/MediaBrowser.Model/LiveTv/DayPattern.cs b/MediaBrowser.Model/LiveTv/DayPattern.cs index 17efe39088..dab69e8974 100644 --- a/MediaBrowser.Model/LiveTv/DayPattern.cs +++ b/MediaBrowser.Model/LiveTv/DayPattern.cs @@ -1,11 +1,23 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Model.LiveTv { + /// <summary> + /// The day pattern of a recurring timer. + /// </summary> public enum DayPattern { + /// <summary> + /// Every day. + /// </summary> Daily, + + /// <summary> + /// Monday through Friday. + /// </summary> Weekdays, + + /// <summary> + /// Saturday and Sunday. + /// </summary> Weekends } } diff --git a/MediaBrowser.Model/LiveTv/LiveTvServiceStatus.cs b/MediaBrowser.Model/LiveTv/LiveTvServiceStatus.cs index 72a0e2d7bf..a3df1dc411 100644 --- a/MediaBrowser.Model/LiveTv/LiveTvServiceStatus.cs +++ b/MediaBrowser.Model/LiveTv/LiveTvServiceStatus.cs @@ -1,10 +1,18 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Model.LiveTv { + /// <summary> + /// The status of a live TV service. + /// </summary> public enum LiveTvServiceStatus { + /// <summary> + /// The service is available. + /// </summary> Ok = 0, + + /// <summary> + /// The service is unavailable. + /// </summary> Unavailable = 1 } } diff --git a/MediaBrowser.Model/MediaInfo/TransportStreamTimestamp.cs b/MediaBrowser.Model/MediaInfo/TransportStreamTimestamp.cs index b7ee5747ab..1988dd8078 100644 --- a/MediaBrowser.Model/MediaInfo/TransportStreamTimestamp.cs +++ b/MediaBrowser.Model/MediaInfo/TransportStreamTimestamp.cs @@ -1,11 +1,23 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Model.MediaInfo { + /// <summary> + /// The type of timestamps used in a transport stream. + /// </summary> public enum TransportStreamTimestamp { + /// <summary> + /// The stream contains no timestamps. + /// </summary> None, + + /// <summary> + /// The stream contains zero-value timestamps. + /// </summary> Zero, + + /// <summary> + /// The stream contains valid timestamps. + /// </summary> Valid } } diff --git a/MediaBrowser.Providers/Lyric/LyricManager.cs b/MediaBrowser.Providers/Lyric/LyricManager.cs index 913a104a0d..af31e373ef 100644 --- a/MediaBrowser.Providers/Lyric/LyricManager.cs +++ b/MediaBrowser.Providers/Lyric/LyricManager.cs @@ -398,7 +398,7 @@ public class LyricManager : ILyricManager { var mediaFolderPath = Path.GetFullPath(Path.Combine(audio.ContainingFolderPath, saveFileName)); // TODO: Add some error handling to the API user: return BadRequest("Could not save lyric, bad path."); - if (mediaFolderPath.StartsWith(audio.ContainingFolderPath, StringComparison.Ordinal)) + if (PathHelper.IsContainedIn(audio.ContainingFolderPath, mediaFolderPath)) { savePaths.Add(mediaFolderPath); } @@ -407,7 +407,7 @@ public class LyricManager : ILyricManager var internalPath = Path.GetFullPath(Path.Combine(audio.GetInternalMetadataPath(), saveFileName)); // TODO: Add some error to the user: return BadRequest("Could not save lyric, bad path."); - if (internalPath.StartsWith(audio.GetInternalMetadataPath(), StringComparison.Ordinal)) + if (PathHelper.IsContainedIn(audio.GetInternalMetadataPath(), internalPath)) { savePaths.Add(internalPath); } diff --git a/src/Jellyfin.Extensions/PathHelper.cs b/src/Jellyfin.Extensions/PathHelper.cs new file mode 100644 index 0000000000..ab74a7749d --- /dev/null +++ b/src/Jellyfin.Extensions/PathHelper.cs @@ -0,0 +1,77 @@ +using System; +using System.IO; + +namespace Jellyfin.Extensions; + +/// <summary> +/// Helpers for safely composing filesystem paths from untrusted input. +/// </summary> +/// <remarks> +/// <see cref="Path.Combine(string, string)"/> has two issues that matter in +/// any code that joins a trusted directory with an externally-supplied name: +/// it neither normalises <c>..</c> nor rejects a rooted second argument +/// (a rooted second arg silently discards the first). Use the helpers below +/// any time the name comes from media metadata, request input, archive +/// entries, or any other channel that can be influenced by a third party. +/// </remarks> +public static class PathHelper +{ + /// <summary> + /// Reduces a possibly-untrusted file name to a safe leaf-only name with no + /// directory components. + /// </summary> + /// <param name="fileName">The candidate file name.</param> + /// <returns> + /// The leaf component of <paramref name="fileName"/>, or <c>null</c> if + /// the input has no usable leaf (empty, <c>.</c>, or <c>..</c>). + /// </returns> + public static string? GetSafeLeafFileName(string? fileName) + { + if (string.IsNullOrEmpty(fileName)) + { + return null; + } + + var leaf = Path.GetFileName(fileName); + if (string.IsNullOrEmpty(leaf) || string.Equals(leaf, ".", StringComparison.Ordinal) || string.Equals(leaf, "..", StringComparison.Ordinal)) + { + return null; + } + + return leaf; + } + + /// <summary> + /// Returns whether <paramref name="candidate"/> resolves to a path that + /// equals or is contained inside <paramref name="root"/>. + /// </summary> + /// <param name="root">The directory the candidate must remain inside.</param> + /// <param name="candidate">The candidate absolute or relative path.</param> + /// <returns><c>true</c> if the candidate is inside or equal to root; otherwise <c>false</c>.</returns> + /// <remarks> + /// Both arguments are resolved via <see cref="Path.GetFullPath(string)"/> + /// so <c>..</c> segments are collapsed before the comparison. The root is + /// compared with a trailing directory separator to prevent prefix + /// collisions (e.g. <c>/var/data</c> must not be accepted as a parent of + /// <c>/var/dataset</c>). + /// </remarks> + public static bool IsContainedIn(string root, string candidate) + { + ArgumentException.ThrowIfNullOrEmpty(root); + ArgumentException.ThrowIfNullOrEmpty(candidate); + + var fullRoot = Path.GetFullPath(root); + var fullCandidate = Path.GetFullPath(candidate); + + if (string.Equals(fullCandidate, fullRoot, StringComparison.Ordinal)) + { + return true; + } + + var rootWithSep = fullRoot.EndsWith(Path.DirectorySeparatorChar) + ? fullRoot + : fullRoot + Path.DirectorySeparatorChar; + + return fullCandidate.StartsWith(rootWithSep, StringComparison.Ordinal); + } +} diff --git a/tests/Jellyfin.Api.Tests/Controllers/ItemUpdateControllerTests.cs b/tests/Jellyfin.Api.Tests/Controllers/ItemUpdateControllerTests.cs new file mode 100644 index 0000000000..1a91efe4f2 --- /dev/null +++ b/tests/Jellyfin.Api.Tests/Controllers/ItemUpdateControllerTests.cs @@ -0,0 +1,77 @@ +using System; +using System.Threading.Tasks; +using Jellyfin.Api.Controllers; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Globalization; +using MediaBrowser.Model.IO; +using Moq; +using Xunit; + +namespace Jellyfin.Api.Tests.Controllers; + +public class ItemUpdateControllerTests +{ + private readonly ItemUpdateController _subject; + + public ItemUpdateControllerTests() + { + _subject = new ItemUpdateController( + Mock.Of<IFileSystem>(), + Mock.Of<ILibraryManager>(), + Mock.Of<IProviderManager>(), + Mock.Of<ILocalizationManager>(), + Mock.Of<IServerConfigurationManager>()); + } + + [Fact] + public async Task UpdateItem_WhenOnlyTagsFieldSupplied_DoesNotThrowAndAppliesTags() + { + // Regression test for https://github.com/jellyfin/jellyfin/issues/17366 + // A partial update payload that only sets "Tags" leaves every other + // BaseItemDto collection property null (they have no default + // initializer). Genres and ProviderIds used to be fed straight into + // Distinct()/ToList() without a null check, so this call used to throw + // ArgumentNullException before the fix below was applied. + var movie = new Movie(); + var request = new BaseItemDto + { + Tags = new[] { "new-tag-1", "new-tag-2" } + }; + + await InvokeUpdateItem(request, movie); + + Assert.Equal(new[] { "new-tag-1", "new-tag-2" }, movie.Tags); + Assert.Empty(movie.Genres); + Assert.Empty(movie.ProviderIds); + } + + [Fact] + public async Task UpdateItem_WhenGenresAndProviderIdsOmitted_LeavesExistingValuesUnchanged() + { + var movie = new Movie + { + Genres = new[] { "Action" } + }; + movie.ProviderIds["Imdb"] = "tt1234567"; + + var request = new BaseItemDto + { + Tags = Array.Empty<string>() + }; + + await InvokeUpdateItem(request, movie); + + Assert.Equal(new[] { "Action" }, movie.Genres); + Assert.Equal("tt1234567", movie.ProviderIds["Imdb"]); + } + + private Task InvokeUpdateItem(BaseItemDto request, BaseItem item) + { + return _subject.UpdateItem(request, item); + } +} diff --git a/tests/Jellyfin.Api.Tests/Controllers/StartupControllerTests.cs b/tests/Jellyfin.Api.Tests/Controllers/StartupControllerTests.cs new file mode 100644 index 0000000000..bad11a9257 --- /dev/null +++ b/tests/Jellyfin.Api.Tests/Controllers/StartupControllerTests.cs @@ -0,0 +1,70 @@ +using System.Threading.Tasks; +using Jellyfin.Api.Controllers; +using Jellyfin.Api.Models.StartupDtos; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Server.Implementations.Users; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Library; +using Microsoft.AspNetCore.Mvc; +using Moq; +using Xunit; + +namespace Jellyfin.Api.Tests.Controllers; + +public class StartupControllerTests +{ + private readonly StartupController _subject; + private readonly Mock<IUserManager> _mockUserManager; + private readonly Mock<IServerConfigurationManager> _mockConfig; + + public StartupControllerTests() + { + _mockUserManager = new Mock<IUserManager>(); + _mockConfig = new Mock<IServerConfigurationManager>(); + _subject = new StartupController(_mockConfig.Object, _mockUserManager.Object); + } + + private static User CreateUser() + => new User( + "jellyfin", + typeof(DefaultAuthenticationProvider).FullName!, + typeof(DefaultPasswordResetProvider).FullName!); + + [Fact] + public async Task UpdateStartupUser_WhenNoUserExists_ReturnsNotFound() + { + _mockUserManager.Setup(m => m.GetFirstUser()).Returns((User?)null); + + var result = await _subject.UpdateStartupUser(new StartupUserDto { Name = "admin", Password = "pw" }); + + Assert.IsType<NotFoundResult>(result); + } + + [Fact] + public async Task UpdateStartupUser_WhenPasswordAlreadyConfigured_ReturnsForbidden() + { + var user = CreateUser(); + user.Password = "already-set-hash"; + _mockUserManager.Setup(m => m.GetFirstUser()).Returns(user); + + var result = await _subject.UpdateStartupUser(new StartupUserDto { Name = "attacker", Password = "new-pw" }); + + // The startup wizard must never overwrite the password of an already-provisioned + // account, even if IsStartupWizardCompleted has been cleared. + Assert.IsType<ForbidResult>(result); + _mockUserManager.Verify(m => m.ChangePassword(It.IsAny<System.Guid>(), It.IsAny<string>()), Times.Never); + } + + [Fact] + public async Task UpdateStartupUser_WhenNoPasswordYet_SetsPassword() + { + var user = CreateUser(); + Assert.True(string.IsNullOrEmpty(user.Password)); + _mockUserManager.Setup(m => m.GetFirstUser()).Returns(user); + + var result = await _subject.UpdateStartupUser(new StartupUserDto { Name = "jellyfin", Password = "first-pw" }); + + Assert.IsType<NoContentResult>(result); + _mockUserManager.Verify(m => m.ChangePassword(user.Id, "first-pw"), Times.Once); + } +} diff --git a/tests/Jellyfin.Controller.Tests/ClientEventLoggerTests.cs b/tests/Jellyfin.Controller.Tests/ClientEventLoggerTests.cs new file mode 100644 index 0000000000..5132e529dd --- /dev/null +++ b/tests/Jellyfin.Controller.Tests/ClientEventLoggerTests.cs @@ -0,0 +1,44 @@ +using System; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using MediaBrowser.Controller; +using MediaBrowser.Controller.ClientEvent; +using Moq; +using Xunit; + +namespace Jellyfin.Controller.Tests +{ + public class ClientEventLoggerTests + { + [Theory] + [InlineData("../../../../etc/passwd", "1.0")] + [InlineData("..\\..\\windows\\system32", "1.0")] + [InlineData("normal-client", "../../../etc/passwd")] + [InlineData("/absolute/path", "1.0")] + public async Task WriteDocumentAsync_TraversalInput_StaysInsideLogDirectory(string clientName, string clientVersion) + { + var logDir = Path.Combine(Path.GetTempPath(), "jellyfin-clientlog-test-" + Path.GetRandomFileName()); + Directory.CreateDirectory(logDir); + try + { + var paths = new Mock<IServerApplicationPaths>(); + paths.Setup(p => p.LogDirectoryPath).Returns(logDir); + + var logger = new ClientEventLogger(paths.Object); + using var contents = new MemoryStream(Encoding.UTF8.GetBytes("payload")); + + var fileName = await logger.WriteDocumentAsync(clientName, clientVersion, contents); + + var resolved = Path.GetFullPath(Path.Combine(logDir, fileName)); + var rootWithSep = Path.GetFullPath(logDir) + Path.DirectorySeparatorChar; + Assert.StartsWith(rootWithSep, resolved, StringComparison.Ordinal); + Assert.True(File.Exists(resolved)); + } + finally + { + Directory.Delete(logDir, recursive: true); + } + } + } +} diff --git a/tests/Jellyfin.Extensions.Tests/PathHelperTests.cs b/tests/Jellyfin.Extensions.Tests/PathHelperTests.cs new file mode 100644 index 0000000000..71fd853ba2 --- /dev/null +++ b/tests/Jellyfin.Extensions.Tests/PathHelperTests.cs @@ -0,0 +1,60 @@ +using System.IO; +using Jellyfin.Extensions; +using Xunit; + +namespace Jellyfin.Extensions.Tests +{ + public static class PathHelperTests + { + [Theory] + [InlineData("file.txt", "file.txt")] + [InlineData("sub/file.txt", "file.txt")] + [InlineData("../../etc/passwd", "passwd")] + public static void GetSafeLeafFileName_ReducesToLeaf(string input, string expected) + { + Assert.Equal(expected, PathHelper.GetSafeLeafFileName(input)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(".")] + [InlineData("..")] + public static void GetSafeLeafFileName_RejectsUnusableLeaf(string? input) + { + Assert.Null(PathHelper.GetSafeLeafFileName(input)); + } + + [Fact] + public static void IsContainedIn_ChildPath_ReturnsTrue() + { + var root = Path.Combine(Path.GetTempPath(), "root"); + var child = Path.Combine(root, "sub", "file.txt"); + Assert.True(PathHelper.IsContainedIn(root, child)); + } + + [Fact] + public static void IsContainedIn_RootItself_ReturnsTrue() + { + var root = Path.Combine(Path.GetTempPath(), "root"); + Assert.True(PathHelper.IsContainedIn(root, root)); + } + + [Fact] + public static void IsContainedIn_TraversalEscape_ReturnsFalse() + { + var root = Path.Combine(Path.GetTempPath(), "root"); + var escape = Path.Combine(root, "..", "..", "etc", "passwd"); + Assert.False(PathHelper.IsContainedIn(root, escape)); + } + + [Fact] + public static void IsContainedIn_SiblingPrefixCollision_ReturnsFalse() + { + // "/var/data" must not be accepted as a parent of "/var/dataset". + var root = Path.Combine(Path.GetTempPath(), "data"); + var sibling = Path.Combine(Path.GetTempPath(), "dataset", "file.txt"); + Assert.False(PathHelper.IsContainedIn(root, sibling)); + } + } +} diff --git a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SubtitleEncoderTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SubtitleEncoderTests.cs index 2d0fa29c9a..48850b2f67 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SubtitleEncoderTests.cs +++ b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SubtitleEncoderTests.cs @@ -21,81 +21,110 @@ namespace Jellyfin.MediaEncoding.Subtitles.Tests private const int StreamCount = 8; private const int CueCount = 500; - public static TheoryData<MediaSourceInfo, MediaStream, SubtitleEncoder.SubtitleInfo> GetReadableFile_Valid_TestData() + // A Greek line that requires a non-UTF-8 legacy encoding to reproduce the bug. The accented + // characters (ά, έ, ή, ί, ό, ύ, ώ) share the same code points in windows-1253 and iso-8859-7, + // so a Greek-vs-Greek charset misdetection still round-trips correctly. + private const string GreekText = "Καλημέρα κόσμε, αυτό είναι ένας υπότιτλος."; + + static SubtitleEncoderTests() { - var data = new TheoryData<MediaSourceInfo, MediaStream, SubtitleEncoder.SubtitleInfo>(); + // Mirrors Jellyfin.Server startup so legacy code pages (e.g. Greek windows-1253) are available. + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + } - data.Add( - new MediaSourceInfo() - { - Protocol = MediaProtocol.File - }, - new MediaStream() - { - Path = "/media/sub.ass", - IsExternal = true - }, - new SubtitleEncoder.SubtitleInfo() - { - Path = "/media/sub.ass", - Protocol = MediaProtocol.File, - Format = "ass", - IsExternal = true - }); + // Enough Greek text to give the charset detector a strong, unambiguous signal. + private static string BuildGreekSrt() + { + var builder = new StringBuilder(); + for (var i = 1; i <= 8; i++) + { + builder.Append(i.ToString(CultureInfo.InvariantCulture)).Append('\n'); + builder.Append("00:00:0").Append(i.ToString(CultureInfo.InvariantCulture)) + .Append(",000 --> 00:00:0").Append((i + 1).ToString(CultureInfo.InvariantCulture)).Append(",000\n"); + builder.Append(GreekText).Append('\n'); + builder.Append("Η γρήγορη καφέ αλεπού πηδάει πάνω από το τεμπέλικο σκυλί.\n\n"); + } - data.Add( - new MediaSourceInfo() - { - Protocol = MediaProtocol.File - }, - new MediaStream() - { - Path = "/media/sub.ssa", - IsExternal = true - }, - new SubtitleEncoder.SubtitleInfo() - { - Path = "/media/sub.ssa", - Protocol = MediaProtocol.File, - Format = "ssa", - IsExternal = true - }); + return builder.ToString(); + } - data.Add( - new MediaSourceInfo() + public static TheoryData<MediaSourceInfo, MediaStream, SubtitleEncoder.SubtitleInfo> GetReadableFile_Valid_TestData() + { + var data = new TheoryData<MediaSourceInfo, MediaStream, SubtitleEncoder.SubtitleInfo> + { { - Protocol = MediaProtocol.File + new MediaSourceInfo() + { + Protocol = MediaProtocol.File + }, + new MediaStream() + { + Path = "/media/sub.ass", + IsExternal = true + }, + new SubtitleEncoder.SubtitleInfo() + { + Path = "/media/sub.ass", + Protocol = MediaProtocol.File, + Format = "ass", + IsExternal = true + } }, - new MediaStream() { - Path = "/media/sub.srt", - IsExternal = true + new MediaSourceInfo() + { + Protocol = MediaProtocol.File + }, + new MediaStream() + { + Path = "/media/sub.ssa", + IsExternal = true + }, + new SubtitleEncoder.SubtitleInfo() + { + Path = "/media/sub.ssa", + Protocol = MediaProtocol.File, + Format = "ssa", + IsExternal = true + } }, - new SubtitleEncoder.SubtitleInfo() { - Path = "/media/sub.srt", - Protocol = MediaProtocol.File, - Format = "srt", - IsExternal = true - }); - - data.Add( - new MediaSourceInfo() - { - Protocol = MediaProtocol.Http + new MediaSourceInfo() + { + Protocol = MediaProtocol.File + }, + new MediaStream() + { + Path = "/media/sub.srt", + IsExternal = true + }, + new SubtitleEncoder.SubtitleInfo() + { + Path = "/media/sub.srt", + Protocol = MediaProtocol.File, + Format = "srt", + IsExternal = true + } }, - new MediaStream() { - Path = "/media/sub.ass", - IsExternal = true - }, - new SubtitleEncoder.SubtitleInfo() - { - Path = "/media/sub.ass", - Protocol = MediaProtocol.File, - Format = "ass", - IsExternal = true - }); + new MediaSourceInfo() + { + Protocol = MediaProtocol.Http + }, + new MediaStream() + { + Path = "/media/sub.ass", + IsExternal = true + }, + new SubtitleEncoder.SubtitleInfo() + { + Path = "/media/sub.ass", + Protocol = MediaProtocol.File, + Format = "ass", + IsExternal = true + } + } + }; return data; } @@ -113,6 +142,55 @@ namespace Jellyfin.MediaEncoding.Subtitles.Tests Assert.Equal(subtitleInfo.IsExternal, result.IsExternal); } + public static TheoryData<Encoding> GetSubtitleStream_NonUtf8LocalFile_TestData() + { + return + [ + // Greek legacy encodings – the exact scenario reported in issue #17267. + Encoding.GetEncoding("windows-1253"), + Encoding.GetEncoding("iso-8859-7"), + // Wide encoding with a BOM. + new UnicodeEncoding(bigEndian: false, byteOrderMark: true), + ]; + } + + [Theory] + [MemberData(nameof(GetSubtitleStream_NonUtf8LocalFile_TestData))] + public async Task GetSubtitleStream_NonUtf8LocalFile_ConvertedToUtf8(Encoding sourceEncoding) + { + var cancellationToken = TestContext.Current.CancellationToken; + var srt = BuildGreekSrt(); + var path = Path.GetTempFileName(); + try + { + await File.WriteAllTextAsync(path, srt, sourceEncoding, cancellationToken); + + var fixture = new Fixture().Customize(new AutoMoqCustomization { ConfigureMembers = true }); + var subtitleEncoder = fixture.Create<SubtitleEncoder>(); + + var fileInfo = new SubtitleEncoder.SubtitleInfo + { + Path = path, + Protocol = MediaProtocol.File, + Format = "srt", + IsExternal = true + }; + + using var stream = await subtitleEncoder.GetSubtitleStream(fileInfo, cancellationToken); + using var reader = new StreamReader(stream, new UTF8Encoding(false)); + var text = await reader.ReadToEndAsync(cancellationToken); + + // The Greek text must survive round-trip and contain no replacement characters. + Assert.Contains(GreekText, text, StringComparison.Ordinal); + Assert.DoesNotContain('�', text); + Assert.DoesNotContain('?', text); + } + finally + { + File.Delete(path); + } + } + [Fact] public void ConvertSubtitles_SequentialCalls_AreDeterministic() { @@ -130,6 +208,44 @@ namespace Jellyfin.MediaEncoding.Subtitles.Tests } [Fact] + public async Task GetSubtitleStream_Utf8LocalFile_PreservesContent() + { + var cancellationToken = TestContext.Current.CancellationToken; + var srt = BuildGreekSrt(); + var path = Path.GetTempFileName(); + try + { + await File.WriteAllTextAsync(path, srt, new UTF8Encoding(false), cancellationToken); + + var fixture = new Fixture().Customize(new AutoMoqCustomization { ConfigureMembers = true }); + var subtitleEncoder = fixture.Create<SubtitleEncoder>(); + + var fileInfo = new SubtitleEncoder.SubtitleInfo + { + Path = path, + Protocol = MediaProtocol.File, + Format = "srt", + IsExternal = true + }; + + using var stream = await subtitleEncoder.GetSubtitleStream(fileInfo, cancellationToken); + + // An already-UTF-8 file must be short-circuited and served directly from disk, + // not read into memory and re-encoded (which would produce a MemoryStream). + Assert.IsNotType<MemoryStream>(stream); + + using var reader = new StreamReader(stream, new UTF8Encoding(false)); + var text = await reader.ReadToEndAsync(cancellationToken); + + Assert.Contains(GreekText, text, StringComparison.Ordinal); + } + finally + { + File.Delete(path); + } + } + + [Fact] public async Task ConvertSubtitles_ConcurrentCalls_MatchSequentialBaseline() { const int Iterations = 10; diff --git a/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs index d94d56bc20..5ba061296a 100644 --- a/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs +++ b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs @@ -677,6 +677,48 @@ namespace Jellyfin.Model.Tests } [Theory] + [InlineData(false, null, true, SubtitleDeliveryMethod.External)] + [InlineData(false, null, false, SubtitleDeliveryMethod.Encode)] + [InlineData(true, "/media/sub.mks", true, SubtitleDeliveryMethod.External)] + [InlineData(true, "/media/sub.idx", true, SubtitleDeliveryMethod.Encode)] + [InlineData(true, "/media/sub.sub", true, SubtitleDeliveryMethod.Encode)] + public void GetSubtitleProfile_MatchesVobSubMksProfileOnlyWhenDeliveredAsMks( + bool isExternal, + string? path, + bool enableSubtitleExtraction, + SubtitleDeliveryMethod expectedMethod) + { + var mediaSource = new MediaSourceInfo(); + var subtitleStream = new MediaStream + { + Type = MediaStreamType.Subtitle, + Index = 0, + IsExternal = isExternal, + Path = path, + Codec = "vobsub" + }; + + var subtitleProfiles = new[] + { + new SubtitleProfile { Format = "vobsub", Container = "mks", Method = SubtitleDeliveryMethod.External } + }; + + var transcoderSupport = new Mock<ITranscoderSupport>(); + transcoderSupport.Setup(t => t.CanExtractSubtitles(It.IsAny<string>())).Returns(enableSubtitleExtraction); + + var result = StreamBuilder.GetSubtitleProfile( + mediaSource, + subtitleStream, + subtitleProfiles, + PlayMethod.Transcode, + transcoderSupport.Object, + null, + null); + + Assert.Equal(expectedMethod, result.Method); + } + + [Theory] // External text subs embedded into MKV when transcoding (#16403) [InlineData("srt", true, PlayMethod.Transcode, "mkv", MediaStreamProtocol.http, SubtitleDeliveryMethod.Embed)] [InlineData("ass", true, PlayMethod.Transcode, "mkv", MediaStreamProtocol.http, SubtitleDeliveryMethod.Embed)] diff --git a/tests/Jellyfin.Server.Implementations.Tests/FullSystemBackup/BackupServiceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/FullSystemBackup/BackupServiceTests.cs new file mode 100644 index 0000000000..66c392a6ad --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/FullSystemBackup/BackupServiceTests.cs @@ -0,0 +1,182 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Locking; +using Jellyfin.Database.Providers.Sqlite; +using Jellyfin.Server.Implementations.FullSystemBackup; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.SystemBackupService; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; +using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind; + +namespace Jellyfin.Server.Implementations.Tests.FullSystemBackup; + +/// <summary> +/// Tests for <see cref="BackupService"/>, in particular that a single row of corrupt +/// <see cref="KeyframeData"/> (e.g. malformed <c>KeyframeTicks</c> JSON) does not abort +/// an otherwise healthy backup. See https://github.com/jellyfin/jellyfin/issues/17216. +/// </summary> +public sealed class BackupServiceTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly DbContextOptions<JellyfinDbContext> _dbOptions; + private readonly string _testRoot; + private readonly string _backupPath; + private readonly string _configurationDirectoryPath; + + public BackupServiceTests() + { + _connection = new SqliteConnection("Data Source=:memory:"); + _connection.Open(); + + _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>() + .UseSqlite(_connection) + .Options; + + using (var ctx = CreateDbContext()) + { + ctx.Database.EnsureCreated(); + } + + // Use the test assembly's own output directory instead of Path.GetTempPath(). On GitHub-hosted + // windows-latest runners, the system temp directory lives on the constrained C: drive, which can have + // less than the 5GiB BackupService requires free, causing spurious failures. AppContext.BaseDirectory + // is under the repo checkout (the much larger D: drive on Windows runners) on all platforms. + _testRoot = Path.Combine(AppContext.BaseDirectory, "jellyfin-backup-service-tests-" + Guid.NewGuid().ToString("N")); + _backupPath = Path.Combine(_testRoot, "Backup"); + _configurationDirectoryPath = Path.Combine(_testRoot, "Config"); + Directory.CreateDirectory(_backupPath); + Directory.CreateDirectory(_configurationDirectoryPath); + } + + public void Dispose() + { + _connection.Dispose(); + + if (Directory.Exists(_testRoot)) + { + Directory.Delete(_testRoot, true); + } + } + + [Fact] + public async Task CreateBackupAsync_WithCorruptKeyframeDataRow_SkipsRowAndCompletesBackup() + { + var cancellationToken = TestContext.Current.CancellationToken; + var validItemId = Guid.Parse("11111111-1111-1111-1111-111111111111"); + var corruptItemId = Guid.Parse("22222222-2222-2222-2222-222222222222"); + + await using (var ctx = CreateDbContext()) + { + // A healthy item + keyframe row, written the normal way. + ctx.BaseItems.Add(CreateMovieEntity(validItemId, "Good Movie")); + ctx.BaseItems.Add(CreateMovieEntity(corruptItemId, "Corrupt Movie")); + await ctx.SaveChangesAsync(cancellationToken).ConfigureAwait(true); + + ctx.KeyframeData.Add(new KeyframeData + { + ItemId = validItemId, + TotalDuration = 60_000, + KeyframeTicks = [0, 1000, 2000] + }); + await ctx.SaveChangesAsync(cancellationToken).ConfigureAwait(true); + + // Simulate a corrupted database row: truncated JSON array for KeyframeTicks, + // written directly via SQL to bypass EF's normal (well-formed) write path. + await ctx.Database.ExecuteSqlInterpolatedAsync( + $"INSERT INTO KeyframeData (ItemId, TotalDuration, KeyframeTicks) VALUES ({corruptItemId.ToString()}, {5000L}, {"[1,2,3"})", + cancellationToken).ConfigureAwait(true); + } + + var backupService = CreateBackupService(); + + var manifest = await backupService.CreateBackupAsync(new BackupOptionsDto()).ConfigureAwait(true); + + Assert.True(File.Exists(manifest.Path)); + + using var archive = await ZipFile.OpenReadAsync(manifest.Path, cancellationToken).ConfigureAwait(true); + var keyframeEntry = archive.GetEntry("Database/KeyframeData.json"); + Assert.NotNull(keyframeEntry); + + await using var entryStream = await keyframeEntry!.OpenAsync(cancellationToken).ConfigureAwait(true); + using var document = await JsonDocument.ParseAsync(entryStream, cancellationToken: cancellationToken).ConfigureAwait(true); + + var rows = document.RootElement.EnumerateArray().ToList(); + + // The corrupt row must be skipped, but the valid row must still make it into the backup. + var singleRow = Assert.Single(rows); + Assert.Equal(validItemId, singleRow.GetProperty("ItemId").GetGuid()); + } + + private BackupService CreateBackupService() + { + var factory = new Mock<IDbContextFactory<JellyfinDbContext>>(); + factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext); + factory.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>())).ReturnsAsync(CreateDbContext); + + var applicationHost = new Mock<IServerApplicationHost>(); + applicationHost.Setup(a => a.ApplicationVersion).Returns(new Version(10, 11, 0)); + + var applicationPaths = new Mock<IServerApplicationPaths>(); + applicationPaths.Setup(a => a.BackupPath).Returns(_backupPath); + applicationPaths.Setup(a => a.ConfigurationDirectoryPath).Returns(_configurationDirectoryPath); + applicationPaths.Setup(a => a.DataPath).Returns(Path.Combine(_testRoot, "Data")); + applicationPaths.Setup(a => a.RootFolderPath).Returns(Path.Combine(_testRoot, "Root")); + applicationPaths.Setup(a => a.InternalMetadataPath).Returns(Path.Combine(_testRoot, "Metadata")); + applicationPaths.Setup(a => a.DefaultInternalMetadataPath).Returns(Path.Combine(_testRoot, "MetadataDefault")); + + var jellyfinDatabaseProvider = new Mock<IJellyfinDatabaseProvider>(); + jellyfinDatabaseProvider.Setup(p => p.RunScheduledOptimisation(It.IsAny<CancellationToken>())).Returns(Task.CompletedTask); + jellyfinDatabaseProvider.Setup(p => p.PurgeDatabase(It.IsAny<JellyfinDbContext>(), It.IsAny<System.Collections.Generic.IEnumerable<string>>())).Returns(Task.CompletedTask); + + var applicationLifetime = new Mock<IHostApplicationLifetime>(); + + var libraryManager = new Mock<ILibraryManager>(); + libraryManager.Setup(l => l.IsScanRunning).Returns(false); + + return new BackupService( + NullLogger<BackupService>.Instance, + factory.Object, + applicationHost.Object, + applicationPaths.Object, + jellyfinDatabaseProvider.Object, + applicationLifetime.Object, + libraryManager.Object); + } + + private static BaseItemEntity CreateMovieEntity(Guid id, string name) + { + return new BaseItemEntity + { + Id = id, + Type = "Movie", + Name = name, + PresentationUniqueKey = id.ToString("N"), + MediaType = "Video", + IsMovie = true, + IsFolder = false, + IsVirtualItem = false + }; + } + + private JellyfinDbContext CreateDbContext() + { + return new JellyfinDbContext( + _dbOptions, + NullLogger<JellyfinDbContext>.Instance, + new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance), + new NoLockBehavior(NullLogger<NoLockBehavior>.Instance)); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs index bdb726f06d..2ed880ed9c 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs @@ -119,6 +119,40 @@ namespace Jellyfin.Server.Implementations.Tests.Localization Assert.Equal(code, culture.ThreeLetterISOLanguageName); } + [Theory] + [InlineData("ell", "Greek")] // Comma truncation + [InlineData("nld", "Dutch")] // Semicolon truncation + [InlineData("ron", "Romanian")] // Semicolon truncation, multiple + [InlineData("eng", "English")] // No truncation + [InlineData("zh-CN", "Chinese (Simplified)")] // No truncation, with parentheses + public async Task GetLanguageDisplayName_DelimitedName_ReturnsTruncatedName(string language, string expected) + { + var localizationManager = Setup(new ServerConfiguration + { + UICulture = "en-US" + }); + await localizationManager.LoadAll(); + + var result = localizationManager.GetLanguageDisplayName(language); + Assert.Equal(expected, result); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("xyz")] + public async Task GetLanguageDisplayName_InvalidInput_ReturnsNull(string? language) + { + var localizationManager = Setup(new ServerConfiguration + { + UICulture = "en-US" + }); + await localizationManager.LoadAll(); + + var result = localizationManager.GetLanguageDisplayName(language!); + Assert.Null(result); + } + [Fact] public async Task GetParentalRatings_Default_Success() { diff --git a/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerTests.cs index 4cea53bd3d..2bf1d1d05b 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerTests.cs @@ -27,6 +27,8 @@ namespace Jellyfin.Server.Implementations.Tests.Users [InlineData(" thishasaspaceatthestart")] [InlineData(" thishasaspaceatbothends ")] [InlineData(" this has a space at both ends and inbetween ")] + [InlineData(".")] + [InlineData("..")] public void ThrowIfInvalidUsername_WhenInvalidUsername_ThrowsArgumentException(string username) { Assert.Throws<ArgumentException>(() => UserManager.ThrowIfInvalidUsername(username)); |
