mirror of
https://github.com/jellyfin/jellyfin.git
synced 2026-06-28 10:30:57 +01:00
Merge branch 'master' into clean-orphaned-people
This commit is contained in:
@@ -93,6 +93,9 @@ using MediaBrowser.Model.Net;
|
||||
using MediaBrowser.Model.Serialization;
|
||||
using MediaBrowser.Model.System;
|
||||
using MediaBrowser.Model.Tasks;
|
||||
using MediaBrowser.Providers.Books;
|
||||
using MediaBrowser.Providers.Books.ComicBookInfo;
|
||||
using MediaBrowser.Providers.Books.ComicInfo;
|
||||
using MediaBrowser.Providers.Lyric;
|
||||
using MediaBrowser.Providers.Manager;
|
||||
using MediaBrowser.Providers.Plugins.ListenBrainz;
|
||||
@@ -496,6 +499,14 @@ namespace Emby.Server.Implementations
|
||||
serviceCollection.AddSingleton<ListenBrainzLabsClient>();
|
||||
serviceCollection.AddSingleton<ListenBrainzSimilarArtistProvider>();
|
||||
|
||||
// register the generic local metadata provider for comic files
|
||||
serviceCollection.AddSingleton<ComicProvider>();
|
||||
|
||||
// register the actual implementations of the local metadata provider for comic files
|
||||
serviceCollection.AddSingleton<IComicProvider, ComicBookInfoProvider>();
|
||||
serviceCollection.AddSingleton<IComicProvider, ExternalComicInfoProvider>();
|
||||
serviceCollection.AddSingleton<IComicProvider, InternalComicInfoProvider>();
|
||||
|
||||
serviceCollection.AddSingleton(NetManager);
|
||||
|
||||
serviceCollection.AddSingleton<ITaskManager, TaskManager>();
|
||||
|
||||
@@ -57,6 +57,11 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
|
||||
return null;
|
||||
}
|
||||
|
||||
if (args.Parent is not null && args.Parent.IsRoot)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var seriesInfo = Naming.TV.SeriesResolver.Resolve(_namingOptions, args.Path);
|
||||
|
||||
var collectionType = args.GetCollectionType();
|
||||
|
||||
19
Emby.Server.Implementations/Localization/Core/az.json
Normal file
19
Emby.Server.Implementations/Localization/Core/az.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"Books": "Kitablar",
|
||||
"HomeVideos": "Ev Videoları",
|
||||
"Latest": "Ən son",
|
||||
"MixedContent": "Qarışıq məzmun",
|
||||
"Movies": "Filmlər",
|
||||
"Music": "Musiqi",
|
||||
"MusicVideos": "Musiqi Videoları",
|
||||
"NameSeasonUnknown": "Mövsüm Naməlum",
|
||||
"NewVersionIsAvailable": "Jellyfin Serverin yeni versiyası yükləmək üçün əlçatandır.",
|
||||
"NotificationOptionApplicationUpdateAvailable": "Tətbiq yeniləməsi mövcuddur",
|
||||
"NotificationOptionApplicationUpdateInstalled": "Tətbiq yeniləməsi quraşdırılıb",
|
||||
"NotificationOptionAudioPlayback": "Audio oxutma başladı",
|
||||
"NotificationOptionAudioPlaybackStopped": "Audio oxutma dayandırıldı",
|
||||
"NotificationOptionCameraImageUploaded": "Kamera şəkli yükləndi",
|
||||
"NotificationOptionInstallationFailed": "Quraşdırma uğursuzluğu",
|
||||
"NotificationOptionNewLibraryContent": "Yeni məzmun əlavə edildi",
|
||||
"NotificationOptionPluginError": "Plugin uğursuzluğu"
|
||||
}
|
||||
@@ -106,5 +106,6 @@
|
||||
"TaskRefreshTrickplayImages": "Xerar miniaturas de previsualización",
|
||||
"TaskAudioNormalizationDescription": "Escanea ficheiros á procura de datos de normalización de volume.",
|
||||
"CleanupUserDataTask": "Tarefa de limpeza de datos dos usuarios",
|
||||
"CleanupUserDataTaskDescription": "Limpa todos os datos do usuario (estado de visualización, de favorito etc.) dos medios ausentes polo menos 90 días."
|
||||
"CleanupUserDataTaskDescription": "Limpa todos os datos do usuario (estado de visualización, de favorito etc.) dos medios ausentes polo menos 90 días.",
|
||||
"Original": "Orixinal"
|
||||
}
|
||||
|
||||
@@ -106,5 +106,7 @@
|
||||
"TaskDownloadMissingLyrics": "누락된 가사 다운로드",
|
||||
"TaskDownloadMissingLyricsDescription": "가사 다운로드",
|
||||
"CleanupUserDataTask": "사용자 데이터 정리 작업",
|
||||
"CleanupUserDataTaskDescription": "최소 90일 이상 존재하지 않는 미디어에 대한 사용자 데이터(시청 상태, 즐겨찾기 등)를 정리합니다."
|
||||
"CleanupUserDataTaskDescription": "최소 90일 이상 존재하지 않는 미디어에 대한 사용자 데이터(시청 상태, 즐겨찾기 등)를 정리합니다.",
|
||||
"LyricDownloadFailureFromForItem": "{1}에 대한 가사를 {0}에서 다운로드하지 못했습니다",
|
||||
"Original": "원본"
|
||||
}
|
||||
|
||||
@@ -107,5 +107,6 @@
|
||||
"TaskMoveTrickplayImagesDescription": "Move os ficheiros trickplay existentes de acordo com as definições da mediateca.",
|
||||
"CleanupUserDataTaskDescription": "Apaga todos os dados de utilizador (estados de reprodução, favoritos, etc) de arquivos média não presentes há 90 dias ou mais.",
|
||||
"CleanupUserDataTask": "Limpeza de dados de utilizador",
|
||||
"Original": "Original"
|
||||
"Original": "Original",
|
||||
"LyricDownloadFailureFromForItem": "Erro ao descarregar letras de {0} para {1}"
|
||||
}
|
||||
|
||||
@@ -106,5 +106,7 @@
|
||||
"TaskDownloadMissingLyrics": "Stiahnuť chýbajúce texty piesní",
|
||||
"TaskDownloadMissingLyricsDescription": "Stiahne texty pre piesne",
|
||||
"CleanupUserDataTask": "Prečistiť používateľské dáta",
|
||||
"CleanupUserDataTaskDescription": "Vyčistí všetky dáta používateľa (stav sledovania, stav obľúbených atď.) z médií, ktoré už neexistujú aspoň 90 dní."
|
||||
"CleanupUserDataTaskDescription": "Vyčistí všetky dáta používateľa (stav sledovania, stav obľúbených atď.) z médií, ktoré už neexistujú aspoň 90 dní.",
|
||||
"LyricDownloadFailureFromForItem": "Text piesne sa nepodarilo stiahnuť z {0} pre {1}",
|
||||
"Original": "Originál"
|
||||
}
|
||||
|
||||
@@ -106,5 +106,7 @@
|
||||
"TaskAudioNormalization": "Normalizacija zvoka",
|
||||
"TaskAudioNormalizationDescription": "Pregled datotek za podatke o normalizaciji zvoka.",
|
||||
"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."
|
||||
"CleanupUserDataTaskDescription": "Izbriše vse uporabniške podatke (stanje ogleda, priljubljene itd.) za vsebine, ki že več kot 90 dni niso na voljo.",
|
||||
"LyricDownloadFailureFromForItem": "Besedila ni bilo mogoče prenesti iz {0} za {1}",
|
||||
"Original": "Original"
|
||||
}
|
||||
|
||||
@@ -566,11 +566,15 @@ namespace Emby.Server.Implementations.Localization
|
||||
|
||||
private static string GetResourceFilename(string culture)
|
||||
{
|
||||
var parts = culture.Split('-');
|
||||
// Region codes may use a '-' (BCP-47, e.g. "pt-BR") or '_' (e.g. "es_419", "ar_SA") separator.
|
||||
// Normalize the casing (lower-case language, upper-case region) while preserving the separator
|
||||
// so the result matches the embedded resource file name, which is case-sensitive.
|
||||
var separatorIndex = culture.IndexOfAny(['-', '_']);
|
||||
|
||||
if (parts.Length == 2)
|
||||
if (separatorIndex > 0)
|
||||
{
|
||||
culture = parts[0].ToLowerInvariant() + "-" + parts[1].ToUpperInvariant();
|
||||
var separator = culture[separatorIndex];
|
||||
culture = culture[..separatorIndex].ToLowerInvariant() + separator + culture[(separatorIndex + 1)..].ToUpperInvariant();
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.Globalization;
|
||||
using MediaBrowser.Model.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -17,6 +18,7 @@ public class OptimizeDatabaseTask : IScheduledTask, IConfigurableScheduledTask
|
||||
private readonly ILogger<OptimizeDatabaseTask> _logger;
|
||||
private readonly ILocalizationManager _localization;
|
||||
private readonly IJellyfinDatabaseProvider _jellyfinDatabaseProvider;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="OptimizeDatabaseTask" /> class.
|
||||
@@ -24,14 +26,17 @@ public class OptimizeDatabaseTask : IScheduledTask, IConfigurableScheduledTask
|
||||
/// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
|
||||
/// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
|
||||
/// <param name="jellyfinDatabaseProvider">Instance of the JellyfinDatabaseProvider that can be used for provider specific operations.</param>
|
||||
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
|
||||
public OptimizeDatabaseTask(
|
||||
ILogger<OptimizeDatabaseTask> logger,
|
||||
ILocalizationManager localization,
|
||||
IJellyfinDatabaseProvider jellyfinDatabaseProvider)
|
||||
IJellyfinDatabaseProvider jellyfinDatabaseProvider,
|
||||
ILibraryManager libraryManager)
|
||||
{
|
||||
_logger = logger;
|
||||
_localization = localization;
|
||||
_jellyfinDatabaseProvider = jellyfinDatabaseProvider;
|
||||
_libraryManager = libraryManager;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -68,6 +73,15 @@ public class OptimizeDatabaseTask : IScheduledTask, IConfigurableScheduledTask
|
||||
/// <inheritdoc />
|
||||
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
||||
{
|
||||
// Vacuuming/checkpointing requires an exclusive lock on the database. Running it while a library scan is in
|
||||
// progress causes both operations to contend for the database and can stall the scan, so defer optimization
|
||||
// until no scan is running. The task will run again on its next trigger.
|
||||
if (_libraryManager.IsScanRunning)
|
||||
{
|
||||
_logger.LogInformation("Skipping database optimization because a library scan is currently running.");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Optimizing and vacuuming jellyfin.db...");
|
||||
|
||||
try
|
||||
|
||||
@@ -75,6 +75,14 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
|
||||
/// <inheritdoc />
|
||||
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
||||
{
|
||||
// People validation performs heavy database writes that contend with an active library scan.
|
||||
// Defer it until the scan has finished; the task will run again on its next trigger.
|
||||
if (_libraryManager.IsScanRunning)
|
||||
{
|
||||
_logger.LogInformation("Skipping people validation because a library scan is currently running.");
|
||||
return;
|
||||
}
|
||||
|
||||
var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using (context.ConfigureAwait(false))
|
||||
{
|
||||
|
||||
@@ -343,6 +343,10 @@ namespace Emby.Server.Implementations.Session
|
||||
_activeLiveStreamSessions.TryRemove(liveStreamId, out _);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
liveStreamNeedsToBeClosed = true;
|
||||
}
|
||||
|
||||
if (liveStreamNeedsToBeClosed)
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
@@ -32,6 +33,8 @@ namespace Emby.Server.Implementations.Updates
|
||||
/// </summary>
|
||||
public class InstallationManager : IInstallationManager
|
||||
{
|
||||
private static readonly SearchValues<char> InvalidPackageNameChars = SearchValues.Create([.. Path.GetInvalidFileNameChars(), '/', '\\']);
|
||||
|
||||
/// <summary>
|
||||
/// The logger.
|
||||
/// </summary>
|
||||
@@ -521,9 +524,27 @@ namespace Emby.Server.Implementations.Updates
|
||||
return;
|
||||
}
|
||||
|
||||
if (!IsValidPackageDirectoryName(package.Name))
|
||||
{
|
||||
_logger.LogError("Refusing to install package with invalid name {PackageName}.", package.Name);
|
||||
throw new InvalidDataException($"Plugin package name '{package.Name}' is not a valid directory name.");
|
||||
}
|
||||
|
||||
// Always override the passed-in target (which is a file) and figure it out again
|
||||
string targetDir = Path.Combine(_appPaths.PluginsPath, package.Name);
|
||||
|
||||
var pluginsRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(_appPaths.PluginsPath));
|
||||
var resolvedTarget = Path.GetFullPath(targetDir);
|
||||
if (!resolvedTarget.StartsWith(pluginsRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogError(
|
||||
"Refusing to install package {PackageName}: resolved target {Resolved} is outside plugins directory {Root}.",
|
||||
package.Name,
|
||||
resolvedTarget,
|
||||
pluginsRoot);
|
||||
throw new InvalidDataException($"Plugin package name '{package.Name}' resolves outside the plugins directory.");
|
||||
}
|
||||
|
||||
using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
|
||||
.GetAsync(new Uri(package.SourceUrl), cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
@@ -572,6 +593,26 @@ namespace Emby.Server.Implementations.Updates
|
||||
_pluginManager.ImportPluginFrom(targetDir);
|
||||
}
|
||||
|
||||
private static bool IsValidPackageDirectoryName(string? name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (name.Equals(".", StringComparison.Ordinal) || name.Equals("..", StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (name.IndexOfAny(InvalidPackageNameChars) >= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<bool> InstallPackageInternal(InstallationInfo package, CancellationToken cancellationToken)
|
||||
{
|
||||
LocalPlugin? plugin = _pluginManager.Plugins.FirstOrDefault(p => p.Id.Equals(package.Id) && p.Version.Equals(package.Version))
|
||||
|
||||
@@ -1002,9 +1002,7 @@ public class LiveTvController : BaseJellyfinApiController
|
||||
{
|
||||
if (!string.IsNullOrEmpty(pw))
|
||||
{
|
||||
// TODO: remove ToLower when Convert.ToHexString supports lowercase
|
||||
// Schedules Direct requires the hex to be lowercase
|
||||
listingsProviderInfo.Password = Convert.ToHexString(SHA1.HashData(Encoding.UTF8.GetBytes(pw))).ToLowerInvariant();
|
||||
listingsProviderInfo.Password = Convert.ToHexStringLower(SHA1.HashData(Encoding.UTF8.GetBytes(pw)));
|
||||
}
|
||||
|
||||
return await _listingsManager.SaveListingProvider(listingsProviderInfo, validateLogin, validateListings).ConfigureAwait(false);
|
||||
|
||||
@@ -122,6 +122,7 @@ public class TrailersController : BaseJellyfinApiController
|
||||
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the trailers.</returns>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[Obsolete("Use GetItems with includeItemTypes=Trailer instead.")]
|
||||
public async Task<ActionResult<QueryResult<BaseItemDto>>> GetTrailers(
|
||||
[FromQuery] Guid? userId,
|
||||
[FromQuery] string? maxOfficialRating,
|
||||
|
||||
@@ -12,6 +12,7 @@ using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Server.Implementations.StorageHelpers;
|
||||
using Jellyfin.Server.Implementations.SystemBackupService;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.SystemBackupService;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
@@ -33,6 +34,7 @@ public class BackupService : IBackupService
|
||||
private readonly IServerApplicationPaths _applicationPaths;
|
||||
private readonly IJellyfinDatabaseProvider _jellyfinDatabaseProvider;
|
||||
private readonly IHostApplicationLifetime _hostApplicationLifetime;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private static readonly JsonSerializerOptions _serializerSettings = new JsonSerializerOptions(JsonSerializerDefaults.General)
|
||||
{
|
||||
AllowTrailingCommas = true,
|
||||
@@ -50,13 +52,15 @@ public class BackupService : IBackupService
|
||||
/// <param name="applicationPaths">The application paths.</param>
|
||||
/// <param name="jellyfinDatabaseProvider">The Jellyfin database Provider in use.</param>
|
||||
/// <param name="applicationLifetime">The SystemManager.</param>
|
||||
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
|
||||
public BackupService(
|
||||
ILogger<BackupService> logger,
|
||||
IDbContextFactory<JellyfinDbContext> dbProvider,
|
||||
IServerApplicationHost applicationHost,
|
||||
IServerApplicationPaths applicationPaths,
|
||||
IJellyfinDatabaseProvider jellyfinDatabaseProvider,
|
||||
IHostApplicationLifetime applicationLifetime)
|
||||
IHostApplicationLifetime applicationLifetime,
|
||||
ILibraryManager libraryManager)
|
||||
{
|
||||
_logger = logger;
|
||||
_dbProvider = dbProvider;
|
||||
@@ -64,6 +68,7 @@ public class BackupService : IBackupService
|
||||
_applicationPaths = applicationPaths;
|
||||
_jellyfinDatabaseProvider = jellyfinDatabaseProvider;
|
||||
_hostApplicationLifetime = applicationLifetime;
|
||||
_libraryManager = libraryManager;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -263,6 +268,14 @@ public class BackupService : IBackupService
|
||||
/// <inheritdoc/>
|
||||
public async Task<BackupManifestDto> CreateBackupAsync(BackupOptionsDto backupOptions)
|
||||
{
|
||||
// Creating a backup runs a database optimization and reads the entire database under a transaction, both of
|
||||
// which heavily contend with an active library scan and could capture an inconsistent database state.
|
||||
if (_libraryManager.IsScanRunning)
|
||||
{
|
||||
_logger.LogWarning("Cannot create a backup while a library scan is running.");
|
||||
throw new InvalidOperationException("Cannot create a backup while a library scan is running. Please try again once the scan has finished.");
|
||||
}
|
||||
|
||||
var manifest = new BackupManifest()
|
||||
{
|
||||
DateCreated = DateTime.UtcNow,
|
||||
|
||||
@@ -65,8 +65,13 @@ public class ItemPersistenceService : IItemPersistenceService
|
||||
descendantIds.Add(id);
|
||||
}
|
||||
|
||||
// Use WhereOneOrMany instead of a raw HashSet.Contains so large id sets are bound as a
|
||||
// single parameter (json_each) rather than one SQL variable per id, which would otherwise
|
||||
// overflow SQLite's variable limit when deleting many items at once (e.g. migrations).
|
||||
var ownerIds = descendantIds.ToArray();
|
||||
var extraIds = context.BaseItems
|
||||
.Where(e => e.OwnerId.HasValue && descendantIds.Contains(e.OwnerId.Value))
|
||||
.Where(e => e.OwnerId.HasValue)
|
||||
.WhereOneOrMany(ownerIds, e => e.OwnerId!.Value)
|
||||
.Select(e => e.Id)
|
||||
.ToArray();
|
||||
|
||||
|
||||
@@ -215,8 +215,11 @@ internal class JellyfinMigrationService
|
||||
logger.LogInformation("There are {Pending} migrations for stage {Stage}.", pendingCodeMigrations.Length, stage);
|
||||
migrations = pendingMigrations.OrderBy(e => e.Key).ToArray();
|
||||
|
||||
var migrationIndex = 0;
|
||||
foreach (var item in migrations)
|
||||
{
|
||||
// Surface generic "Running migration X of Y" progress in the always-visible startup UI header.
|
||||
SetupServer.ReportActivity(StartupActivity.Migration(++migrationIndex, migrations.Length));
|
||||
var migrationLogger = logger.With(_loggerFactory.CreateLogger(item.Migration.GetType().Name)).BeginGroup($"{item.Key}");
|
||||
try
|
||||
{
|
||||
|
||||
@@ -136,19 +136,38 @@ public class FixIncorrectOwnerIdRelationships : IAsyncMigrationRoutine
|
||||
|
||||
if (allIdsToDelete.Count > 0)
|
||||
{
|
||||
// Batch-resolve items for metadata path cleanup, then delete all at once
|
||||
var itemsToDelete = allIdsToDelete
|
||||
.Select(id => _libraryManager.GetItemById(id))
|
||||
.Where(item => item is not null)
|
||||
.ToList();
|
||||
_libraryManager.DeleteItemsUnsafeFast(itemsToDelete!);
|
||||
_logger.LogInformation("Deleting {Count} duplicate database entries...", allIdsToDelete.Count);
|
||||
|
||||
// Fall back to direct DB deletion for any items that couldn't be resolved via LibraryManager
|
||||
var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet();
|
||||
var unresolvedIds = allIdsToDelete.Where(id => !deletedIds.Contains(id)).ToList();
|
||||
if (unresolvedIds.Count > 0)
|
||||
// Delete in batches so progress is visible (item resolution and deletion can take a
|
||||
// long time on large libraries) and so we never issue one massive delete transaction.
|
||||
const int deleteBatchSize = 500;
|
||||
var deletedSoFar = 0;
|
||||
for (var offset = 0; offset < allIdsToDelete.Count; offset += deleteBatchSize)
|
||||
{
|
||||
_persistenceService.DeleteItem(unresolvedIds);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var batchIds = allIdsToDelete.GetRange(offset, Math.Min(deleteBatchSize, allIdsToDelete.Count - offset));
|
||||
|
||||
// Resolve items for metadata path cleanup, then delete this batch
|
||||
var itemsToDelete = batchIds
|
||||
.Select(id => _libraryManager.GetItemById(id))
|
||||
.Where(item => item is not null)
|
||||
.ToList();
|
||||
if (itemsToDelete.Count > 0)
|
||||
{
|
||||
_libraryManager.DeleteItemsUnsafeFast(itemsToDelete!);
|
||||
}
|
||||
|
||||
// Fall back to direct DB deletion for any items that couldn't be resolved via LibraryManager
|
||||
var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet();
|
||||
var unresolvedIds = batchIds.Where(id => !deletedIds.Contains(id)).ToList();
|
||||
if (unresolvedIds.Count > 0)
|
||||
{
|
||||
_persistenceService.DeleteItem(unresolvedIds);
|
||||
}
|
||||
|
||||
deletedSoFar += batchIds.Count;
|
||||
_logger.LogInformation("Deleting duplicates: {Deleted}/{Total} items", deletedSoFar, allIdsToDelete.Count);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -133,10 +133,12 @@ namespace Jellyfin.Server
|
||||
}
|
||||
}
|
||||
|
||||
SetupServer.ReportActivity(StartupActivity.CheckingStorage);
|
||||
StorageHelper.TestCommonPathsForStorageCapacity(appPaths, StartupLogger.Logger.With(_loggerFactory.CreateLogger<Startup>()).BeginGroup($"Storage Check"));
|
||||
|
||||
StartupHelpers.PerformStaticInitialization();
|
||||
|
||||
SetupServer.ReportActivity(StartupActivity.Initializing);
|
||||
await ApplyStartupMigrationAsync(appPaths, startupConfig, options).ConfigureAwait(false);
|
||||
|
||||
do
|
||||
@@ -195,6 +197,7 @@ namespace Jellyfin.Server
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_restoreFromBackup))
|
||||
{
|
||||
SetupServer.ReportActivity(StartupActivity.RestoringBackup);
|
||||
await appHost.ServiceProvider.GetService<IBackupService>()!.RestoreBackupAsync(_restoreFromBackup).ConfigureAwait(false);
|
||||
_restoreFromBackup = null;
|
||||
_restartOnShutdown = true;
|
||||
@@ -202,9 +205,13 @@ namespace Jellyfin.Server
|
||||
}
|
||||
|
||||
var jellyfinMigrationService = ActivatorUtilities.CreateInstance<JellyfinMigrationService>(appHost.ServiceProvider);
|
||||
SetupServer.ReportActivity(StartupActivity.PreparingMigrations);
|
||||
await jellyfinMigrationService.PrepareSystemForMigration(_logger).ConfigureAwait(false);
|
||||
// "Preparing migrations" carries through the DB read; per-migration progress is reported
|
||||
// as "Running migration X of Y" from inside the step once the pending set is known.
|
||||
await jellyfinMigrationService.MigrateStepAsync(JellyfinMigrationStageTypes.CoreInitialisation, appHost.ServiceProvider).ConfigureAwait(false);
|
||||
|
||||
SetupServer.ReportActivity(StartupActivity.InitializingServices);
|
||||
await appHost.InitializeServices(startupConfig).ConfigureAwait(false);
|
||||
_appHost = appHost;
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ using Jellyfin.Server.Extensions;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Net;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Model.IO;
|
||||
using MediaBrowser.Model.System;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
@@ -25,9 +24,6 @@ using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using Morestachio;
|
||||
using Morestachio.Framework.IO.SingleStream;
|
||||
using Morestachio.Rendering;
|
||||
using Serilog;
|
||||
using ILogger = Microsoft.Extensions.Logging.ILogger;
|
||||
|
||||
@@ -44,7 +40,8 @@ public sealed class SetupServer : IDisposable
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private readonly IConfiguration _startupConfiguration;
|
||||
private readonly ServerConfigurationManager _configurationManager;
|
||||
private IRenderer? _startupUiRenderer;
|
||||
private static volatile string _currentActivity = StartupActivity.Starting;
|
||||
private StartupUiRenderer? _startupUiRenderer;
|
||||
private IHost? _startupServer;
|
||||
private bool _disposed;
|
||||
private bool _isUnhealthy;
|
||||
@@ -76,6 +73,12 @@ public sealed class SetupServer : IDisposable
|
||||
|
||||
internal static ConcurrentQueue<StartupLogTopic>? LogQueue { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets a generic, non-identifying summary of what startup is currently doing. This is shown in the
|
||||
/// always-visible header of the startup UI to unauthenticated clients, so it never contains server specific details.
|
||||
/// </summary>
|
||||
internal static string CurrentActivity => _currentActivity;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether Startup server is currently running.
|
||||
/// </summary>
|
||||
@@ -87,64 +90,9 @@ public sealed class SetupServer : IDisposable
|
||||
/// <returns>A Task.</returns>
|
||||
public async Task RunAsync()
|
||||
{
|
||||
var fileTemplate = await File.ReadAllTextAsync(Path.Combine(AppContext.BaseDirectory, "ServerSetupApp", "index.mstemplate.html")).ConfigureAwait(false);
|
||||
_startupUiRenderer = (await ParserOptionsBuilder.New()
|
||||
.WithTemplate(fileTemplate)
|
||||
.WithFormatter(
|
||||
(Version version, int arg) =>
|
||||
{
|
||||
// version type does not for some stupid reason implement IFormattable which morestachio relies on for ToString support therefor we need to do it manually.
|
||||
return version.ToString(arg);
|
||||
},
|
||||
"ToString")
|
||||
.WithFormatter(
|
||||
(StartupLogTopic logEntry, IEnumerable<StartupLogTopic> children) =>
|
||||
{
|
||||
if (children.Any())
|
||||
{
|
||||
var maxLevel = logEntry.LogLevel;
|
||||
var stack = new Stack<StartupLogTopic>(children);
|
||||
|
||||
while (maxLevel != LogLevel.Error && stack.Count > 0 && (logEntry = stack.Pop()) is not null) // error is the highest inherted error level.
|
||||
{
|
||||
maxLevel = maxLevel < logEntry.LogLevel ? logEntry.LogLevel : maxLevel;
|
||||
foreach (var child in logEntry.Children)
|
||||
{
|
||||
stack.Push(child);
|
||||
}
|
||||
}
|
||||
|
||||
return maxLevel;
|
||||
}
|
||||
|
||||
return logEntry.LogLevel;
|
||||
},
|
||||
"FormatLogLevel")
|
||||
.WithFormatter(
|
||||
(LogLevel logLevel) =>
|
||||
{
|
||||
switch (logLevel)
|
||||
{
|
||||
case LogLevel.Trace:
|
||||
case LogLevel.Debug:
|
||||
case LogLevel.None:
|
||||
return "success";
|
||||
case LogLevel.Information:
|
||||
return "info";
|
||||
case LogLevel.Warning:
|
||||
return "warn";
|
||||
case LogLevel.Error:
|
||||
return "danger";
|
||||
case LogLevel.Critical:
|
||||
return "danger-strong";
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
},
|
||||
"ToString")
|
||||
.BuildAndParseAsync()
|
||||
.ConfigureAwait(false))
|
||||
.CreateCompiledRenderer();
|
||||
ReportActivity(StartupActivity.Starting);
|
||||
_startupUiRenderer = await StartupUiRenderer.CreateAsync(
|
||||
Path.Combine(AppContext.BaseDirectory, "ServerSetupApp", "index.mstemplate.html")).ConfigureAwait(false);
|
||||
|
||||
ThrowIfDisposed();
|
||||
var retryAfterValue = TimeSpan.FromSeconds(5);
|
||||
@@ -257,13 +205,14 @@ public sealed class SetupServer : IDisposable
|
||||
new Dictionary<string, object>()
|
||||
{
|
||||
{ "isInReportingMode", _isUnhealthy },
|
||||
{ "currentActivity", CurrentActivity },
|
||||
{ "retryValue", retryAfterValue },
|
||||
{ "version", version },
|
||||
{ "logs", startupLogEntries },
|
||||
{ "networkManagerReady", networkManager is not null },
|
||||
{ "localNetworkRequest", networkManager is not null && context.Connection.RemoteIpAddress is not null && networkManager.IsInLocalNetwork(context.Connection.RemoteIpAddress) }
|
||||
},
|
||||
new ByteCounterStream(context.Response.BodyWriter.AsStream(), IODefaults.FileStreamBufferSize, true, _startupUiRenderer.ParserOptions))
|
||||
context.Response.BodyWriter.AsStream())
|
||||
.ConfigureAwait(false);
|
||||
});
|
||||
});
|
||||
@@ -309,6 +258,16 @@ public sealed class SetupServer : IDisposable
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reports the current startup activity shown to all clients in the startup UI header.
|
||||
/// Only pass generic, non-identifying text from <see cref="StartupActivity"/>.
|
||||
/// </summary>
|
||||
/// <param name="activity">A generic description such as <see cref="StartupActivity.PreparingMigrations"/>.</param>
|
||||
internal static void ReportActivity(string activity)
|
||||
{
|
||||
_currentActivity = activity;
|
||||
}
|
||||
|
||||
internal void SoftStop()
|
||||
{
|
||||
_isUnhealthy = true;
|
||||
|
||||
41
Jellyfin.Server/ServerSetupApp/StartupActivity.cs
Normal file
41
Jellyfin.Server/ServerSetupApp/StartupActivity.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace Jellyfin.Server.ServerSetupApp;
|
||||
|
||||
/// <summary>
|
||||
/// A curated vocabulary of generic, non-identifying descriptions of what the server is doing during startup.
|
||||
/// These are shown in the always-visible header of the startup UI to <b>unauthenticated</b> clients, so every
|
||||
/// value must stay generic and must never contain server specific details (paths, names, plugin or migration ids, counts of items, etc.).
|
||||
/// </summary>
|
||||
public static class StartupActivity
|
||||
{
|
||||
/// <summary>The default state before any work has been reported.</summary>
|
||||
public const string Starting = "Starting up";
|
||||
|
||||
/// <summary>Validating that the configured storage locations are usable.</summary>
|
||||
public const string CheckingStorage = "Checking storage";
|
||||
|
||||
/// <summary>Bringing up the migration subsystem and running early startup checks.</summary>
|
||||
public const string Initializing = "Initializing server";
|
||||
|
||||
/// <summary>Preparing the system for migrations (e.g. taking safety backups).</summary>
|
||||
public const string PreparingMigrations = "Preparing migrations";
|
||||
|
||||
/// <summary>Restoring from a backup.</summary>
|
||||
public const string RestoringBackup = "Restoring backup";
|
||||
|
||||
/// <summary>Bringing up core services and plugins.</summary>
|
||||
public const string InitializingServices = "Initializing services";
|
||||
|
||||
/// <summary>Running the final startup tasks.</summary>
|
||||
public const string FinishingStartup = "Finishing startup";
|
||||
|
||||
/// <summary>
|
||||
/// Builds a generic "Running migration X of Y" description. Only the numeric position and total are exposed.
|
||||
/// </summary>
|
||||
/// <param name="current">The 1-based index of the migration currently running.</param>
|
||||
/// <param name="total">The total number of migrations in this batch.</param>
|
||||
/// <returns>A generic progress description.</returns>
|
||||
public static string Migration(int current, int total)
|
||||
=> string.Format(CultureInfo.InvariantCulture, "Running migration {0} of {1}", current, total);
|
||||
}
|
||||
109
Jellyfin.Server/ServerSetupApp/StartupUiRenderer.cs
Normal file
109
Jellyfin.Server/ServerSetupApp/StartupUiRenderer.cs
Normal file
@@ -0,0 +1,109 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Model.IO;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Morestachio;
|
||||
using Morestachio.Framework.IO.SingleStream;
|
||||
using Morestachio.Rendering;
|
||||
|
||||
namespace Jellyfin.Server.ServerSetupApp;
|
||||
|
||||
/// <summary>
|
||||
/// Compiles and renders the startup UI Morestachio template.
|
||||
/// Shared by the live <see cref="SetupServer"/> and the standalone startup UI preview tool so both
|
||||
/// exercise the exact same template and formatters.
|
||||
/// </summary>
|
||||
public sealed class StartupUiRenderer
|
||||
{
|
||||
private readonly IRenderer _renderer;
|
||||
|
||||
private StartupUiRenderer(IRenderer renderer)
|
||||
{
|
||||
_renderer = renderer;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compiles the startup UI template located at <paramref name="templatePath"/>.
|
||||
/// </summary>
|
||||
/// <param name="templatePath">The full path to the <c>index.mstemplate.html</c> template.</param>
|
||||
/// <returns>A ready to use <see cref="StartupUiRenderer"/>.</returns>
|
||||
public static async Task<StartupUiRenderer> CreateAsync(string templatePath)
|
||||
{
|
||||
var fileTemplate = await File.ReadAllTextAsync(templatePath).ConfigureAwait(false);
|
||||
var renderer = (await ParserOptionsBuilder.New()
|
||||
.WithTemplate(fileTemplate)
|
||||
.WithFormatter(
|
||||
(Version version, int arg) =>
|
||||
{
|
||||
// version type does not for some stupid reason implement IFormattable which morestachio relies on for ToString support therefor we need to do it manually.
|
||||
return version.ToString(arg);
|
||||
},
|
||||
"ToString")
|
||||
.WithFormatter(
|
||||
(StartupLogTopic logEntry, IEnumerable<StartupLogTopic> children) =>
|
||||
{
|
||||
if (children.Any())
|
||||
{
|
||||
var maxLevel = logEntry.LogLevel;
|
||||
var stack = new Stack<StartupLogTopic>(children);
|
||||
|
||||
while (maxLevel != LogLevel.Error && stack.Count > 0 && (logEntry = stack.Pop()) is not null) // error is the highest inherted error level.
|
||||
{
|
||||
maxLevel = maxLevel < logEntry.LogLevel ? logEntry.LogLevel : maxLevel;
|
||||
foreach (var child in logEntry.Children)
|
||||
{
|
||||
stack.Push(child);
|
||||
}
|
||||
}
|
||||
|
||||
return maxLevel;
|
||||
}
|
||||
|
||||
return logEntry.LogLevel;
|
||||
},
|
||||
"FormatLogLevel")
|
||||
.WithFormatter(
|
||||
(LogLevel logLevel) =>
|
||||
{
|
||||
switch (logLevel)
|
||||
{
|
||||
case LogLevel.Trace:
|
||||
case LogLevel.Debug:
|
||||
case LogLevel.None:
|
||||
return "success";
|
||||
case LogLevel.Information:
|
||||
return "info";
|
||||
case LogLevel.Warning:
|
||||
return "warn";
|
||||
case LogLevel.Error:
|
||||
return "danger";
|
||||
case LogLevel.Critical:
|
||||
return "danger-strong";
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
},
|
||||
"ToString")
|
||||
.BuildAndParseAsync()
|
||||
.ConfigureAwait(false))
|
||||
.CreateCompiledRenderer();
|
||||
|
||||
return new StartupUiRenderer(renderer);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders the template with the provided model into the target stream.
|
||||
/// </summary>
|
||||
/// <param name="model">The values made available to the template.</param>
|
||||
/// <param name="output">The stream the rendered HTML is written to.</param>
|
||||
/// <returns>A Task.</returns>
|
||||
public Task RenderAsync(IDictionary<string, object> model, Stream output)
|
||||
{
|
||||
return _renderer.RenderAsync(
|
||||
model,
|
||||
new ByteCounterStream(output, IODefaults.FileStreamBufferSize, true, _renderer.ParserOptions));
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -7870,13 +7870,14 @@ namespace MediaBrowser.Controller.MediaEncoding
|
||||
audioTranscodeParams.Add("-ar " + state.BaseRequest.AudioBitRate);
|
||||
}
|
||||
|
||||
if (!string.Equals(outputCodec, "opus", StringComparison.OrdinalIgnoreCase))
|
||||
var sampleRate = state.OutputAudioSampleRate;
|
||||
if (sampleRate.HasValue)
|
||||
{
|
||||
// opus only supports specific sampling rates
|
||||
var sampleRate = state.OutputAudioSampleRate;
|
||||
if (sampleRate.HasValue)
|
||||
var sampleRateValue = sampleRate.Value;
|
||||
if (string.Equals(outputCodec, "opus", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var sampleRateValue = sampleRate.Value switch
|
||||
// opus only supports specific sampling rates
|
||||
sampleRateValue = sampleRate.Value switch
|
||||
{
|
||||
<= 8000 => 8000,
|
||||
<= 12000 => 12000,
|
||||
@@ -7884,9 +7885,9 @@ namespace MediaBrowser.Controller.MediaEncoding
|
||||
<= 24000 => 24000,
|
||||
_ => 48000
|
||||
};
|
||||
|
||||
audioTranscodeParams.Add("-ar " + sampleRateValue.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
audioTranscodeParams.Add("-ar " + sampleRateValue.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
// Copy the movflags from GetProgressiveVideoFullCommandLine
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Controller.Plugins;
|
||||
using MediaBrowser.Providers.Books.ComicBookInfo;
|
||||
using MediaBrowser.Providers.Books.ComicInfo;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace MediaBrowser.Providers.Books;
|
||||
|
||||
/// <inheritdoc />
|
||||
public class ComicServiceRegistrator : IPluginServiceRegistrator
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)
|
||||
{
|
||||
// register the generic local metadata provider for comic files
|
||||
serviceCollection.AddSingleton<ComicProvider>();
|
||||
|
||||
// register the actual implementations of the local metadata provider for comic files
|
||||
serviceCollection.AddSingleton<IComicProvider, ComicBookInfoProvider>();
|
||||
serviceCollection.AddSingleton<IComicProvider, ExternalComicInfoProvider>();
|
||||
serviceCollection.AddSingleton<IComicProvider, InternalComicInfoProvider>();
|
||||
}
|
||||
}
|
||||
@@ -831,8 +831,16 @@ namespace MediaBrowser.Providers.Manager
|
||||
var isLocalLocked = temp.Item.IsLocked;
|
||||
if (!isLocalLocked && (options.ReplaceAllMetadata || options.MetadataRefreshMode > MetadataRefreshMode.ValidationOnly))
|
||||
{
|
||||
var remoteResult = await ExecuteRemoteProviders(temp, logName, false, id, providers.OfType<IRemoteMetadataProvider<TItemType, TIdType>>(), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
var remoteProviders = providers.OfType<IRemoteMetadataProvider<TItemType, TIdType>>();
|
||||
|
||||
// When identifying, run the provider the user picked first so the correct IDs are used.
|
||||
if (!string.IsNullOrEmpty(options.SearchResult?.SearchProviderName))
|
||||
{
|
||||
remoteProviders = remoteProviders
|
||||
.OrderBy(i => string.Equals(i.Name, options.SearchResult.SearchProviderName, StringComparison.OrdinalIgnoreCase) ? 0 : 1);
|
||||
}
|
||||
|
||||
var remoteResult = await ExecuteRemoteProviders(temp, logName, false, id, remoteProviders, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
refreshResult.UpdateType |= remoteResult.UpdateType;
|
||||
refreshResult.ErrorMessage = remoteResult.ErrorMessage;
|
||||
|
||||
@@ -549,7 +549,7 @@ namespace MediaBrowser.Providers.MediaInfo
|
||||
var candidateUnsynchronizedLyric = supportedLyrics.FirstOrDefault(l => l.Format is LyricsInfo.LyricsFormat.UNSYNCHRONIZED or LyricsInfo.LyricsFormat.OTHER && l.UnsynchronizedLyrics is not null);
|
||||
var lyrics = candidateSynchronizedLyric is not null ? candidateSynchronizedLyric.FormatSynch() : candidateUnsynchronizedLyric?.UnsynchronizedLyrics;
|
||||
if (!string.IsNullOrWhiteSpace(lyrics)
|
||||
&& tryExtractEmbeddedLyrics)
|
||||
&& (tryExtractEmbeddedLyrics || options.ReplaceAllMetadata))
|
||||
{
|
||||
await _lyricManager.SaveLyricAsync(audio, "lrc", lyrics).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Database.Implementations.Enums;
|
||||
using Jellyfin.Extensions;
|
||||
using Jellyfin.Extensions.Json;
|
||||
using Jellyfin.LiveTv;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
using MediaBrowser.Controller.Channels;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
@@ -1109,9 +1110,8 @@ namespace Jellyfin.LiveTv.Channels
|
||||
item.Path = mediaSource?.Path;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(info.ImageUrl) && !item.HasImage(ImageType.Primary))
|
||||
if (LiveTvChannelImageHelper.UpdateChannelImageIfNeeded(item, null, info.ImageUrl))
|
||||
{
|
||||
item.SetImagePath(ImageType.Primary, info.ImageUrl);
|
||||
_logger.LogDebug("Forcing update due to ImageUrl {0}", item.Name);
|
||||
forceUpdate = true;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Extensions;
|
||||
using Jellyfin.LiveTv;
|
||||
using Jellyfin.LiveTv.Configuration;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Controller.Dto;
|
||||
@@ -448,23 +449,9 @@ public class GuideManager : IGuideManager
|
||||
|
||||
item.Name = channelInfo.Name;
|
||||
|
||||
var currentPrimary = item.GetImageInfo(ImageType.Primary, 0);
|
||||
var imageUrlIsNull = string.IsNullOrWhiteSpace(channelInfo.ImageUrl);
|
||||
|
||||
// Update channel image if image URL has changed
|
||||
if (currentPrimary is null
|
||||
|| (!imageUrlIsNull && !string.Equals(currentPrimary.Path, channelInfo.ImageUrl, StringComparison.Ordinal)))
|
||||
if (LiveTvChannelImageHelper.UpdateChannelImageIfNeeded(item, channelInfo.ImagePath, channelInfo.ImageUrl))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(channelInfo.ImagePath))
|
||||
{
|
||||
item.SetImagePath(ImageType.Primary, channelInfo.ImagePath);
|
||||
forceUpdate = true;
|
||||
}
|
||||
else if (!imageUrlIsNull)
|
||||
{
|
||||
item.SetImagePath(ImageType.Primary, channelInfo.ImageUrl);
|
||||
forceUpdate = true;
|
||||
}
|
||||
forceUpdate = true;
|
||||
}
|
||||
|
||||
if (isNew)
|
||||
|
||||
@@ -748,9 +748,7 @@ namespace Jellyfin.LiveTv.Listings
|
||||
#pragma warning disable CA5350 // SchedulesDirect is always SHA1.
|
||||
var hashedPasswordBytes = SHA1.HashData(Encoding.ASCII.GetBytes(password));
|
||||
#pragma warning restore CA5350
|
||||
// TODO: remove ToLower when Convert.ToHexString supports lowercase
|
||||
// Schedules Direct requires the hex to be lowercase
|
||||
string hashedPassword = Convert.ToHexString(hashedPasswordBytes).ToLowerInvariant();
|
||||
string hashedPassword = Convert.ToHexStringLower(hashedPasswordBytes);
|
||||
options.Content = new StringContent("{\"username\":\"" + username + "\",\"password\":\"" + hashedPassword + "\"}", Encoding.UTF8, MediaTypeNames.Application.Json);
|
||||
|
||||
var root = await Request<TokenDto>(options, false, null, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
33
src/Jellyfin.LiveTv/LiveTvChannelImageHelper.cs
Normal file
33
src/Jellyfin.LiveTv/LiveTvChannelImageHelper.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Model.Entities;
|
||||
|
||||
namespace Jellyfin.LiveTv;
|
||||
|
||||
/// <summary>
|
||||
/// Helpers for keeping Live TV channel icons in sync with guide data.
|
||||
/// </summary>
|
||||
internal static class LiveTvChannelImageHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Applies the channel icon from guide or tuner metadata.
|
||||
/// Called on each guide refresh so remote icons are re-downloaded even when the URL is unchanged.
|
||||
/// </summary>
|
||||
/// <param name="item">The channel item.</param>
|
||||
/// <param name="imagePath">The local image path from the tuner, if any.</param>
|
||||
/// <param name="imageUrl">The remote image URL from the guide provider, if any.</param>
|
||||
/// <returns><c>true</c> when the item image metadata was updated.</returns>
|
||||
internal static bool UpdateChannelImageIfNeeded(BaseItem item, string? imagePath, string? imageUrl)
|
||||
{
|
||||
var newImageSource = !string.IsNullOrWhiteSpace(imagePath)
|
||||
? imagePath
|
||||
: imageUrl;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(newImageSource))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
item.SetImagePath(ImageType.Primary, newImageSource);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ using MediaBrowser.Model.Configuration;
|
||||
using MediaBrowser.Model.Dlna;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.MediaInfo;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
@@ -203,6 +204,50 @@ public class EncodingHelperTests
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("aac", 44100, 44100)] // non-opus: requested rate must be preserved (issue #17026)
|
||||
[InlineData("aac", 48000, 48000)]
|
||||
[InlineData("mp3", 22050, 22050)]
|
||||
[InlineData("flac", 96000, 96000)]
|
||||
[InlineData("opus", 44100, 48000)] // opus: must snap to a libopus-supported rate
|
||||
[InlineData("opus", 22050, 24000)]
|
||||
[InlineData("opus", 8000, 8000)]
|
||||
public void GetProgressiveAudioFullCommandLine_SampleRate_OnlyClampedForOpus(
|
||||
string audioCodec,
|
||||
int requestedSampleRate,
|
||||
int expectedSampleRate)
|
||||
{
|
||||
var state = BuildAudioState(audioCodec, requestedSampleRate);
|
||||
var args = CreateHelper().GetProgressiveAudioFullCommandLine(state, new EncodingOptions(), "/tmp/out");
|
||||
|
||||
Assert.Contains("-ar " + expectedSampleRate, args, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static EncodingJobInfo BuildAudioState(string audioCodec, int requestedSampleRate)
|
||||
{
|
||||
var audio = new MediaStream { Index = 0, Type = MediaStreamType.Audio, Codec = "flac", SampleRate = 96000 };
|
||||
|
||||
return new EncodingJobInfo(TranscodingJobType.Progressive)
|
||||
{
|
||||
MediaSource = new MediaSourceInfo
|
||||
{
|
||||
Container = "flac",
|
||||
MediaStreams = new List<MediaStream> { audio },
|
||||
Path = "/media/track.flac",
|
||||
Protocol = MediaProtocol.File,
|
||||
},
|
||||
AudioStream = audio,
|
||||
OutputAudioCodec = audioCodec,
|
||||
BaseRequest = new VideoRequestDto
|
||||
{
|
||||
AudioCodec = audioCodec,
|
||||
AudioSampleRate = requestedSampleRate,
|
||||
},
|
||||
IsVideoRequest = false,
|
||||
IsInputVideo = false,
|
||||
};
|
||||
}
|
||||
|
||||
private static EncodingJobInfo BuildState(
|
||||
MediaStream? subtitle,
|
||||
SubtitleDeliveryMethod? deliveryMethod,
|
||||
|
||||
51
tests/Jellyfin.LiveTv.Tests/LiveTvChannelImageHelperTests.cs
Normal file
51
tests/Jellyfin.LiveTv.Tests/LiveTvChannelImageHelperTests.cs
Normal file
@@ -0,0 +1,51 @@
|
||||
using Jellyfin.LiveTv;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.LiveTv;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.LiveTv.Tests;
|
||||
|
||||
public class LiveTvChannelImageHelperTests
|
||||
{
|
||||
[Fact]
|
||||
public void UpdateChannelImageIfNeeded_NoSource_DoesNotUpdate()
|
||||
{
|
||||
var channel = new LiveTvChannel { Name = "Test Channel" };
|
||||
|
||||
var updated = LiveTvChannelImageHelper.UpdateChannelImageIfNeeded(channel, null, null);
|
||||
|
||||
Assert.False(updated);
|
||||
Assert.False(channel.HasImage(ImageType.Primary));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateChannelImageIfNeeded_WithUrl_AppliesUrl()
|
||||
{
|
||||
var channel = new LiveTvChannel { Name = "Test Channel" };
|
||||
|
||||
var updated = LiveTvChannelImageHelper.UpdateChannelImageIfNeeded(
|
||||
channel,
|
||||
null,
|
||||
"https://example.com/icon.png");
|
||||
|
||||
Assert.True(updated);
|
||||
Assert.True(channel.HasImage(ImageType.Primary));
|
||||
Assert.Equal("https://example.com/icon.png", channel.GetImagePath(ImageType.Primary));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateChannelImageIfNeeded_SameUrl_StillUpdates()
|
||||
{
|
||||
var channel = new LiveTvChannel { Name = "Test Channel" };
|
||||
LiveTvChannelImageHelper.UpdateChannelImageIfNeeded(channel, null, "https://example.com/icon.png");
|
||||
|
||||
var updated = LiveTvChannelImageHelper.UpdateChannelImageIfNeeded(
|
||||
channel,
|
||||
null,
|
||||
"https://example.com/icon.png");
|
||||
|
||||
Assert.True(updated);
|
||||
Assert.Equal("https://example.com/icon.png", channel.GetImagePath(ImageType.Primary));
|
||||
}
|
||||
}
|
||||
@@ -344,6 +344,20 @@ namespace Jellyfin.Server.Implementations.Tests.Localization
|
||||
Assert.NotEqual("Default", translated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLocalizedString_WithBcp47NormalizationToUppercaseRegion_ReturnsTranslation()
|
||||
{
|
||||
var localizationManager = Setup(new ServerConfiguration
|
||||
{
|
||||
UICulture = "en-US"
|
||||
});
|
||||
|
||||
// he-IL normalizes to the underscore resource he_IL. The resource lookup is case-sensitive,
|
||||
// so the region casing has to be preserved or the file is not found and we fall back to en-US.
|
||||
var translated = localizationManager.GetLocalizedString("Books", "he-IL");
|
||||
Assert.Equal("ספרים", translated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetServerLocalizedString_UsesServerCulture()
|
||||
{
|
||||
|
||||
@@ -109,5 +109,29 @@ namespace Jellyfin.Server.Implementations.Tests.Updates
|
||||
var ex = await Record.ExceptionAsync(() => _installationManager.InstallPackage(packageInfo, CancellationToken.None));
|
||||
Assert.Null(ex);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("../evil")]
|
||||
[InlineData("..\\evil")]
|
||||
[InlineData("../../escape_attempt")]
|
||||
[InlineData("..")]
|
||||
[InlineData(".")]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("foo/bar")]
|
||||
[InlineData("foo\\bar")]
|
||||
[InlineData("/absolute")]
|
||||
[InlineData("foo\0bar")]
|
||||
public async Task InstallPackage_InvalidName_ThrowsInvalidDataException(string name)
|
||||
{
|
||||
var packageInfo = new InstallationInfo()
|
||||
{
|
||||
Name = name,
|
||||
SourceUrl = "https://repo.jellyfin.org/releases/plugin/empty/empty.zip",
|
||||
Checksum = "11b5b2f1a9ebc4f66d6ef19018543361"
|
||||
};
|
||||
|
||||
await Assert.ThrowsAsync<InvalidDataException>(() => _installationManager.InstallPackage(packageInfo, CancellationToken.None));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user