diff options
Diffstat (limited to 'Jellyfin.Api/Helpers')
| -rw-r--r-- | Jellyfin.Api/Helpers/RequestHelpers.cs | 107 | ||||
| -rw-r--r-- | Jellyfin.Api/Helpers/SimilarItemsHelper.cs | 182 | ||||
| -rw-r--r-- | Jellyfin.Api/Helpers/TranscodingJobHelper.cs | 354 |
3 files changed, 642 insertions, 1 deletions
diff --git a/Jellyfin.Api/Helpers/RequestHelpers.cs b/Jellyfin.Api/Helpers/RequestHelpers.cs index 9f4d34f9c..fd86feb8b 100644 --- a/Jellyfin.Api/Helpers/RequestHelpers.cs +++ b/Jellyfin.Api/Helpers/RequestHelpers.cs @@ -1,4 +1,10 @@ using System; +using System.Linq; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Net; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.Entities; +using Microsoft.AspNetCore.Http; namespace Jellyfin.Api.Helpers { @@ -14,7 +20,7 @@ namespace Jellyfin.Api.Helpers /// <param name="separator">The char that separates the substrings.</param> /// <param name="removeEmpty">Option to remove empty substrings from the array.</param> /// <returns>An array of the substrings.</returns> - internal static string[] Split(string value, char separator, bool removeEmpty) + internal static string[] Split(string? value, char separator, bool removeEmpty) { if (string.IsNullOrWhiteSpace(value)) { @@ -25,5 +31,104 @@ namespace Jellyfin.Api.Helpers ? value.Split(new[] { separator }, StringSplitOptions.RemoveEmptyEntries) : value.Split(separator); } + + /// <summary> + /// Checks if the user can update an entry. + /// </summary> + /// <param name="authContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param> + /// <param name="requestContext">The <see cref="HttpRequest"/>.</param> + /// <param name="userId">The user id.</param> + /// <param name="restrictUserPreferences">Whether to restrict the user preferences.</param> + /// <returns>A <see cref="bool"/> whether the user can update the entry.</returns> + internal static bool AssertCanUpdateUser(IAuthorizationContext authContext, HttpRequest requestContext, Guid userId, bool restrictUserPreferences) + { + var auth = authContext.GetAuthorizationInfo(requestContext); + + var authenticatedUser = auth.User; + + // If they're going to update the record of another user, they must be an administrator + if ((!userId.Equals(auth.UserId) && !authenticatedUser.HasPermission(PermissionKind.IsAdministrator)) + || (restrictUserPreferences && !authenticatedUser.EnableUserPreferenceAccess)) + { + return false; + } + + return true; + } + + internal static SessionInfo GetSession(ISessionManager sessionManager, IAuthorizationContext authContext, HttpRequest request) + { + var authorization = authContext.GetAuthorizationInfo(request); + var user = authorization.User; + var session = sessionManager.LogSessionActivity( + authorization.Client, + authorization.Version, + authorization.DeviceId, + authorization.Device, + request.HttpContext.Connection.RemoteIpAddress.ToString(), + user); + + if (session == null) + { + throw new ArgumentException("Session not found."); + } + + return session; + } + + /// <summary> + /// Get Guid array from string. + /// </summary> + /// <param name="value">String value.</param> + /// <returns>Guid array.</returns> + internal static Guid[] GetGuids(string? value) + { + if (value == null) + { + return Array.Empty<Guid>(); + } + + return Split(value, ',', true) + .Select(i => new Guid(i)) + .ToArray(); + } + + /// <summary> + /// Get orderby. + /// </summary> + /// <param name="sortBy">Sort by.</param> + /// <param name="requestedSortOrder">Sort order.</param> + /// <returns>Resulting order by.</returns> + internal static ValueTuple<string, SortOrder>[] GetOrderBy(string? sortBy, string? requestedSortOrder) + { + if (string.IsNullOrEmpty(sortBy)) + { + return Array.Empty<ValueTuple<string, SortOrder>>(); + } + + var vals = sortBy.Split(','); + if (string.IsNullOrWhiteSpace(requestedSortOrder)) + { + requestedSortOrder = "Ascending"; + } + + var sortOrders = requestedSortOrder.Split(','); + + var result = new ValueTuple<string, SortOrder>[vals.Length]; + + for (var i = 0; i < vals.Length; i++) + { + var sortOrderIndex = sortOrders.Length > i ? i : 0; + + var sortOrderValue = sortOrders.Length > sortOrderIndex ? sortOrders[sortOrderIndex] : null; + var sortOrder = string.Equals(sortOrderValue, "Descending", StringComparison.OrdinalIgnoreCase) + ? SortOrder.Descending + : SortOrder.Ascending; + + result[i] = new ValueTuple<string, SortOrder>(vals[i], sortOrder); + } + + return result; + } } } diff --git a/Jellyfin.Api/Helpers/SimilarItemsHelper.cs b/Jellyfin.Api/Helpers/SimilarItemsHelper.cs new file mode 100644 index 000000000..fd0c31504 --- /dev/null +++ b/Jellyfin.Api/Helpers/SimilarItemsHelper.cs @@ -0,0 +1,182 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Querying; + +namespace Jellyfin.Api.Helpers +{ + /// <summary> + /// The similar items helper class. + /// </summary> + public static class SimilarItemsHelper + { + internal static QueryResult<BaseItemDto> GetSimilarItemsResult( + DtoOptions dtoOptions, + IUserManager userManager, + ILibraryManager libraryManager, + IDtoService dtoService, + Guid userId, + string id, + string? excludeArtistIds, + int? limit, + Type[] includeTypes, + Func<BaseItem, List<PersonInfo>, List<PersonInfo>, BaseItem, int> getSimilarityScore) + { + var user = !userId.Equals(Guid.Empty) ? userManager.GetUserById(userId) : null; + + var item = string.IsNullOrEmpty(id) ? + (!userId.Equals(Guid.Empty) ? libraryManager.GetUserRootFolder() : + libraryManager.RootFolder) : libraryManager.GetItemById(id); + + var query = new InternalItemsQuery(user) + { + IncludeItemTypes = includeTypes.Select(i => i.Name).ToArray(), + Recursive = true, + DtoOptions = dtoOptions + }; + + query.ExcludeArtistIds = RequestHelpers.GetGuids(excludeArtistIds); + + var inputItems = libraryManager.GetItemList(query); + + var items = GetSimilaritems(item, libraryManager, inputItems, getSimilarityScore) + .ToList(); + + var returnItems = items; + + if (limit.HasValue) + { + returnItems = returnItems.Take(limit.Value).ToList(); + } + + var dtos = dtoService.GetBaseItemDtos(returnItems, dtoOptions, user); + + return new QueryResult<BaseItemDto> + { + Items = dtos, + TotalRecordCount = items.Count + }; + } + + /// <summary> + /// Gets the similaritems. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="libraryManager">The library manager.</param> + /// <param name="inputItems">The input items.</param> + /// <param name="getSimilarityScore">The get similarity score.</param> + /// <returns>IEnumerable{BaseItem}.</returns> + private static IEnumerable<BaseItem> GetSimilaritems( + BaseItem item, + ILibraryManager libraryManager, + IEnumerable<BaseItem> inputItems, + Func<BaseItem, List<PersonInfo>, List<PersonInfo>, BaseItem, int> getSimilarityScore) + { + var itemId = item.Id; + inputItems = inputItems.Where(i => i.Id != itemId); + var itemPeople = libraryManager.GetPeople(item); + var allPeople = libraryManager.GetPeople(new InternalPeopleQuery + { + AppearsInItemId = item.Id + }); + + return inputItems.Select(i => new Tuple<BaseItem, int>(i, getSimilarityScore(item, itemPeople, allPeople, i))) + .Where(i => i.Item2 > 2) + .OrderByDescending(i => i.Item2) + .Select(i => i.Item1); + } + + private static IEnumerable<string> GetTags(BaseItem item) + { + return item.Tags; + } + + /// <summary> + /// Gets the similiarity score. + /// </summary> + /// <param name="item1">The item1.</param> + /// <param name="item1People">The item1 people.</param> + /// <param name="allPeople">All people.</param> + /// <param name="item2">The item2.</param> + /// <returns>System.Int32.</returns> + internal static int GetSimiliarityScore(BaseItem item1, List<PersonInfo> item1People, List<PersonInfo> allPeople, BaseItem item2) + { + var points = 0; + + if (!string.IsNullOrEmpty(item1.OfficialRating) && string.Equals(item1.OfficialRating, item2.OfficialRating, StringComparison.OrdinalIgnoreCase)) + { + points += 10; + } + + // Find common genres + points += item1.Genres.Where(i => item2.Genres.Contains(i, StringComparer.OrdinalIgnoreCase)).Sum(i => 10); + + // Find common tags + points += GetTags(item1).Where(i => GetTags(item2).Contains(i, StringComparer.OrdinalIgnoreCase)).Sum(i => 10); + + // Find common studios + points += item1.Studios.Where(i => item2.Studios.Contains(i, StringComparer.OrdinalIgnoreCase)).Sum(i => 3); + + var item2PeopleNames = allPeople.Where(i => i.ItemId == item2.Id) + .Select(i => i.Name) + .Where(i => !string.IsNullOrWhiteSpace(i)) + .DistinctNames() + .ToDictionary(i => i, StringComparer.OrdinalIgnoreCase); + + points += item1People.Where(i => item2PeopleNames.ContainsKey(i.Name)).Sum(i => + { + if (string.Equals(i.Type, PersonType.Director, StringComparison.OrdinalIgnoreCase) || string.Equals(i.Role, PersonType.Director, StringComparison.OrdinalIgnoreCase)) + { + return 5; + } + + if (string.Equals(i.Type, PersonType.Actor, StringComparison.OrdinalIgnoreCase) || string.Equals(i.Role, PersonType.Actor, StringComparison.OrdinalIgnoreCase)) + { + return 3; + } + + if (string.Equals(i.Type, PersonType.Composer, StringComparison.OrdinalIgnoreCase) || string.Equals(i.Role, PersonType.Composer, StringComparison.OrdinalIgnoreCase)) + { + return 3; + } + + if (string.Equals(i.Type, PersonType.GuestStar, StringComparison.OrdinalIgnoreCase) || string.Equals(i.Role, PersonType.GuestStar, StringComparison.OrdinalIgnoreCase)) + { + return 3; + } + + if (string.Equals(i.Type, PersonType.Writer, StringComparison.OrdinalIgnoreCase) || string.Equals(i.Role, PersonType.Writer, StringComparison.OrdinalIgnoreCase)) + { + return 2; + } + + return 1; + }); + + if (item1.ProductionYear.HasValue && item2.ProductionYear.HasValue) + { + var diff = Math.Abs(item1.ProductionYear.Value - item2.ProductionYear.Value); + + // Add if they came out within the same decade + if (diff < 10) + { + points += 2; + } + + // And more if within five years + if (diff < 5) + { + points += 2; + } + } + + return points; + } + } +} diff --git a/Jellyfin.Api/Helpers/TranscodingJobHelper.cs b/Jellyfin.Api/Helpers/TranscodingJobHelper.cs new file mode 100644 index 000000000..44f662e6e --- /dev/null +++ b/Jellyfin.Api/Helpers/TranscodingJobHelper.cs @@ -0,0 +1,354 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Api.Models.PlaybackDtos; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Model.IO; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Api.Helpers +{ + /// <summary> + /// Transcoding job helpers. + /// </summary> + public class TranscodingJobHelper + { + /// <summary> + /// The active transcoding jobs. + /// </summary> + private static readonly List<TranscodingJobDto> _activeTranscodingJobs = new List<TranscodingJobDto>(); + + /// <summary> + /// The transcoding locks. + /// </summary> + private static readonly Dictionary<string, SemaphoreSlim> _transcodingLocks = new Dictionary<string, SemaphoreSlim>(); + + private readonly ILogger<TranscodingJobHelper> _logger; + private readonly IMediaSourceManager _mediaSourceManager; + private readonly IFileSystem _fileSystem; + + /// <summary> + /// Initializes a new instance of the <see cref="TranscodingJobHelper"/> class. + /// </summary> + /// <param name="logger">Instance of the <see cref="ILogger{TranscodingJobHelpers}"/> interface.</param> + /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param> + /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> + public TranscodingJobHelper( + ILogger<TranscodingJobHelper> logger, + IMediaSourceManager mediaSourceManager, + IFileSystem fileSystem) + { + _logger = logger; + _mediaSourceManager = mediaSourceManager; + _fileSystem = fileSystem; + } + + /// <summary> + /// Get transcoding job. + /// </summary> + /// <param name="playSessionId">Playback session id.</param> + /// <returns>The transcoding job.</returns> + public TranscodingJobDto GetTranscodingJob(string playSessionId) + { + lock (_activeTranscodingJobs) + { + return _activeTranscodingJobs.FirstOrDefault(j => string.Equals(j.PlaySessionId, playSessionId, StringComparison.OrdinalIgnoreCase)); + } + } + + /// <summary> + /// Ping transcoding job. + /// </summary> + /// <param name="playSessionId">Play session id.</param> + /// <param name="isUserPaused">Is user paused.</param> + /// <exception cref="ArgumentNullException">Play session id is null.</exception> + public void PingTranscodingJob(string playSessionId, bool? isUserPaused) + { + if (string.IsNullOrEmpty(playSessionId)) + { + throw new ArgumentNullException(nameof(playSessionId)); + } + + _logger.LogDebug("PingTranscodingJob PlaySessionId={0} isUsedPaused: {1}", playSessionId, isUserPaused); + + List<TranscodingJobDto> jobs; + + lock (_activeTranscodingJobs) + { + // This is really only needed for HLS. + // Progressive streams can stop on their own reliably + jobs = _activeTranscodingJobs.Where(j => string.Equals(playSessionId, j.PlaySessionId, StringComparison.OrdinalIgnoreCase)).ToList(); + } + + foreach (var job in jobs) + { + if (isUserPaused.HasValue) + { + _logger.LogDebug("Setting job.IsUserPaused to {0}. jobId: {1}", isUserPaused, job.Id); + job.IsUserPaused = isUserPaused.Value; + } + + PingTimer(job, true); + } + } + + private void PingTimer(TranscodingJobDto job, bool isProgressCheckIn) + { + if (job.HasExited) + { + job.StopKillTimer(); + return; + } + + var timerDuration = 10000; + + if (job.Type != TranscodingJobType.Progressive) + { + timerDuration = 60000; + } + + job.PingTimeout = timerDuration; + job.LastPingDate = DateTime.UtcNow; + + // Don't start the timer for playback checkins with progressive streaming + if (job.Type != TranscodingJobType.Progressive || !isProgressCheckIn) + { + job.StartKillTimer(OnTranscodeKillTimerStopped); + } + else + { + job.ChangeKillTimerIfStarted(); + } + } + + /// <summary> + /// Called when [transcode kill timer stopped]. + /// </summary> + /// <param name="state">The state.</param> + private async void OnTranscodeKillTimerStopped(object state) + { + var job = (TranscodingJobDto)state; + + if (!job.HasExited && job.Type != TranscodingJobType.Progressive) + { + var timeSinceLastPing = (DateTime.UtcNow - job.LastPingDate).TotalMilliseconds; + + if (timeSinceLastPing < job.PingTimeout) + { + job.StartKillTimer(OnTranscodeKillTimerStopped, job.PingTimeout); + return; + } + } + + _logger.LogInformation("Transcoding kill timer stopped for JobId {0} PlaySessionId {1}. Killing transcoding", job.Id, job.PlaySessionId); + + await KillTranscodingJob(job, true, path => true).ConfigureAwait(false); + } + + /// <summary> + /// Kills the single transcoding job. + /// </summary> + /// <param name="deviceId">The device id.</param> + /// <param name="playSessionId">The play session identifier.</param> + /// <param name="deleteFiles">The delete files.</param> + /// <returns>Task.</returns> + public Task KillTranscodingJobs(string deviceId, string playSessionId, Func<string, bool> deleteFiles) + { + return KillTranscodingJobs( + j => string.IsNullOrWhiteSpace(playSessionId) + ? string.Equals(deviceId, j.DeviceId, StringComparison.OrdinalIgnoreCase) + : string.Equals(playSessionId, j.PlaySessionId, StringComparison.OrdinalIgnoreCase), deleteFiles); + } + + /// <summary> + /// Kills the transcoding jobs. + /// </summary> + /// <param name="killJob">The kill job.</param> + /// <param name="deleteFiles">The delete files.</param> + /// <returns>Task.</returns> + private Task KillTranscodingJobs(Func<TranscodingJobDto, bool> killJob, Func<string, bool> deleteFiles) + { + var jobs = new List<TranscodingJobDto>(); + + lock (_activeTranscodingJobs) + { + // This is really only needed for HLS. + // Progressive streams can stop on their own reliably + jobs.AddRange(_activeTranscodingJobs.Where(killJob)); + } + + if (jobs.Count == 0) + { + return Task.CompletedTask; + } + + IEnumerable<Task> GetKillJobs() + { + foreach (var job in jobs) + { + yield return KillTranscodingJob(job, false, deleteFiles); + } + } + + return Task.WhenAll(GetKillJobs()); + } + + /// <summary> + /// Kills the transcoding job. + /// </summary> + /// <param name="job">The job.</param> + /// <param name="closeLiveStream">if set to <c>true</c> [close live stream].</param> + /// <param name="delete">The delete.</param> + private async Task KillTranscodingJob(TranscodingJobDto job, bool closeLiveStream, Func<string, bool> delete) + { + job.DisposeKillTimer(); + + _logger.LogDebug("KillTranscodingJob - JobId {0} PlaySessionId {1}. Killing transcoding", job.Id, job.PlaySessionId); + + lock (_activeTranscodingJobs) + { + _activeTranscodingJobs.Remove(job); + + if (!job.CancellationTokenSource!.IsCancellationRequested) + { + job.CancellationTokenSource.Cancel(); + } + } + + lock (_transcodingLocks) + { + _transcodingLocks.Remove(job.Path!); + } + + lock (job.ProcessLock!) + { + job.TranscodingThrottler?.Stop().GetAwaiter().GetResult(); + + var process = job.Process; + + var hasExited = job.HasExited; + + if (!hasExited) + { + try + { + _logger.LogInformation("Stopping ffmpeg process with q command for {Path}", job.Path); + + process!.StandardInput.WriteLine("q"); + + // Need to wait because killing is asynchronous + if (!process.WaitForExit(5000)) + { + _logger.LogInformation("Killing ffmpeg process for {Path}", job.Path); + process.Kill(); + } + } + catch (InvalidOperationException) + { + } + } + } + + if (delete(job.Path!)) + { + await DeletePartialStreamFiles(job.Path!, job.Type, 0, 1500).ConfigureAwait(false); + } + + if (closeLiveStream && !string.IsNullOrWhiteSpace(job.LiveStreamId)) + { + try + { + await _mediaSourceManager.CloseLiveStream(job.LiveStreamId).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error closing live stream for {Path}", job.Path); + } + } + } + + private async Task DeletePartialStreamFiles(string path, TranscodingJobType jobType, int retryCount, int delayMs) + { + if (retryCount >= 10) + { + return; + } + + _logger.LogInformation("Deleting partial stream file(s) {Path}", path); + + await Task.Delay(delayMs).ConfigureAwait(false); + + try + { + if (jobType == TranscodingJobType.Progressive) + { + DeleteProgressivePartialStreamFiles(path); + } + else + { + DeleteHlsPartialStreamFiles(path); + } + } + catch (IOException ex) + { + _logger.LogError(ex, "Error deleting partial stream file(s) {Path}", path); + + await DeletePartialStreamFiles(path, jobType, retryCount + 1, 500).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting partial stream file(s) {Path}", path); + } + } + + /// <summary> + /// Deletes the progressive partial stream files. + /// </summary> + /// <param name="outputFilePath">The output file path.</param> + private void DeleteProgressivePartialStreamFiles(string outputFilePath) + { + if (File.Exists(outputFilePath)) + { + _fileSystem.DeleteFile(outputFilePath); + } + } + + /// <summary> + /// Deletes the HLS partial stream files. + /// </summary> + /// <param name="outputFilePath">The output file path.</param> + private void DeleteHlsPartialStreamFiles(string outputFilePath) + { + var directory = Path.GetDirectoryName(outputFilePath); + var name = Path.GetFileNameWithoutExtension(outputFilePath); + + var filesToDelete = _fileSystem.GetFilePaths(directory) + .Where(f => f.IndexOf(name, StringComparison.OrdinalIgnoreCase) != -1); + + List<Exception>? exs = null; + foreach (var file in filesToDelete) + { + try + { + _logger.LogDebug("Deleting HLS file {0}", file); + _fileSystem.DeleteFile(file); + } + catch (IOException ex) + { + (exs ??= new List<Exception>(4)).Add(ex); + _logger.LogError(ex, "Error deleting HLS file {Path}", file); + } + } + + if (exs != null) + { + throw new AggregateException("Error deleting HLS files", exs); + } + } + } +} |
